diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index b25c545a8a..1a2befbd9c 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -343,8 +343,9 @@ export const reconstructJavaScriptArtifact = async ( const OPERATION = "analyze_javascript_application"; /** - * Resolve the caller-selected input. A missing or unreadable selection is a - * caller or host-permission failure, not a damaged artifact. + * Resolve the caller-selected input. A missing, looping, overlong, or + * unreadable selection is a caller or host-resolution failure, not a damaged + * artifact. */ const resolveSelectedInput = async (inputPath: string): Promise => { try { @@ -366,10 +367,26 @@ const resolveSelectedInput = async (inputPath: string): Promise => { message: `No file or directory exists at the selected input path (${code}): ${inputPath}`, }, ]); + if (code === "ELOOP" || code === "ENAMETOOLONG") + throw new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["input_path"], + reason: "invalid_value", + message: selectedInputResolutionMessage(code, inputPath), + }, + ]); throw cause; } }; +const selectedInputResolutionMessage = ( + code: "ELOOP" | "ENAMETOOLONG", + inputPath: string, +): string => + code === "ENAMETOOLONG" + ? `Selected input path exceeds the host path or component length limit (ENAMETOOLONG): ${inputPath}` + : `Selected input path could not be resolved because symbolic-link traversal looped or exceeded the host limit (ELOOP): ${inputPath}`; + /** The selected input's kind, checked against the caller's requested format. */ const resolveFormat = async ( path: string, diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts index c3c4fea0fc..ecf70049da 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts @@ -172,6 +172,56 @@ it("continues to project an ordinary member access as a property read", () => { ); }); +it("keeps a directly returned or passed property in backward provenance", () => { + const source = ` + const settings = { index: "products" }; + function viaLocal() { + const value = settings.index; + return value; + } + function viaMember() { + return settings.index; + } + function viaParen() { + return (settings.index); + } + const arrow = () => settings.index; + function take(value) { return value; } + function added() { return settings.index + "!"; } + const a = viaLocal(); + const b = viaMember(); + const direct = settings.index; + const c = viaParen(); + const d = arrow(); + const e = take(settings.index); + const g = take((settings.index)); + const kept = settings.index; + const local = take(kept); + const concatenated = added(); + `; + const graph = graphFor(source); + const provenance = (binding: string, candidates: boolean) => { + const node = graph.nodes.find( + (item) => item.kind === "binding" && item.label === binding, + ); + if (node === undefined) throw new Error(`missing ${binding}`); + const trace = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "semantic-node", node_id: node.node_id }, + direction: "backward-provenance", + include_ambiguous_dynamic_edges: candidates, + }); + return trace.nodes.some( + (item) => item.kind === "literal" && item.properties.value === "products", + ); + }; + for (const binding of ["a", "b", "direct", "c", "d", "e", "g", "local"]) { + expect(provenance(binding, false), binding).toBe(true); + expect(provenance(binding, true), `${binding} candidates`).toBe(true); + } + expect(provenance("concatenated", false)).toBe(false); + expect(provenance("concatenated", true)).toBe(false); +}); + it("keeps dynamic calls explicit and returns complete deterministic results", () => { const graph = graphFor(` const handlers = { ready() { return 1; } }; diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts index 3417fbb895..a890d54225 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts @@ -60,6 +60,7 @@ import { projectSemanticFrontiers, projectSemanticPromises, projectSemanticReturnValues, + semanticObjectReadOccurrence, type SemanticFlowProjectionContext, } from "./JavaScriptSemanticGraphFlowProjection.js"; import { @@ -828,6 +829,15 @@ const projectCalls = (context: FileContext): void => { ? "resolved" : "candidate", }); + addRelation(context.state, { + source: semanticObjectReadOccurrence( + context, + argument.valueExpressionLocation, + ), + target: argumentNode, + relation: "aliases", + resolution: "resolved", + }); } } for (const flow of context.ir.argumentFlows) { diff --git a/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts b/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts index 052a132618..59ab8b52de 100644 --- a/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts @@ -1,4 +1,5 @@ import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; +import { javaScriptSemanticNodeId } from "../../domain/javascript/javascriptSemanticGraph.js"; import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js"; import { sourceRangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js"; import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; @@ -190,6 +191,57 @@ const addPromiseUnknown = ( }); }; +interface ObjectReadIndex { + readonly state: SemanticGraphProjectionState; + readonly nodes: ReadonlyMap; +} + +const objectReadIndexes = new WeakMap(); + +const objectReadRangeKey = (location: JavaScriptSourceRange): string => + `${String(location.start.line)}:${String(location.start.column)}:${String(location.end.line)}:${String(location.end.column)}`; + +/** + * The object projection owns a member read's occurrence. Index it once per + * file so return and argument flow can connect that expression without + * scanning every property read again. + */ +const objectReadOccurrences = ( + context: SemanticFlowProjectionContext, +): ReadonlyMap => { + const cached = objectReadIndexes.get(context.ir); + if (cached !== undefined && cached.state === context.state) + return cached.nodes; + const nodes = new Map(); + for (const operation of context.ir.objectOperations) { + if (operation.kind !== "read") continue; + const node = context.state.nodes.get( + javaScriptSemanticNodeId({ + kind: "expression", + identity: { + artifact_sha256: context.file.sha256, + module_path: context.file.path, + source_range: operation.location, + role_key: operation.objectOperationId, + }, + }), + ); + if (node !== undefined) + nodes.set(objectReadRangeKey(operation.location), node); + } + objectReadIndexes.set(context.ir, { state: context.state, nodes }); + return nodes; +}; + +/** Return the retained read occurrence for one unwrapped member expression. */ +export const semanticObjectReadOccurrence = ( + context: SemanticFlowProjectionContext, + location: JavaScriptSourceRange | null, +): JavaScriptSemanticGraphNode | undefined => + location === null + ? undefined + : objectReadOccurrences(context).get(objectReadRangeKey(location)); + /** Link direct return expressions to their retained semantic references. */ export const projectSemanticReturnValues = ( context: SemanticFlowProjectionContext, @@ -212,6 +264,15 @@ export const projectSemanticReturnValues = ( ? "resolved" : "candidate", }); + addSemanticGraphRelation(context.state, { + source: semanticObjectReadOccurrence( + context, + site.valueExpressionLocation, + ), + target: returnNode, + relation: "aliases", + resolution: "resolved", + }); } }; diff --git a/src/domain/analysisView/analysisView.test.ts b/src/domain/analysisView/analysisView.test.ts index 87d1930f19..6e79299724 100644 --- a/src/domain/analysisView/analysisView.test.ts +++ b/src/domain/analysisView/analysisView.test.ts @@ -429,6 +429,54 @@ it("rejects ambiguous layout names with candidate indexes", () => { }); }); +it("counts JavaScript summary coverage in module units", () => { + const analysis = analysisViewJavaScriptAnalysisWithSource(); + const moduleNode = analysis.graph.nodes[0]; + const observation = moduleNode?.observations[0]; + if (moduleNode === undefined || observation === undefined) + throw new Error("missing module observation"); + const packageNode = createJavaScriptApplicationNode({ + kind: "artifact", + identity: moduleNode.identity, + observations: moduleNode.observations.map((item) => ({ + label: item.label, + properties: item.properties, + evidence: item.evidence, + ...(item.source_map_reference === null + ? {} + : { source_map_reference: item.source_map_reference }), + })), + }); + const graph = createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + root_node_ids: [moduleNode.node_id], + nodes: [moduleNode, packageNode], + edges: [], + coverage: analysis.graph.coverage, + limitations: analysis.graph.limitations, + }); + const parent = javascriptParent( + analysisViewBindJavaScriptGraphs(analysis, graph), + ); + const summary = projectAnalysisView(parent, { kind: "summary" }); + if (!summary.ok) throw summary.error; + expect(summary.value.coverage).toEqual({ + status: "complete-within-view", + examined: 1, + total: 1, + next_offset: null, + exhausted: true, + }); + const page = projectAnalysisView(parent, { + kind: "page", + collection: "modules", + offset: 0, + limit: 8, + }); + if (!page.ok) throw page.error; + expect(page.value.coverage).toMatchObject({ examined: 1, total: 1 }); +}); + it("projects JavaScript summary and preserves selected module observations", () => { const analysis = analysisViewJavaScriptAnalysisWithSource(); const parent = javascriptParent(analysis); diff --git a/src/domain/analysisView/javascriptApplicationView.ts b/src/domain/analysisView/javascriptApplicationView.ts index ca9d96b76e..13490df699 100644 --- a/src/domain/analysisView/javascriptApplicationView.ts +++ b/src/domain/analysisView/javascriptApplicationView.ts @@ -350,6 +350,8 @@ export const projectJavaScriptApplicationView = ( }); } if (view.kind === "summary") { + // Summary coverage counts modules. Graph nodes that are not modules stay + // in summary.coverage.application rather than inflating this total. const moduleCount = analysis.graph.nodes.reduce( (count, node) => count + Number(isModuleNode(node)), 0, @@ -384,7 +386,7 @@ export const projectJavaScriptApplicationView = ( }), limitation_count: analysis.limitations.length, }, - coverage: completeCoverage(moduleCount, analysis.graph.nodes.length), + coverage: completeCoverage(moduleCount, moduleCount), ...shared, }); } diff --git a/src/domain/javascript/javascriptSemanticAnalysis.unions.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.unions.test.ts index cfd7da8795..eebaf98daf 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.unions.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.unions.test.ts @@ -503,6 +503,37 @@ describe("semantic expression depth bounds", () => { }); }); + it("counts erased TypeScript wrappers on a return against expression depth", () => { + const source = "function answer() { return true; }"; + const parsed = parseJavaScriptSource(source); + const declaration = parsed?.program.body[0]; + const statement = t.isFunctionDeclaration(declaration) + ? declaration.body.body[0] + : undefined; + const argument = t.isReturnStatement(statement) + ? statement.argument + : undefined; + if (parsed === null || !t.isReturnStatement(statement) || argument == null) + throw new Error("Expected a returned value"); + let expression = argument; + for (let index = 0; index < 300; index += 1) + expression = t.tsAsExpression(expression, t.tsBooleanKeyword()); + statement.argument = expression; + + const ir = analyzeParsedJavaScriptSemantics(parsed); + const site = ir.callables.find((item) => item.name === "answer") + ?.returnSites[0]; + expect(site?.value).toMatchObject({ + status: "unknown", + resourceLimit: "expression-depth", + }); + const literalStart = source.indexOf("true"); + expect(site?.valueExpressionLocation).toEqual({ + start: { line: 1, column: literalStart }, + end: { line: 1, column: literalStart + "true".length }, + }); + }); + it("counts erased TypeScript wrappers against expression depth", () => { const parsed = parseJavaScriptSource("const answer = true;"); const declaration = parsed?.program.body[0]; diff --git a/src/domain/javascript/javascriptSemanticCalls.ts b/src/domain/javascript/javascriptSemanticCalls.ts index 65c342fefb..3abe4579a6 100644 --- a/src/domain/javascript/javascriptSemanticCalls.ts +++ b/src/domain/javascript/javascriptSemanticCalls.ts @@ -11,7 +11,10 @@ import type { } from "./javascriptSemanticIr.js"; import { semanticReferenceRole } from "./javascriptSemanticProjection.js"; import { createSemanticCallableStack } from "./javascriptSemanticCallableStack.js"; -import { semanticStaticPropertyKey } from "./javascriptAstValues.js"; +import { + semanticStaticPropertyKey, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; import { resolveSemanticBindingState, isUnshadowedGlobal, @@ -210,9 +213,14 @@ const retainedArguments = ( for (const [index, node] of nodes.entries()) { // Babel recovery can leave null slots; retain the other argument positions. if (node === null) continue; + const valueNode = + t.isExpression(node) && !t.isSpreadElement(node) + ? unwrapJavaScriptExpression(node).node + : null; retained.push({ index, location: range(node), + valueExpressionLocation: valueNode === null ? null : range(valueNode), spread: t.isSpreadElement(node), }); } diff --git a/src/domain/javascript/javascriptSemanticIr.ts b/src/domain/javascript/javascriptSemanticIr.ts index 5355b3ad33..f153ea39ad 100644 --- a/src/domain/javascript/javascriptSemanticIr.ts +++ b/src/domain/javascript/javascriptSemanticIr.ts @@ -109,6 +109,11 @@ export interface JavaScriptSemanticReturnSite { readonly returnSiteId: string; readonly location: JavaScriptSourceRange; readonly identityReferenceLocation: JavaScriptSourceRange | null; + /** + * Range of the unwrapped returned expression. Null when the return has no + * value. This is the value's syntax, which may be wider than an identifier. + */ + readonly valueExpressionLocation: JavaScriptSourceRange | null; readonly value: JavaScriptSemanticValue; } @@ -128,6 +133,8 @@ export interface JavaScriptSemanticCallSite { export interface JavaScriptSemanticCallArgument { readonly index: number; readonly location: JavaScriptSourceRange; + /** Unwrapped value expression; null for a spread or non-expression slot. */ + readonly valueExpressionLocation: JavaScriptSourceRange | null; readonly spread: boolean; } diff --git a/src/domain/javascript/javascriptSemanticReturns.ts b/src/domain/javascript/javascriptSemanticReturns.ts index 61a90203a0..a34b7be780 100644 --- a/src/domain/javascript/javascriptSemanticReturns.ts +++ b/src/domain/javascript/javascriptSemanticReturns.ts @@ -54,6 +54,10 @@ export const collectSemanticReturns = ( const expressions = directReturnExpressions(node); const sites: JavaScriptSemanticReturnSite[] = []; for (const expression of expressions) { + const returned = + expression.node === null + ? null + : unwrapJavaScriptExpression(expression.node).node; sites.push({ returnSiteId: semanticReturnSiteId( callable.callableId, @@ -61,9 +65,10 @@ export const collectSemanticReturns = ( ), location: expression.location, identityReferenceLocation: - expression.node !== null && t.isIdentifier(expression.node) - ? range(expression.node) + returned !== null && t.isIdentifier(returned) + ? range(returned) : null, + valueExpressionLocation: returned === null ? null : range(returned), value: expression.node === null ? { status: "unknown", reason: "Return has no value." } diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index 30bb2c176b..f238ee9700 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -630,6 +630,7 @@ const callable = z.strictObject({ returnSiteId: z.string(), location: range, identityReferenceLocation: range.nullable(), + valueExpressionLocation: range.nullable(), value: semanticValue, }), ), diff --git a/tests/boundary/filesystem/javascriptApplicationInputSelection.test.ts b/tests/boundary/filesystem/javascriptApplicationInputSelection.test.ts index 390ea6664c..e0af6b86da 100644 --- a/tests/boundary/filesystem/javascriptApplicationInputSelection.test.ts +++ b/tests/boundary/filesystem/javascriptApplicationInputSelection.test.ts @@ -1,4 +1,4 @@ -import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { chmod, mkdir, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it, onTestFinished } from "vitest"; @@ -23,6 +23,74 @@ const application = async () => { }; describe("JavaScript application input selection", () => { + it("preserves the selected path and host reason for an overlong name", async () => { + const { root } = await application(); + const longPath = join(root, "x".repeat(300)); + + expect(await analyzeFailure({ input_path: longPath })).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["input_path"], + reason: "invalid_value", + message: expect.stringContaining( + `Selected input path exceeds the host path or component length limit (ENAMETOOLONG): ${longPath}`, + ), + }, + ], + }, + }); + }); + + it.skipIf(process.platform === "win32")( + "preserves the selected path when symlink traversal loops or exceeds the host limit", + async () => { + const { root } = await application(); + const loopPath = join(root, "loop-a"); + await symlink("loop-b", loopPath); + await symlink("loop-a", join(root, "loop-b")); + await writeFile(join(root, "chain-end"), ""); + let target = "chain-end"; + for (let index = 59; index >= 0; index -= 1) { + const name = `chain-${String(index)}`; + await symlink(target, join(root, name)); + target = name; + } + const chainPath = join(root, "chain-0"); + + const loopError = await analyzeFailure({ input_path: loopPath }); + const chainError = await analyzeFailure({ input_path: chainPath }); + const eloopMessage = (path: string) => + `Selected input path could not be resolved because symbolic-link traversal looped or exceeded the host limit (ELOOP): ${path}`; + expect(loopError).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["input_path"], + reason: "invalid_value", + message: expect.stringContaining(eloopMessage(loopPath)), + }, + ], + }, + }); + expect(chainError).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["input_path"], + reason: "invalid_value", + message: expect.stringContaining(eloopMessage(chainPath)), + }, + ], + }, + }); + expect(loopError).not.toEqual(chainError); + }, + ); + it("reports a missing input path as invalid input, not an artifact read failure", async () => { const { root } = await application(); const missing = join(root, "missing");