From 5c05eb88e577843c68946f4c7fcf93a3a2547c22 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:06:55 +0800 Subject: [PATCH] fix(javascript)!: preserve operator influence in semantic traces (#7939) * fix(javascript)!: preserve operator influence in semantic traces * test(javascript): align semantic influence regressions * style(javascript): format semantic IR * test(javascript): follow literal argument seed influence * fix(javascript): keep one literal node per operator operand Reuse an existing literal for operator influence, and treat TypeScript assertion wrappers as transparent when deciding whether a call result is used. * test(mcp): assert bundle path issues by field compare_bundles reports each relative path as an invalid_format issue instead of one joined diagnostic sentence. * fix(javascript): index operator literals by range and initializer Repeated string operands rescanned every retained literal and every initializer flow. Lookup now uses those indexes, and a 1200-declaration file still projects one literal per operand. * fix(javascript): keep literal and call facts on their own occurrences Operator literals match their own source range, so a nested function does not inherit the enclosing declaration's literal. A direct argument still reaches the call, while the assigned result follows modeled returns. for-loop initializers and updates are discarded call results. * test(javascript): expect conditional operand literals beside binding values A conditional binding keeps one candidate literal per union value and one operator literal per operand token. Those are different facts, so the aggregate payload check counts four nodes and compares literal seeds in query order. * perf(javascript): index call return flows by call site projectCalls filtered every call return for each assigned result. Group those flows once while projecting returns-to-call edges. --------- Co-authored-by: Cursor Agent --- docs/javascript-application-workflows.md | 19 + ...ScriptSemanticGraphAssignmentProjection.ts | 14 +- .../JavaScriptSemanticGraphBuilder.test.ts | 7 +- .../JavaScriptSemanticGraphBuilder.ts | 94 ++++- ...ptSemanticGraphExpressionInfluence.test.ts | 356 ++++++++++++++++ ...ScriptSemanticGraphExpressionProjection.ts | 396 ++++++++++++++++++ .../JavaScriptSemanticGraphFlowProjection.ts | 16 +- .../JavaScriptSemanticGraphResources.test.ts | 14 +- .../JavaScriptSemanticUnknownCapacity.test.ts | 22 + .../javascriptAnalysisWorkerProcess.ts | 1 + .../javascript/javascriptCallLiteral.test.ts | 10 +- .../javascriptSemanticAnalysis.calls.test.ts | 38 ++ ...vascriptSemanticAnalysis.rejection.test.ts | 6 +- .../javascript/javascriptSemanticAnalysis.ts | 27 +- .../javascript/javascriptSemanticCalls.ts | 28 ++ .../javascriptSemanticDerivedAnalysis.ts | 181 +++++++- .../javascriptSemanticGraphSchemas.ts | 2 + .../javascriptSemanticGraphTraversal.test.ts | 26 +- src/domain/javascript/javascriptSemanticIr.ts | 32 ++ .../javascriptSemanticQueryAssessment.ts | 2 + .../javascriptSemanticQueryIdentity.ts | 27 +- ...scriptFeatureTraceSeedSelectionCli.test.ts | 13 +- .../javascriptSemanticTraceEvidence.test.ts | 84 ++++ .../mcp/javascriptLiteralPayload.test.ts | 8 +- 24 files changed, 1384 insertions(+), 39 deletions(-) create mode 100644 src/application/javascript/JavaScriptSemanticGraphExpressionInfluence.test.ts create mode 100644 src/application/javascript/JavaScriptSemanticGraphExpressionProjection.ts diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 6f9fb270c7..2f89295c45 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -224,6 +224,25 @@ resulting value. Deferred callback bodies do not become inputs to the enclosing assignment. Computed method keys belong to the enclosing callable; parameter defaults and method bodies belong to the method. Uncertain later property reads remain opt-in through `include_ambiguous_dynamic_edges`. + +Binary, logical, unary, template, conditional, and sequence expressions retain +`influences` edges from supported immediate inputs to the result, then to a +direct declaration, assignment, call argument, or return. These are static +dependencies, not aliases or proof that the expression executed; conditional +operands and logical-assignment writes carry `conditional: true`. Unsupported +operand dependencies, unmodeled receiving containers, dynamic call results, +and sequence side effects remain unknown. An applicable +unknown makes the trace status `partial` even when a seed was found. Literal +operator operands, direct call arguments and directly returned scalar literals +can also be seeds. Older graphs that only recorded a call-site literal remain +searchable. The offered per-file node capacity also bounds the producer's +expression and operand collections before graph projection. +Expression nodes and unknowns share the existing per-file graph admission +budgets; omitted facts make graph coverage truncated and the trace partial. +Consumers that previously treated `found` as compatible with a relevant +unknown should handle `partial` and inspect the returned unknowns. Retained +graphs without `influences` edges remain readable but do not gain edges until +the application is analyzed again. Global timer, `fetch`, `WebSocket`, `JSON.parse`, and coercion calls receive these built-in effects only when the file has no visible possible write to that global, including a computed write through a global object. The check does not diff --git a/src/application/javascript/JavaScriptSemanticGraphAssignmentProjection.ts b/src/application/javascript/JavaScriptSemanticGraphAssignmentProjection.ts index 8c7d48ff47..748b6ec490 100644 --- a/src/application/javascript/JavaScriptSemanticGraphAssignmentProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphAssignmentProjection.ts @@ -1,6 +1,7 @@ import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js"; import { createSemanticNodeRangeLookup } from "./JavaScriptSemanticGraphProjection.js"; import { sourceRangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js"; +import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js"; import { addSemanticGraphRelation, addSemanticGraphUnknown, @@ -13,6 +14,11 @@ export function* projectSemanticAssignments( ): Generator { const readsWithin = createSemanticNodeRangeLookup(context.readNodes); const writesWithin = createSemanticNodeRangeLookup(context.writeNodes); + const expressions = new Set( + context.ir.expressionFlows.map((flow) => + javascriptSourceRangeKey(flow.location), + ), + ); for (const [index, flow] of context.ir.assignmentFlows.entries()) { if (index % 64 === 0) yield; const owner = @@ -22,9 +28,11 @@ export function* projectSemanticAssignments( const writes = writesWithin(flow.target).filter((node) => sourceRangesEqual(node.identity.source_range, flow.target), ); - const reads = readsWithin(flow.source).filter( - (node) => node.function_node_id === owner, - ); + const reads = expressions.has(javascriptSourceRangeKey(flow.source)) + ? [] + : readsWithin(flow.source).filter( + (node) => node.function_node_id === owner, + ); // Compound assignments consume the previous value as well as the RHS. if (flow.operator !== "=") reads.push( diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts index 51f724ebeb..d9819c750b 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts @@ -31,7 +31,7 @@ import { getterExpansionSource } from "../../../tests/fixtures/javascriptGetterE const SHA256 = "a".repeat(64); -it("retains direct call literals and resolves literal queries to their call site", () => { +it("retains direct call literals and resolves literal queries to their argument", () => { const source = 'api.rpc("proc_0", 7, false, null, dynamic, ...values, `fixed`, -3);'; const graph = graphFor(source); @@ -57,7 +57,10 @@ it("retains direct call literals and resolves literal queries to their call site direction: "forward-influence", }), ); - expect(trace.seed_node_ids).toContain(call.node_id); + expect( + trace.nodes.find((node) => node.node_id === trace.seed_node_ids[0])?.label, + ).toBe("argument 0"); + expect(trace.nodes.some((node) => node.node_id === call.node_id)).toBe(true); }); it("reports the primitive byte bound when a direct argument is too large", () => { diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts index fb9d4830d4..bb14d397f3 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts @@ -14,9 +14,11 @@ import { } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; import type { JavaScriptSemanticCallArgument, + JavaScriptSemanticCallReturnFlow, JavaScriptSemanticIr, } from "../../domain/javascript/javascriptSemanticIr.js"; import { sourceRangesEqual as rangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js"; +import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js"; import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { @@ -31,6 +33,7 @@ import { } from "./JavaScriptSemanticGraphDataProjection.js"; import { projectSemanticResources } from "./JavaScriptSemanticGraphResourceProjection.js"; import { projectSemanticAssignments } from "./JavaScriptSemanticGraphAssignmentProjection.js"; +import { projectSemanticExpressionInfluence } from "./JavaScriptSemanticGraphExpressionProjection.js"; import { projectSemanticObjects } from "./JavaScriptSemanticGraphObjectProjection.js"; import { projectSemanticValues } from "./JavaScriptSemanticGraphValueProjection.js"; import { @@ -935,6 +938,11 @@ function* projectDefinitionsAndReferences( } const projectCalls = (context: FileContext): void => { + const expressions = new Set( + context.ir.expressionFlows.map((flow) => + javascriptSourceRangeKey(flow.location), + ), + ); for (const call of context.ir.callSites) { const callNode = context.callSiteNodes.get(call.callSiteId); if (callNode === undefined) continue; @@ -959,7 +967,40 @@ const projectCalls = (context: FileContext): void => { `${call.callSiteId}\u0000${String(argument.index)}`, argumentNode, ); - const references = context.referenceNodesWithin(argument.location); + if (!argument.spread && Object.hasOwn(argument, "literal")) + addRelation(context.state, { + source: argumentNode, + target: callNode, + relation: "influences", + resolution: "resolved", + }); + if ( + call.resolution !== "exact" && + call.resultUsed && + argumentNode !== null + ) + addSemanticGraphUnknown(context.state, { + node_id: argumentNode.node_id, + family: "data-flow", + relation_kinds: ["influences"], + reason: "dynamic-call", + detail: + "The dynamic call's return dependency on this argument is unknown.", + candidate_node_ids: [], + evidence: unknownSemanticEvidence(context.file, argument.location), + }); + const references = + argument.valueExpressionLocation !== null && + expressions.has( + javascriptSourceRangeKey(argument.valueExpressionLocation), + ) + ? [] + : context + .referenceNodesWithin(argument.location) + .filter( + (reference) => + reference.function_node_id === callNode.function_node_id, + ); for (const reference of references) addRelation(context.state, { source: reference, @@ -994,20 +1035,46 @@ const projectCalls = (context: FileContext): void => { resolution: context.callResolutions.get(flow.callSiteId) ?? "candidate", }); } - for (const flow of context.ir.callReturnFlows) + const returnFlowsByCallSite = new Map< + string, + JavaScriptSemanticCallReturnFlow[] + >(); + for (const flow of context.ir.callReturnFlows) { + const grouped = returnFlowsByCallSite.get(flow.callSiteId); + if (grouped === undefined) + returnFlowsByCallSite.set(flow.callSiteId, [flow]); + else grouped.push(flow); addRelation(context.state, { source: context.returnSiteNodes.get(flow.returnSiteId), target: context.callSiteNodes.get(flow.callSiteId), relation: "returns-to-call", resolution: context.callResolutions.get(flow.callSiteId) ?? "candidate", }); - for (const flow of context.ir.callResultFlows) - addRelation(context.state, { - source: context.callSiteNodes.get(flow.callSiteId), - target: context.bindingNodes.get(flow.bindingId), - relation: "aliases", - resolution: context.callResolutions.get(flow.callSiteId) ?? "candidate", - }); + } + for (const flow of context.ir.callResultFlows) { + const resolution = + context.callResolutions.get(flow.callSiteId) ?? "candidate"; + const target = context.bindingNodes.get(flow.bindingId); + const returns = returnFlowsByCallSite.get(flow.callSiteId) ?? []; + // A literal argument may influence the invocation. The assigned value + // comes from modeled returns, so an ignored parameter does not reach it. + if (returns.length === 0) { + addRelation(context.state, { + source: context.callSiteNodes.get(flow.callSiteId), + target, + relation: "aliases", + resolution, + }); + continue; + } + for (const item of returns) + addRelation(context.state, { + source: context.returnSiteNodes.get(item.returnSiteId), + target, + relation: "aliases", + resolution, + }); + } projectSemanticReturnValues(context); }; @@ -1025,6 +1092,14 @@ const createArgumentNode = ( ? "spread argument" : `argument ${String(argument.index)}`, functionNodeId: callNode.function_node_id, + ...(!argument.spread && Object.hasOwn(argument, "literal") + ? { + properties: { + literal_argument_value: argument.literal ?? null, + literal_argument_call_site_node_id: callNode.node_id, + }, + } + : {}), }); /** Per-file projection passes, run in this order with a yield between each. */ @@ -1035,6 +1110,7 @@ const FILE_PROJECTION_PASSES: readonly (( projectSemanticObjects, projectSemanticAssignments, projectCalls, + projectSemanticExpressionInfluence, projectSemanticPromises, projectSemanticEvents, projectSemanticTimers, diff --git a/src/application/javascript/JavaScriptSemanticGraphExpressionInfluence.test.ts b/src/application/javascript/JavaScriptSemanticGraphExpressionInfluence.test.ts new file mode 100644 index 0000000000..11c36243c2 --- /dev/null +++ b/src/application/javascript/JavaScriptSemanticGraphExpressionInfluence.test.ts @@ -0,0 +1,356 @@ +import { expect, it } from "vitest"; +import { graphForJavaScript } from "../../../tests/fixtures/javascriptSemanticGraph.js"; +import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js"; +import { resolveJavaScriptSemanticQuerySeeds } from "../../domain/javascript/javascriptSemanticQueryIdentity.js"; +import { javaScriptSemanticQueryInputSchema } from "../../domain/javascript/javascriptSemanticQuerySchemas.js"; +import { parseJavaScriptSource } from "../../domain/javascript/javascriptSourceParser.js"; +import { analyzeParsedJavaScriptSemanticsSteps } from "../../domain/javascript/javascriptSemanticAnalysis.js"; +import { completeSemanticSteps } from "../../domain/javascript/javascriptSemanticTraversal.js"; + +const trace = (body: string) => { + const graph = graphForJavaScript( + `const base = "https://example.test"; ${body}`, + ); + return queryJavaScriptSemanticGraph( + graph, + javaScriptSemanticQueryInputSchema.parse({ + seed: { kind: "literal", value: "https://example.test" }, + direction: "forward-influence", + }), + ); +}; + +it.each([ + ["declaration", 'const url = base + "/x"; fetch(url);'], + ["assignment", 'let url; url = base + "/x"; fetch(url);'], + ["nested operator", 'const url = (base + "/x") + "/y"; fetch(url);'], + ["call argument", 'fetch(base + "/x");'], + ["template", "fetch(`${base}/x`);"], +])("follows transformed input to a %s without opt-in", (_name, body) => { + const result = trace(body); + expect(result.nodes.map((node) => node.label)).toContain("argument 0"); + expect(result.relations.some((edge) => edge.relation === "influences")).toBe( + true, + ); +}); + +it("explains an unmodeled dynamic call result at the reached argument", () => { + const result = trace('const url = wrap(base + "/x"); fetch(url);'); + expect(result.unknowns.map((unknown) => unknown.detail)).toContain( + "The dynamic call's return dependency on this argument is unknown.", + ); +}); + +it.each([ + 'const obj = { url: wrap(base + "/x") }; fetch(obj.url);', + 'function address() { return wrap(base + "/x"); }', + 'consume(wrap(base + "/x"));', +])("keeps nested dynamic call return influence unknown: %s", (body) => { + const result = trace(body); + expect(result.status).not.toBe("found"); + expect(result.unknowns.map((unknown) => unknown.detail)).toContain( + "The dynamic call's return dependency on this argument is unknown.", + ); +}); + +it("does not report an unused dynamic call result as a missing value", () => { + const result = trace('consume(base + "/x");'); + expect( + result.unknowns.some( + (unknown) => + unknown.detail === + "The dynamic call's return dependency on this argument is unknown.", + ), + ).toBe(false); +}); + +it("bounds operator production before graph admission and reports truncation", () => { + const expressions = Array.from({ length: 500 }, () => "${base}").join(""); + const source = `const base = "value"; const value = \`${expressions}\`;`; + const parsed = parseJavaScriptSource(source, "app.js"); + if (parsed === null) throw new Error("Expected parsed source"); + const ir = completeSemanticSteps( + analyzeParsedJavaScriptSemanticsSteps(parsed, "import", 4), + ); + expect(ir.expressionFlowsTruncated).toBe(true); + expect(ir.expressionFlows.length).toBeLessThanOrEqual(4); + expect( + ir.expressionFlows.reduce((count, flow) => count + flow.operands.length, 0), + ).toBeLessThanOrEqual(4); + expect(ir.coverage).toMatchObject({ status: "partial", omittedCount: null }); + expect(graphForJavaScript(source, ir).coverage.truncated).toBe(true); +}); + +it("follows a transformed return expression", () => { + const result = trace('function address() { return base + "/x"; }'); + expect(result.nodes.map((node) => node.label)).toContain("return"); +}); + +it("does not treat a deferred callback body as an enclosing call argument value", () => { + const graph = graphForJavaScript( + 'const base = "https://example.test"; consume(() => base + "/x");', + ); + const base = graph.nodes.find( + (node) => node.kind === "binding" && node.label === "base", + ); + const argument = graph.nodes.find( + (node) => node.kind === "expression" && node.label === "argument 0", + ); + expect(base).toBeDefined(); + expect(argument).toBeDefined(); + const result = queryJavaScriptSemanticGraph( + graph, + javaScriptSemanticQueryInputSchema.parse({ + seed: { kind: "literal", value: "https://example.test" }, + direction: "forward-influence", + include_ambiguous_dynamic_edges: true, + }), + ); + expect(result.nodes.map((node) => node.node_id)).not.toContain( + argument?.node_id, + ); +}); + +it.each(['"/unique"', "`/unique`"])( + "seeds an exact literal operand %s and reaches the receiving argument", + (operand) => { + const graph = graphForJavaScript( + `const prefix = "root"; const path = prefix + ${operand}; fetch(path);`, + ); + const result = queryJavaScriptSemanticGraph( + graph, + javaScriptSemanticQueryInputSchema.parse({ + seed: { kind: "literal", value: "/unique" }, + direction: "forward-influence", + }), + ); + expect(result.nodes.map((node) => node.label)).toContain("argument 0"); + }, +); + +it("marks a short-circuited operand as conditional without claiming alias identity", () => { + const graph = graphForJavaScript( + 'const base = "unique"; const maybe = ready && base; fetch(maybe);', + ); + const conditional = graph.relations.filter( + (edge) => + edge.relation === "influences" && edge.properties.conditional === true, + ); + expect(conditional).toHaveLength(1); + expect( + graph.nodes.find((node) => node.node_id === conditional[0]?.source_node_id) + ?.label, + ).toBe("base"); +}); + +it("reports uncertainty on a read inside an unsupported operand", () => { + const result = trace('const url = format(base) + "/x"; fetch(url);'); + expect(result.status).toBe("partial"); + expect( + result.unknowns.some((unknown) => + unknown.detail.includes("unmodeled operator operand"), + ), + ).toBe(true); +}); + +it.each([ + 'function answer() { return "return-only"; }', + 'const answer = () => "return-only";', +])("seeds a literal returned directly: %s", (source) => { + const graph = graphForJavaScript(source); + const result = queryJavaScriptSemanticGraph( + graph, + javaScriptSemanticQueryInputSchema.parse({ + seed: { kind: "literal", value: "return-only" }, + direction: "forward-influence", + }), + ); + expect(result.summary.total_seed_matches).toBeGreaterThan(0); + expect(result.nodes.map((node) => node.label)).toContain("return"); +}); + +it("marks a logical assignment's transformed input conditional", () => { + const graph = graphForJavaScript( + 'const base = "unique"; let value; value ||= base + "/x";', + ); + const conditional = graph.relations.find( + (edge) => + edge.relation === "influences" && + edge.properties.conditional === true && + graph.nodes.find((node) => node.node_id === edge.target_node_id) + ?.label === "value", + ); + expect(conditional).toBeDefined(); +}); + +it("keeps sequence side effects unknown instead of attributing earlier values directly", () => { + const result = trace('const url = (touch(base), "fixed"); fetch(url);'); + expect(result.status).toBe("partial"); + expect( + result.unknowns.some((unknown) => + unknown.detail.includes("unmodeled operator operand"), + ), + ).toBe(true); +}); + +it("seeds a direct argument at its occurrence and reaches the call", () => { + const graph = graphForJavaScript('consume("only-direct");'); + const result = queryJavaScriptSemanticGraph( + graph, + javaScriptSemanticQueryInputSchema.parse({ + seed: { kind: "literal", value: "only-direct" }, + direction: "forward-influence", + }), + ); + expect(result.summary.total_seed_matches).toBe(1); + expect( + result.nodes.find((node) => node.node_id === result.seed_node_ids[0]) + ?.label, + ).toBe("argument 0"); + expect(result.nodes.some((node) => node.kind === "call-site")).toBe(true); +}); + +it("keeps older call-site literal evidence searchable when argument literals are absent", () => { + const graph = graphForJavaScript('consume("only-direct");'); + const legacy = { + ...graph, + nodes: graph.nodes.filter( + (node) => + node.properties.literal_argument_call_site_node_id === undefined, + ), + }; + const seeds = resolveJavaScriptSemanticQuerySeeds( + legacy, + javaScriptSemanticQueryInputSchema.parse({ + seed: { kind: "literal", value: "only-direct" }, + direction: "forward-influence", + }), + ); + const call = graph.nodes.find((node) => node.kind === "call-site"); + expect(seeds).toEqual([call?.node_id]); +}); + +it("does not treat literals inside a spread or deferred callback as direct arguments", () => { + const graph = graphForJavaScript( + 'consume(...["spread-only"], () => "deferred-only");', + ); + for (const value of ["spread-only", "deferred-only"]) { + const result = queryJavaScriptSemanticGraph( + graph, + javaScriptSemanticQueryInputSchema.parse({ + seed: { kind: "literal", value }, + direction: "forward-influence", + }), + ); + expect( + result.nodes.filter( + (node) => + node.properties.literal_argument_call_site_node_id !== undefined, + ), + ).toEqual([]); + } +}); + +it.each([ + 'const obj = { url: base + "/x" }; fetch(obj.url);', + 'const values = [base + "/x"]; fetch(values[0]);', + 'const url = wrap(base + "/x"); fetch(url);', +])("reports unknown when a consumer boundary is unmodeled: %s", (body) => { + const result = trace(body); + expect(result.status).not.toBe("found"); + expect(result.unknowns.length).toBeGreaterThan(0); +}); + +it("does not reuse an outer literal for the same text in a nested function", () => { + const graph = graphForJavaScript( + 'const suffix = "end"; const value = true ? "token" : () => "token" + suffix;', + ); + const tokens = graph.nodes.filter( + (node) => node.kind === "literal" && node.properties.value === "token", + ); + expect(tokens.length).toBeGreaterThan(1); + const inner = tokens.find((node) => node.function_node_id !== null); + const outer = tokens.find((node) => node.function_node_id === null); + if (inner === undefined || outer === undefined) + throw new Error("Expected separate outer and nested token literals"); + expect(inner.identity.source_range).not.toEqual(outer.identity.source_range); + const traced = queryJavaScriptSemanticGraph( + graph, + javaScriptSemanticQueryInputSchema.parse({ + seed: { kind: "semantic-node", node_id: outer.node_id }, + direction: "forward-influence", + }), + ); + expect(traced.nodes.map((node) => node.node_id)).not.toContain(inner.node_id); +}); + +it("does not send an ignored literal argument to the call result", () => { + const graph = graphForJavaScript( + 'function ignore(x) { return "safe"; } const result = ignore("token");', + ); + const resultBinding = graph.nodes.find( + (node) => node.kind === "binding" && node.label === "result", + ); + if (resultBinding === undefined) throw new Error("Expected result binding"); + const forward = queryJavaScriptSemanticGraph( + graph, + javaScriptSemanticQueryInputSchema.parse({ + seed: { kind: "literal", value: "token" }, + direction: "forward-influence", + }), + ); + expect(forward.nodes.some((node) => node.kind === "call-site")).toBe(true); + expect(forward.nodes.map((node) => node.node_id)).not.toContain( + resultBinding.node_id, + ); + const backward = queryJavaScriptSemanticGraph( + graph, + javaScriptSemanticQueryInputSchema.parse({ + seed: { kind: "semantic-node", node_id: resultBinding.node_id }, + direction: "backward-provenance", + }), + ); + expect( + backward.nodes.some( + (node) => + node.properties.value === "token" || + node.properties.literal_argument_value === "token", + ), + ).toBe(false); + expect(backward.nodes.some((node) => node.properties.value === "safe")).toBe( + true, + ); +}); + +it("keeps conditional binding candidates distinct from operand occurrences", () => { + const graph = graphForJavaScript('export const choice = flag ? 1 : "1";'); + const choices = graph.nodes.filter( + (node) => + node.kind === "literal" && + (node.properties.value === 1 || node.properties.value === "1"), + ); + expect(choices).toHaveLength(4); + expect( + new Set( + choices.map((node) => + JSON.stringify([node.properties.value, node.identity.source_range]), + ), + ).size, + ).toBe(4); +}); + +it("projects repeated operator literals with indexed lookup", () => { + const count = 1_200; + const declarations = Array.from( + { length: count }, + (_, index) => `const item${String(index)} = input + "/item";`, + ); + const graph = graphForJavaScript( + `const input = "root";\n${declarations.join("\n")}`, + ); + expect( + graph.nodes.filter( + (node) => node.kind === "literal" && node.properties.value === "/item", + ), + ).toHaveLength(count); +}); diff --git a/src/application/javascript/JavaScriptSemanticGraphExpressionProjection.ts b/src/application/javascript/JavaScriptSemanticGraphExpressionProjection.ts new file mode 100644 index 0000000000..be3267b48f --- /dev/null +++ b/src/application/javascript/JavaScriptSemanticGraphExpressionProjection.ts @@ -0,0 +1,396 @@ +import { + compareJavaScriptSourcePoints, + javascriptSourceRangeContains, + javascriptSourceRangeKey, +} from "../../domain/javascript/javascriptSourceCoordinates.js"; +import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; +import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; +import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js"; +import { + addSemanticGraphRelation, + addSemanticGraphUnknown, + retainSemanticGraphNode, +} from "./JavaScriptSemanticGraphConstruction.js"; +import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js"; + +const sameArtifact = ( + node: JavaScriptSemanticGraphNode, + context: SemanticFlowProjectionContext, +): boolean => + node.identity.module_path === context.file.path && + node.identity.artifact_sha256 === context.file.sha256; + +const literalValueKey = (value: string | number | boolean | null): string => + `${typeof value}:${JSON.stringify(value)}`; + +type IndexedOperatorLiteral = { + readonly node: JavaScriptSemanticGraphNode; + readonly range: JavaScriptSourceRange; + readonly order: number; +}; + +/** + * Literals are remembered by their own source range and value. + * An initializer that merely contains another occurrence, including a + * nested function body, does not reuse the declaration's literal. + */ +const operatorLiteralIndex = ( + context: SemanticFlowProjectionContext, +): { + remember( + node: JavaScriptSemanticGraphNode | null, + ): JavaScriptSemanticGraphNode | null; + find( + value: string | number | boolean | null, + location: JavaScriptSourceRange, + ): JavaScriptSemanticGraphNode | undefined; +} => { + const exact = new Map(); + const byValue = new Map(); + const seen = new Set(); + let order = 0; + const remember = ( + node: JavaScriptSemanticGraphNode | null, + ): JavaScriptSemanticGraphNode | null => { + if ( + node === null || + node.kind !== "literal" || + !sameArtifact(node, context) || + seen.has(node.node_id) + ) + return node; + const range = node.identity.source_range; + const value = node.properties.value; + if ( + range === null || + (typeof value !== "string" && + typeof value !== "number" && + typeof value !== "boolean" && + value !== null) + ) + return node; + seen.add(node.node_id); + const entry = { node, range, order: order++ }; + const id = `${literalValueKey(value)}\0${javascriptSourceRangeKey(range)}`; + const group = byValue.get(literalValueKey(value)) ?? []; + group.push(entry); + byValue.set(literalValueKey(value), group); + if (!exact.has(id)) exact.set(id, entry); + return node; + }; + for (const node of context.state.nodes.values()) remember(node); + return { + remember, + find(value, location) { + const valueKey = literalValueKey(value); + const operandKey = javascriptSourceRangeKey(location); + let best: IndexedOperatorLiteral | undefined; + const consider = (entry: IndexedOperatorLiteral | undefined): void => { + if ( + entry !== undefined && + (best === undefined || entry.order < best.order) + ) + best = entry; + }; + consider(exact.get(`${valueKey}\0${operandKey}`)); + const group = byValue.get(valueKey) ?? []; + let low = 0; + let high = group.length; + while (low < high) { + const middle = (low + high) >> 1; + const entry = group[middle]; + if ( + entry !== undefined && + compareJavaScriptSourcePoints(entry.range.start, location.start) <= 0 + ) + low = middle + 1; + else high = middle; + } + const candidate = group[low - 1]; + if ( + candidate !== undefined && + javascriptSourceRangeContains(candidate.range, location) + ) + consider(candidate); + return best?.node; + }, + }; +}; + +/** Project immediate operator dependencies without treating them as aliases. */ +export function* projectSemanticExpressionInfluence( + context: SemanticFlowProjectionContext & { + readonly argumentNodes: ReadonlyMap; + }, +): Generator { + if (context.ir.expressionFlowsTruncated) + context.state.fileNodesDropped = true; + const key = javascriptSourceRangeKey; + const expressions = new Map(); + const usedExpressions = new Set(); + const reads = new Map( + context.readNodes.flatMap((node) => + node.identity.source_range === null + ? [] + : [[key(node.identity.source_range), node] as const], + ), + ); + const writes = new Map( + context.writeNodes.flatMap((node) => + node.identity.source_range === null + ? [] + : [[key(node.identity.source_range), node] as const], + ), + ); + const bindings = new Map( + context.ir.bindings.flatMap((binding) => { + const node = context.bindingNodes.get(binding.bindingId); + return node === undefined + ? [] + : binding.definitions.map( + (definition) => [key(definition.location), node] as const, + ); + }), + ); + for (const [index, flow] of context.ir.expressionFlows.entries()) { + if (index % 64 === 0) yield; + const owner = + flow.ownerCallableId === null + ? null + : (context.callableNodes.get(flow.ownerCallableId)?.node_id ?? null); + const node = retainSemanticGraphNode(context.state, context.file, { + kind: "expression", + roleKey: `operator:${index}`, + location: flow.location, + label: flow.operator, + functionNodeId: owner, + properties: { operator: flow.operator }, + }); + if (node !== null) expressions.set(key(flow.location), node); + } + const remainingUnknowns = + context.state.unknownCapacity === null + ? context.readNodes.length + : Math.max( + 0, + context.state.unknownCapacity - context.state.unknowns.size, + ); + let unsupportedReads = 0; + let unsupportedOverflow = false; + const orderedReads = context.readNodes + .flatMap((node) => + node.identity.source_range === null + ? [] + : [{ node, location: node.identity.source_range }], + ) + .sort((left, right) => + compareJavaScriptSourcePoints(left.location.start, right.location.start), + ); + let processedOperands = 0; + let processedReferences = 0; + const literals = operatorLiteralIndex(context); + for (const [index, flow] of context.ir.expressionFlows.entries()) { + if (index % 64 === 0) yield; + const result = expressions.get(key(flow.location)); + for (const operand of flow.operands) { + if (processedOperands % 64 === 0) yield; + processedOperands += 1; + if (operand.kind === "unsupported") { + if (result !== undefined) + addSemanticGraphUnknown(context.state, { + node_id: result.node_id, + family: "data-flow", + relation_kinds: ["influences"], + reason: "unknown-value", + detail: "An operator operand has unmodeled value dependencies.", + candidate_node_ids: [], + evidence: unknownSemanticEvidence(context.file, operand.location), + }); + if (!unsupportedOverflow) { + let low = 0, + high = orderedReads.length; + while (low < high) { + const middle = Math.floor((low + high) / 2); + if ( + compareJavaScriptSourcePoints( + orderedReads[middle]!.location.start, + operand.location.start, + ) < 0 + ) + low = middle + 1; + else high = middle; + } + for (let offset = low; offset < orderedReads.length; offset++) { + const { node: reference, location } = orderedReads[offset]!; + if ( + compareJavaScriptSourcePoints( + location.start, + operand.location.end, + ) > 0 + ) + break; + if (processedReferences % 64 === 0) yield; + processedReferences += 1; + if (processedReferences > remainingUnknowns) { + unsupportedOverflow = true; + break; + } + if ( + !javascriptSourceRangeContains(operand.location, location) || + reference.function_node_id !== result?.function_node_id + ) + continue; + if (unsupportedReads >= remainingUnknowns) { + unsupportedOverflow = true; + break; + } + unsupportedReads += 1; + addSemanticGraphUnknown(context.state, { + node_id: reference.node_id, + family: "data-flow", + relation_kinds: ["influences"], + reason: "unknown-value", + detail: + "This read occurs inside an unmodeled operator operand; its influence on the result is unknown.", + candidate_node_ids: [], + evidence: unknownSemanticEvidence(context.file, operand.location), + }); + } + } + continue; + } + const source = + operand.kind === "reference" + ? reads.get(key(operand.location)) + : operand.kind === "expression" + ? expressions.get(key(operand.location)) + : (literals.find(operand.literal ?? null, operand.location) ?? + literals.remember( + retainSemanticGraphNode(context.state, context.file, { + kind: "literal", + roleKey: `operator-literal:${key(operand.location)}:${JSON.stringify(operand.literal ?? null)}`, + location: operand.location, + label: "operator literal", + functionNodeId: result?.function_node_id ?? null, + properties: { value: operand.literal ?? null }, + }) ?? null, + )); + if ( + operand.kind === "expression" && + source !== undefined && + result !== undefined + ) + usedExpressions.add(key(operand.location)); + addSemanticGraphRelation(context.state, { + source, + target: result, + relation: "influences", + resolution: "resolved", + properties: { conditional: operand.conditional }, + }); + } + } + // Some operands are calls, members or other constructs whose value owner is + // not yet modeled here. Do not claim complete influence for any query that + // may cross one of these unmodeled operator inputs. + if (unsupportedOverflow) + addSemanticGraphUnknown(context.state, { + node_id: null, + family: "data-flow", + relation_kinds: ["influences"], + reason: "resource-limit", + detail: + "The bounded operator-dependency audit omitted some unmodeled operand reads; influence through them remains unknown.", + candidate_node_ids: [], + evidence: unknownSemanticEvidence(context.file, null), + }); + const connect = ( + source: JavaScriptSemanticGraphNode | undefined, + target: JavaScriptSemanticGraphNode | undefined, + conditional = false, + ) => { + if ( + source !== undefined && + target !== undefined && + source.identity.source_range !== null && + expressions.get(key(source.identity.source_range)) === source + ) + usedExpressions.add(key(source.identity.source_range)); + addSemanticGraphRelation(context.state, { + source, + target, + relation: "influences", + resolution: "resolved", + properties: { conditional }, + }); + }; + for (const flow of context.ir.initializerFlows) + connect(expressions.get(key(flow.source)), bindings.get(key(flow.target))); + for (const flow of context.ir.assignmentFlows) { + const source = expressions.get(key(flow.source)); + const target = writes.get(key(flow.target)); + if (source !== undefined && target !== undefined) + usedExpressions.add(key(flow.source)); + addSemanticGraphRelation(context.state, { + source, + target, + relation: "influences", + resolution: "resolved", + properties: { + conditional: ["&&=", "||=", "??="].includes(flow.operator), + assignment_operator: flow.operator, + }, + }); + } + for (const call of context.ir.callSites) + for (const argument of call.arguments) { + if (argument.valueExpressionLocation === null) continue; + connect( + expressions.get(key(argument.valueExpressionLocation)), + context.argumentNodes.get( + `${call.callSiteId}\u0000${String(argument.index)}`, + ), + ); + } + const literalReturns = new Map( + context.ir.literalReturns.map((item) => [key(item.location), item]), + ); + for (const callable of context.ir.callables) + for (const site of callable.returnSites) { + if (site.valueExpressionLocation !== null) { + const target = context.returnSiteNodes.get(site.returnSiteId); + connect(expressions.get(key(site.valueExpressionLocation)), target); + const literal = literalReturns.get(key(site.valueExpressionLocation)); + if (literal !== undefined) { + const source = retainSemanticGraphNode(context.state, context.file, { + kind: "literal", + roleKey: `return-literal:${site.returnSiteId}`, + location: literal.location, + label: "return literal", + functionNodeId: target?.function_node_id ?? null, + properties: { value: literal.value }, + }); + addSemanticGraphRelation(context.state, { + source, + target, + relation: "aliases", + resolution: "resolved", + }); + } + } + } + for (const flow of context.ir.expressionFlows) { + const node = expressions.get(key(flow.location)); + if (node === undefined || usedExpressions.has(key(flow.location))) continue; + addSemanticGraphUnknown(context.state, { + node_id: node.node_id, + family: "data-flow", + relation_kinds: ["influences"], + reason: "unknown-value", + detail: + "The receiving expression or container for this operator result is not modeled; further influence is unknown.", + candidate_node_ids: [], + evidence: unknownSemanticEvidence(context.file, flow.location), + }); + } +} diff --git a/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts b/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts index 619670ac9b..06fb86bdf1 100644 --- a/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts @@ -269,10 +269,24 @@ export const semanticObjectReadOccurrence = ( export const projectSemanticReturnValues = ( context: SemanticFlowProjectionContext, ): void => { + const expressions = new Set( + context.ir.expressionFlows.map((flow) => + javascriptSourceRangeKey(flow.location), + ), + ); for (const callable of context.ir.callables) for (const site of callable.returnSites) { const returnNode = context.returnSiteNodes.get(site.returnSiteId); - const references = context.referenceNodesWithin(site.location); + const references = + site.valueExpressionLocation !== null && + expressions.has(javascriptSourceRangeKey(site.valueExpressionLocation)) + ? [] + : context + .referenceNodesWithin(site.location) + .filter( + (reference) => + reference.function_node_id === returnNode?.function_node_id, + ); for (const reference of references) addSemanticGraphRelation(context.state, { source: reference, diff --git a/src/application/javascript/JavaScriptSemanticGraphResources.test.ts b/src/application/javascript/JavaScriptSemanticGraphResources.test.ts index e8c1aa4270..dd77388c12 100644 --- a/src/application/javascript/JavaScriptSemanticGraphResources.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphResources.test.ts @@ -360,7 +360,13 @@ it("does not promote transformed return expressions to resolved aliases", () => ({ relation, target_node_id }) => relation === "aliases" && target_node_id === returnNode.node_id, ), - ).toEqual([expect.objectContaining({ resolution: "candidate" })]); + ).toEqual([]); + expect( + graph.relations.filter( + ({ relation, target_node_id }) => + relation === "influences" && target_node_id === returnNode.node_id, + ), + ).toEqual([expect.objectContaining({ resolution: "resolved" })]); const output = graph.nodes.find( ({ kind, label }) => kind === "binding" && label === "output", ); @@ -376,10 +382,8 @@ it("does not promote transformed return expressions to resolved aliases", () => direction: "backward-provenance", }), ); - expect(strict.nodes.map(({ node_id }) => node_id)).not.toContain( - input.node_id, - ); - expect(strict.status).toBe("ambiguous"); + expect(strict.nodes.map(({ node_id }) => node_id)).toContain(input.node_id); + expect(strict.status).toBe("partial"); }); it("uses unavailable evidence when no source produced semantic IR", () => { diff --git a/src/application/javascript/JavaScriptSemanticUnknownCapacity.test.ts b/src/application/javascript/JavaScriptSemanticUnknownCapacity.test.ts index 6dec214d3b..6a95a43dfb 100644 --- a/src/application/javascript/JavaScriptSemanticUnknownCapacity.test.ts +++ b/src/application/javascript/JavaScriptSemanticUnknownCapacity.test.ts @@ -318,3 +318,25 @@ it("applies the same unknown admission in direct and transferred construction", transferred.finish(digest, application), ); }); + +it("bounds reads within unmodeled nested operator operands", () => { + const body = `const base = "token"; const value = ${"wrap(".repeat(30)}base${")".repeat(30)} + "/x";`; + const bounded = fileProjection(source("app.js", body), 200, 1); + expect(bounded.unknowns.length).toBeLessThanOrEqual(1); + expect(bounded.omittedUnknownOccurrences?.length).toBeGreaterThan(0); + const projection = createJavaScriptSemanticGraphProjection({ + totalUnknowns: 1, + fileUnknowns: 1, + }); + projection.adoptFileProjection(bounded); + const graph = projection.finish(digest, application); + expect(graph.coverage.truncated).toBe(true); + const trace = queryJavaScriptSemanticGraph( + graph, + javaScriptSemanticQueryInputSchema.parse({ + seed: { kind: "literal", value: "token" }, + direction: "forward-influence", + }), + ); + expect(trace.status).not.toBe("found"); +}); diff --git a/src/composition/javascriptAnalysisWorkerProcess.ts b/src/composition/javascriptAnalysisWorkerProcess.ts index 2ae016b52e..540cbadd6b 100644 --- a/src/composition/javascriptAnalysisWorkerProcess.ts +++ b/src/composition/javascriptAnalysisWorkerProcess.ts @@ -47,6 +47,7 @@ const analyze: JavaScriptSourceWorkerAnalyzer = async ( analyzeParsedJavaScriptSemanticsSteps( parsed, moduleResolutionModeForJavaScriptReference(file.path, "static-import"), + capacity.nodes, ), ); parsed = null; diff --git a/src/domain/javascript/javascriptCallLiteral.test.ts b/src/domain/javascript/javascriptCallLiteral.test.ts index e9a8212978..ca8727dfc6 100644 --- a/src/domain/javascript/javascriptCallLiteral.test.ts +++ b/src/domain/javascript/javascriptCallLiteral.test.ts @@ -41,5 +41,13 @@ it("retains wrapped TypeScript call literals for semantic queries", () => { direction: "forward-influence", }), ); - expect(result.seed_node_ids).toContain(call.node_id); + const argument = graph.nodes.find( + (node) => + node.kind === "expression" && + node.properties.literal_argument_value === -7 && + node.properties.literal_argument_call_site_node_id === call.node_id, + ); + if (argument === undefined) throw new Error("Expected literal argument"); + expect(result.seed_node_ids).toContain(argument.node_id); + expect(result.nodes.map(({ node_id }) => node_id)).toContain(call.node_id); }); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts index 794a53ffbd..148cf73fef 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.calls.test.ts @@ -7,6 +7,44 @@ import { topLevelBinding, } from "./javascriptSemanticAnalysis.fixture.js"; +describe("JavaScript semantic call result use", () => { + it.each([ + 'consume("unique");', + 'consume("unique")!;', + 'consume("unique") as unknown;', + '(consume("unique") as unknown)!;', + ])("does not treat %s as a used call result", (source) => { + const ir = analyzeJavaScriptSemantics(source); + expect(ir.callSites[0]?.resultUsed).toBe(false); + }); + + it("treats an asserted call initializer as a used result", () => { + const used = analyzeJavaScriptSemantics( + 'const value = consume("unique")!;', + ); + expect(used.callSites[0]?.resultUsed).toBe(true); + }); + + it.each([ + 'for (notify("token"); running;) {}', + 'for (; running; notify("token")) {}', + 'for (notify("token")!; running;) {}', + ])("does not treat %s as a used call result", (source) => { + const ir = analyzeJavaScriptSemantics(source); + const call = ir.callSites.find((site) => + site.arguments.some((argument) => argument.literal === "token"), + ); + expect(call?.resultUsed).toBe(false); + }); + + it("treats a for-loop variable initializer call as a used result", () => { + const ir = analyzeJavaScriptSemantics( + 'for (const value = notify("token"); running;) {}', + ); + expect(ir.callSites[0]?.resultUsed).toBe(true); + }); +}); + describe("JavaScript semantic analysis: calls 1", () => { it("does not assign computed method-name reads to method closures", () => { const ir = analyzeJavaScriptSemantics(` diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejection.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejection.test.ts index c77700a252..d60b62d226 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejection.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejection.test.ts @@ -44,7 +44,11 @@ describe("JavaScript semantic analysis: rejection 1", () => { expect(collectJavaScriptExports(parsed).values).toEqual([]); expect(fingerprintJavaScriptAst(parsed)).toMatch(/^[a-f0-9]{64}$/u); const ir = analyzeParsedJavaScriptSemantics(parsed); - expect(ir.coverage.status).toBe("complete"); + expect(ir.coverage).toMatchObject({ + status: "partial", + omittedCount: null, + }); + expect(ir.expressionFlowsTruncated).toBe(true); const binding = topLevelBinding(ir, "result"); expect(binding.value).toEqual({ status: "literal", value: 12000 }); expect(ir.references).toEqual([ diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 3f9b3d6610..2cb3140745 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -420,6 +420,7 @@ export const analyzeParsedJavaScriptSemantics = ( export function* analyzeParsedJavaScriptSemanticsSteps( file: ParsedJavaScriptSource, staticImportResolutionMode: JavaScriptModuleResolutionMode = "import", + expressionNodeBudget?: number, ): Generator { const state = createState(file.program, staticImportResolutionMode); yield* collectDefinitionsSteps(file.program, state); @@ -447,6 +448,14 @@ export function* analyzeParsedJavaScriptSemanticsSteps( callables, references, parserPartial, + expressionNodeBudget, + ); + const coverage = semanticCoverage( + parserPartial, + [ + ...new Set([...state.valueResourceLimits, ...state.getterResourceLimits]), + ].sort(), + [...state.returnedReferenceWidenings].map(range), ); return { schema: "JavaScriptSemanticIR", @@ -456,17 +465,15 @@ export function* analyzeParsedJavaScriptSemanticsSteps( references, moduleLinks, ...derived, - coverage: semanticCoverage( - parserPartial, - [ - ...new Set([ - ...state.valueResourceLimits, - ...state.getterResourceLimits, - ]), - ].sort(), - [...state.returnedReferenceWidenings].map(range), - ), + coverage: derived.expressionFlowsTruncated + ? { ...coverage, status: "partial", omittedCount: null } + : coverage, limitations: [ + ...(derived.expressionFlowsTruncated + ? [ + "The offered semantic node capacity bounded expression dependencies; omitted influence remains unknown.", + ] + : []), ...(state.valueResourceLimits.has("alias-selections") ? [semanticResourceLimitReason("alias-selections")] : []), diff --git a/src/domain/javascript/javascriptSemanticCalls.ts b/src/domain/javascript/javascriptSemanticCalls.ts index ca53ffa67c..a6a116dfeb 100644 --- a/src/domain/javascript/javascriptSemanticCalls.ts +++ b/src/domain/javascript/javascriptSemanticCalls.ts @@ -12,6 +12,7 @@ import type { import { semanticIdentifierRole } from "./javascriptSemanticProjection.js"; import { createSemanticCallableStack } from "./javascriptSemanticCallableStack.js"; import { + javaScriptExpressionWrapperTarget, readExactJavaScriptLiteral, semanticStaticPropertyKey, unwrapJavaScriptExpression, @@ -124,6 +125,32 @@ export const collectJavaScriptSemanticCalls = ( }; }; +/** TypeScript and grouping wrappers do not consume a call result. */ +const callResultIsUsed = ( + call: t.Node, + parent: t.Node | null, + ancestors: readonly t.Node[], +): boolean => { + let current = parent; + let child = call; + let index = ancestors.length - 1; + while ( + current !== null && + javaScriptExpressionWrapperTarget(current) !== undefined + ) { + child = current; + index -= 1; + current = index >= 0 ? (ancestors[index] ?? null) : null; + } + if (t.isExpressionStatement(current)) return false; + // for/of and for/in heads use their expressions. A C-style for initializer + // or update discards the value. + return !( + t.isForStatement(current) && + (current.init === child || current.update === child) + ); +}; + const collectCallSite = ( node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, parent: t.Node | null, @@ -146,6 +173,7 @@ const collectCallSite = ( callerCallableId: owner?.callableId ?? null, location: range(node), calleeLocation: range(node.callee), + resultUsed: callResultIsUsed(node, parent, context.ancestors), resolution: resolution.callableIds.length === 1 && resolution.complete ? "exact" diff --git a/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts b/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts index b523f7d10d..3752482346 100644 --- a/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticDerivedAnalysis.ts @@ -18,11 +18,20 @@ import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState. import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js"; import { traverseJavaScriptValueAstSteps } from "./javascriptSemanticTraversal.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; -import { unwrapJavaScriptExpression } from "./javascriptAstValues.js"; +import { + readExactJavaScriptLiteral, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; +import { semanticPrimitiveSet } from "./javascriptSemanticPrimitives.js"; +import { JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS } from "./javascriptAnalysisResourceControls.js"; type DerivedSemanticAnalysis = Pick< JavaScriptSemanticIr, | "assignmentFlows" + | "expressionFlows" + | "initializerFlows" + | "literalReturns" + | "expressionFlowsTruncated" | "argumentFlows" | "callResultFlows" | "callReturnFlows" @@ -53,6 +62,7 @@ export function* collectJavaScriptDerivedSemanticsSteps( callables: readonly JavaScriptSemanticCallable[], references: readonly JavaScriptSemanticReference[], parserPartial: boolean, + expressionNodeBudget: number = JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.semanticNodesPerFile, ): Generator { const calls = collectJavaScriptSemanticCalls(program, state, callables); yield; @@ -97,6 +107,13 @@ export function* collectJavaScriptDerivedSemanticsSteps( state, callables, ); + const expressionFlows = yield* collectExpressionFlowsSteps( + program, + state, + callables, + expressionNodeBudget, + ); + const initializerFlows = expressionFlows.initializers; const functionFingerprints = yield* collectJavaScriptSemanticFingerprintsSteps({ state, @@ -113,6 +130,10 @@ export function* collectJavaScriptDerivedSemanticsSteps( }); return { assignmentFlows, + expressionFlows: expressionFlows.expressions, + initializerFlows, + literalReturns: expressionFlows.literalReturns, + expressionFlowsTruncated: expressionFlows.truncated, ...calls, promiseOperations, ...asyncEffects, @@ -124,6 +145,164 @@ export function* collectJavaScriptDerivedSemanticsSteps( }; } +const isInfluenceExpression = (node: t.Node): boolean => + t.isBinaryExpression(node) || + t.isLogicalExpression(node) || + t.isUnaryExpression(node) || + t.isTemplateLiteral(node) || + t.isConditionalExpression(node) || + t.isSequenceExpression(node); + +/** One cooperative pass retains only immediate value operands, not subtree ranges. */ +function* collectExpressionFlowsSteps( + program: t.Program, + state: JavaScriptSemanticAnalysisState, + callables: readonly JavaScriptSemanticCallable[], + budget: number, +): Generator< + void, + { + expressions: JavaScriptSemanticIr["expressionFlows"]; + initializers: JavaScriptSemanticIr["initializerFlows"]; + literalReturns: JavaScriptSemanticIr["literalReturns"]; + truncated: boolean; + } +> { + const expressions: JavaScriptSemanticIr["expressionFlows"][number][] = []; + const initializers: JavaScriptSemanticIr["initializerFlows"][number][] = []; + const literalReturns: JavaScriptSemanticIr["literalReturns"][number][] = []; + const candidates: { node: t.Node; ownerCallableId: string | null }[] = []; + let truncated = false; + const stack = createSemanticCallableIdStack( + new Set(callables.map((item) => item.callableId)), + ); + yield* traverseJavaScriptValueAstSteps(program, state.erasedSyntaxNodes, { + enter: (node, parent) => { + stack.enter(node, parent); + const returnValue = t.isReturnStatement(node) + ? node.argument + : t.isArrowFunctionExpression(node) && !t.isBlockStatement(node.body) + ? node.body + : null; + if (returnValue != null) { + const expression = unwrapJavaScriptExpression(returnValue).node; + const exact = readExactJavaScriptLiteral(expression); + if (exact.found) { + const value = semanticPrimitiveSet([exact.value]); + if (value.status === "literal" && literalReturns.length < budget) + literalReturns.push({ + location: range(expression), + value: value.value, + }); + else if (value.status === "literal") truncated = true; + else if ( + value.status === "unknown" && + value.resourceLimit === "primitive-bytes" + ) + state.valueResourceLimits.add("primitive-bytes"); + } + } + if ( + t.isVariableDeclarator(node) && + t.isIdentifier(node.id) && + node.init != null + ) { + const init = unwrapJavaScriptExpression(node.init).node; + if (isInfluenceExpression(init)) { + if (initializers.length < budget) + initializers.push({ target: range(node.id), source: range(init) }); + else truncated = true; + } + } + if ( + isInfluenceExpression(node) && + !(t.isTemplateLiteral(node) && t.isTaggedTemplateExpression(parent)) + ) + if (candidates.length < budget) + candidates.push({ + node, + ownerCallableId: stack.callables.at(-1) ?? null, + }); + else truncated = true; + }, + exit: (node) => stack.exit(node), + }); + let processedOperands = 0; + for (const [ + candidateIndex, + { node, ownerCallableId }, + ] of candidates.entries()) { + if (candidateIndex % 64 === 0) yield; + let operator: string; + let values: readonly t.Node[]; + if (t.isBinaryExpression(node) || t.isLogicalExpression(node)) { + operator = node.operator; + values = [node.left, node.right]; + } else if (t.isUnaryExpression(node)) { + operator = node.operator; + values = [node.argument]; + } else if (t.isTemplateLiteral(node)) { + operator = "template"; + values = node.expressions; + } else if (t.isConditionalExpression(node)) { + operator = "?:"; + values = [node.test, node.consequent, node.alternate]; + } else if (t.isSequenceExpression(node)) { + operator = ","; + values = node.expressions; + } else continue; + const operands: JavaScriptSemanticIr["expressionFlows"][number]["operands"][number][] = + []; + for (const [index, value] of values.entries()) { + if (processedOperands >= budget) { + truncated = true; + break; + } + if (processedOperands % 64 === 0) yield; + processedOperands += 1; + const conditional = + (t.isLogicalExpression(node) && index === 1) || + (t.isConditionalExpression(node) && index > 0); + // Earlier sequence expressions may change the final result via side effects. + const unsupported = + (t.isUnaryExpression(node) && + (node.operator === "void" || node.operator === "delete")) || + (t.isSequenceExpression(node) && index !== values.length - 1); + const operand = unwrapJavaScriptExpression(value).node; + const exact = readExactJavaScriptLiteral(operand); + const literal = exact.found ? semanticPrimitiveSet([exact.value]) : null; + if ( + literal?.status === "unknown" && + literal.resourceLimit === "primitive-bytes" + ) + state.valueResourceLimits.add("primitive-bytes"); + operands.push({ + location: range(operand), + conditional, + kind: unsupported + ? "unsupported" + : t.isIdentifier(operand) + ? "reference" + : literal?.status === "literal" + ? "literal" + : isInfluenceExpression(operand) + ? "expression" + : "unsupported", + ...(!unsupported && literal?.status === "literal" + ? { literal: literal.value } + : {}), + }); + } + expressions.push({ + location: range(node), + operator, + ownerCallableId, + operands, + }); + } + return { expressions, initializers, literalReturns, truncated }; +} + /** Keep assignment syntax in the IR so graph consumers never reparse source. */ function* collectAssignmentFlowsSteps( program: t.Program, diff --git a/src/domain/javascript/javascriptSemanticGraphSchemas.ts b/src/domain/javascript/javascriptSemanticGraphSchemas.ts index 7e4097124a..686f0b4822 100644 --- a/src/domain/javascript/javascriptSemanticGraphSchemas.ts +++ b/src/domain/javascript/javascriptSemanticGraphSchemas.ts @@ -101,6 +101,7 @@ export const JAVASCRIPT_SEMANTIC_RELATIONS = [ "detaches-task", "dispatches-candidate", "forwards-signal", + "influences", "listens-error", "listens-exit", "owns", @@ -149,6 +150,7 @@ export const JAVASCRIPT_SEMANTIC_RELATION_FAMILY = { "detaches-task": "promise-ownership", "dispatches-candidate": "event", "forwards-signal": "child-process", + influences: "data-flow", "listens-error": "child-process", "listens-exit": "child-process", owns: "promise-ownership", diff --git a/src/domain/javascript/javascriptSemanticGraphTraversal.test.ts b/src/domain/javascript/javascriptSemanticGraphTraversal.test.ts index 14fc0b8eef..1b731ca877 100644 --- a/src/domain/javascript/javascriptSemanticGraphTraversal.test.ts +++ b/src/domain/javascript/javascriptSemanticGraphTraversal.test.ts @@ -270,13 +270,37 @@ it("keeps relevant dynamic frontiers unknown", () => { direction: "forward-influence", }), ); - expect(result.status).toBe("found"); + expect(result.status).toBe("partial"); expect(result.coverage.status).toBe("partial"); expect(result.unknowns).toMatchObject([ { reason: "dynamic-call", relation_kinds: ["calls"] }, ]); }); +it("reports partial status when retained graph facts were truncated", () => { + const graph = semanticFixtureGraph(); + const partial = createJavaScriptSemanticGraph({ + ...constructionInput(graph), + coverage: { + ...graph.coverage, + status: "partial", + truncated: true, + omitted_nodes: null, + omitted_relations: null, + limits: [{ name: "nodes", value: 1, unit: "items" }], + }, + limitations: ["The node ceiling left omission extents unknown."], + }); + const result = queryJavaScriptSemanticGraph( + partial, + javaScriptSemanticQueryInputSchema.parse({ + seed: { kind: "literal", value: "TOKEN" }, + direction: "forward-influence", + }), + ); + expect(result.status).toBe("partial"); +}); + it("excludes candidate edges unless the caller explicitly opts in", () => { const graph = graphWithCandidateEdge(); const input = { diff --git a/src/domain/javascript/javascriptSemanticIr.ts b/src/domain/javascript/javascriptSemanticIr.ts index 545b952f49..a4374f8bed 100644 --- a/src/domain/javascript/javascriptSemanticIr.ts +++ b/src/domain/javascript/javascriptSemanticIr.ts @@ -128,6 +128,8 @@ export interface JavaScriptSemanticCallSite { readonly callerCallableId: string | null; readonly location: JavaScriptSourceRange; readonly calleeLocation: JavaScriptSourceRange; + /** The call's value appears in an enclosing expression or return. */ + readonly resultUsed: boolean; readonly resolution: "exact" | "ambiguous" | "unresolved"; readonly calleeCallableIds: readonly string[]; readonly arguments: readonly JavaScriptSemanticCallArgument[]; @@ -441,6 +443,25 @@ export interface JavaScriptSemanticAssignmentFlow { readonly ownerCallableId: string | null; } +/** Syntactic inputs to one operator result; this is influence, not identity. */ +export interface JavaScriptSemanticExpressionFlow { + readonly location: JavaScriptSourceRange; + readonly operator: string; + readonly operands: readonly { + readonly location: JavaScriptSourceRange; + readonly kind: "reference" | "expression" | "literal" | "unsupported"; + readonly conditional: boolean; + readonly literal?: string | number | boolean | null; + }[]; + readonly ownerCallableId: string | null; +} + +/** A direct identifier declaration initialized by an operator expression. */ +export interface JavaScriptSemanticInitializerFlow { + readonly target: JavaScriptSourceRange; + readonly source: JavaScriptSourceRange; +} + /** Provider-neutral JavaScript semantic IR. */ export interface JavaScriptSemanticIr { readonly schema: "JavaScriptSemanticIR"; @@ -464,6 +485,13 @@ export interface JavaScriptSemanticIr { readonly boundaryOperations: readonly JavaScriptSemanticBoundaryOperation[]; readonly resourceOperations: readonly JavaScriptSemanticResourceOperation[]; readonly assignmentFlows: readonly JavaScriptSemanticAssignmentFlow[]; + readonly expressionFlows: readonly JavaScriptSemanticExpressionFlow[]; + readonly initializerFlows: readonly JavaScriptSemanticInitializerFlow[]; + readonly literalReturns: readonly { + readonly location: JavaScriptSourceRange; + readonly value: string | number | boolean | null; + }[]; + readonly expressionFlowsTruncated: boolean; readonly objectOperations: readonly JavaScriptSemanticObjectOperation[]; readonly functionFingerprints: readonly JavaScriptSemanticFunctionFingerprint[]; readonly frontiers: readonly JavaScriptSemanticFrontier[]; @@ -494,6 +522,10 @@ export const failedJavaScriptSemanticIr = (): JavaScriptSemanticIr => ({ boundaryOperations: [], resourceOperations: [], assignmentFlows: [], + expressionFlows: [], + initializerFlows: [], + literalReturns: [], + expressionFlowsTruncated: false, objectOperations: [], functionFingerprints: [], frontiers: [], diff --git a/src/domain/javascript/javascriptSemanticQueryAssessment.ts b/src/domain/javascript/javascriptSemanticQueryAssessment.ts index 001f38f95a..6079c14344 100644 --- a/src/domain/javascript/javascriptSemanticQueryAssessment.ts +++ b/src/domain/javascript/javascriptSemanticQueryAssessment.ts @@ -48,6 +48,8 @@ const queryStatus = ( ): JavaScriptSemanticQueryResult["status"] => { if (input.graph.coverage.status === "unavailable") return "unsupported"; if (input.candidateRelations > 0) return "ambiguous"; + if (input.unknowns.length > 0 || input.graph.coverage.truncated) + return "partial"; if ( input.totalSeeds === 0 || (input.hasExpectation && input.expectedMatches === 0) diff --git a/src/domain/javascript/javascriptSemanticQueryIdentity.ts b/src/domain/javascript/javascriptSemanticQueryIdentity.ts index e67fdbda96..c858ccac89 100644 --- a/src/domain/javascript/javascriptSemanticQueryIdentity.ts +++ b/src/domain/javascript/javascriptSemanticQueryIdentity.ts @@ -56,9 +56,27 @@ export const resolveJavaScriptSemanticQuerySeeds = ( ) : [], ); + const seed = input.seed; + const representedCalls = new Set( + seed.kind === "literal" + ? graph.nodes.flatMap((node) => + node.kind === "expression" && + node.properties.literal_argument_value === seed.value && + typeof node.properties.literal_argument_call_site_node_id === "string" + ? [node.properties.literal_argument_call_site_node_id] + : [], + ) + : [], + ); return graph.nodes .filter((node) => - seedMatches(node, input, fingerprintFunctions, unresolvedAccesses), + seedMatches( + node, + input, + fingerprintFunctions, + unresolvedAccesses, + representedCalls, + ), ) .map(({ node_id }) => node_id) .sort(compareUnicodeCodePoints); @@ -69,6 +87,7 @@ const seedMatches = ( input: JavaScriptSemanticQueryInput, fingerprintFunctions: ReadonlySet, unresolvedAccesses: ReadonlySet, + representedCalls: ReadonlySet, ): boolean => { const seed = input.seed; if (seed.kind === "semantic-node") return node.node_id === seed.node_id; @@ -77,6 +96,12 @@ const seedMatches = ( if (seed.kind === "function") return fingerprintFunctions.has(node.node_id); if (seed.kind === "literal") { if (node.kind === "literal") return node.properties.value === seed.value; + if ( + node.kind === "expression" && + typeof node.properties.literal_argument_call_site_node_id === "string" + ) + return node.properties.literal_argument_value === seed.value; + if (representedCalls.has(node.node_id)) return false; return semanticCallLiteralValues(node).some( (value) => value === seed.value, ); diff --git a/tests/acceptance/applications/javascriptFeatureTraceSeedSelectionCli.test.ts b/tests/acceptance/applications/javascriptFeatureTraceSeedSelectionCli.test.ts index 50001cce6e..477160d48b 100644 --- a/tests/acceptance/applications/javascriptFeatureTraceSeedSelectionCli.test.ts +++ b/tests/acceptance/applications/javascriptFeatureTraceSeedSelectionCli.test.ts @@ -281,8 +281,19 @@ cliTest( const semanticEvidence = toolContract( "trace_javascript_semantics", ).outputSchema.parse(semantic.structuredContent); + const argument = result.semantic_graph.nodes.find( + (node) => + node.kind === "expression" && + node.properties.literal_argument_value === "proc_named" && + node.properties.literal_argument_call_site_node_id === callSite.node_id, + ); + if (argument === undefined) + throw new Error("Expected literal argument node"); expect(semanticEvidence.normalized_result).toMatchObject({ - seed_node_ids: expect.arrayContaining([callSite.node_id]), + seed_node_ids: expect.arrayContaining([argument.node_id]), + nodes: expect.arrayContaining([ + expect.objectContaining({ node_id: callSite.node_id }), + ]), }); }, ); diff --git a/tests/boundary/javascriptSemanticTraceEvidence.test.ts b/tests/boundary/javascriptSemanticTraceEvidence.test.ts index a5863efbe3..33a464a14b 100644 --- a/tests/boundary/javascriptSemanticTraceEvidence.test.ts +++ b/tests/boundary/javascriptSemanticTraceEvidence.test.ts @@ -273,6 +273,90 @@ it("traces exact empty property names through CLI and MCP", async () => { } }); +it("traces transformed operands through CLI and advertised MCP output", async () => { + const root = await createTestTempDirectory("rea-semantic-expression-trace-"); + await writeFile( + join(root, "main.js"), + 'const base = "https://example.test"; const url = base + "/x"; fetch(url); consume("only-direct"); const nested = "nested-token"; const box = { url: wrap(nested + "/x") };', + ); + const cli = createCli({}); + const analyzed = await serveCli(cli, [ + "analyze-javascript-application", + root, + "--json", + ]); + expect(analyzed.exitCode, analyzed.stdout).toBe(0); + const application = parseEvidence(JSON.parse(analyzed.stdout)); + const harness = await createApplicationMcpHarness(); + try { + const result = await assertSuccessfulTraceParity( + cli, + harness, + application, + { + seed: { kind: "literal", value: "https://example.test" }, + direction: "forward-influence", + }, + ); + expect(result.nodes.map((node) => node.label)).toContain("argument 0"); + expect( + result.relations.some((edge) => edge.relation === "influences"), + ).toBe(true); + const direct = await assertSuccessfulTraceParity( + cli, + harness, + application, + { + seed: { kind: "literal", value: "only-direct" }, + direction: "forward-influence", + }, + ); + expect(direct.summary.total_seed_matches).toBe(1); + expect( + direct.nodes.find((node) => node.node_id === direct.seed_node_ids[0]) + ?.label, + ).toBe("argument 0"); + const nested = await assertSuccessfulTraceParity( + cli, + harness, + application, + { + seed: { kind: "literal", value: "nested-token" }, + direction: "forward-influence", + }, + ); + expect(nested.status).not.toBe("found"); + expect(nested.unknowns.map((unknown) => unknown.detail)).toContain( + "The dynamic call's return dependency on this argument is unknown.", + ); + } finally { + await harness.close(); + } +}); + +it("reports offered semantic capacity when a large template exceeds it", async () => { + const root = await createTestTempDirectory("rea-semantic-expression-budget-"); + const expressions = Array.from({ length: 500 }, () => "${base}").join(""); + await writeFile( + join(root, "main.js"), + `const base = "token"; const value = \`${expressions}\`;`, + ); + const analyzed = await serveCli(createCli({}), [ + "analyze-javascript-application", + root, + "--max-semantic-nodes-per-file", + "4", + "--json", + ]); + expect(analyzed.exitCode, analyzed.stdout).toBe(0); + const application = parseEvidence(JSON.parse(analyzed.stdout)); + const result = javascriptApplicationAnalysisResultSchema.parse( + application.normalized_result, + ); + expect(result.semantic_graph.coverage.truncated).toBe(true); + expect(result.semantic_graph.coverage.status).toBe("partial"); +}); + const assertSuccessfulTraceParity = async ( cli: ReturnType, harness: Awaited>, diff --git a/tests/boundary/mcp/javascriptLiteralPayload.test.ts b/tests/boundary/mcp/javascriptLiteralPayload.test.ts index b35cdbd3bd..fb8442f5db 100644 --- a/tests/boundary/mcp/javascriptLiteralPayload.test.ts +++ b/tests/boundary/mcp/javascriptLiteralPayload.test.ts @@ -8,6 +8,7 @@ import { expect } from "vitest"; import { toolContract } from "../../../src/contracts/toolContracts.js"; import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { javaScriptSemanticTraceResultSchema } from "../../../src/domain/javascript/javascriptSemanticTraceSchemas.js"; +import { compareUnicodeCodePoints } from "../../../src/domain/unicodeCodePointOrder.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; @@ -112,8 +113,8 @@ cliTest( node.kind === "literal" && (node.properties.value === 1 || node.properties.value === "1"), ); - expect(choices.length).toBe(2); - expect(new Set(choices.map((node) => node.node_id)).size).toBe(2); + expect(choices.length).toBe(4); + expect(new Set(choices.map((node) => node.node_id)).size).toBe(4); expect( graph.nodes.some( (node) => node.kind === "literal" && node.properties.value === "", @@ -169,7 +170,8 @@ cliTest( expect(valueTrace.seed_node_ids).toEqual( choices .filter((node) => node.properties.value === "1") - .map((node) => node.node_id), + .map((node) => node.node_id) + .sort(compareUnicodeCodePoints), ); await client.ping(); } finally {