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 {