diff --git a/src/application/javascript/JavaScriptSemanticGraphAssignments.test.ts b/src/application/javascript/JavaScriptSemanticGraphAssignments.test.ts index b63ccae7fc..98d07b8941 100644 --- a/src/application/javascript/JavaScriptSemanticGraphAssignments.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphAssignments.test.ts @@ -3,6 +3,42 @@ import { expect, it } from "vitest"; import { graphForJavaScript } from "../../../tests/fixtures/javascriptSemanticGraph.js"; import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js"; +it("retains nested initializer literals as uncertain candidates after a dynamic-scope write", () => { + const graph = graphForJavaScript(` + const config = { nested: { flag: "before", items: ["inside"] }, stable: "unchanged" }; + with (env) { config.nested = replacement; } + `); + for (const [value, pointer] of [ + ["before", "/nested/flag"], + ["inside", "/nested/items/0"], + ]) { + const trace = queryJavaScriptSemanticGraph( + graph, + javaScriptSemanticQueryInputSchema.parse({ + seed: { kind: "literal", value }, + direction: "forward-influence", + include_ambiguous_dynamic_edges: true, + }), + ); + const slot = trace.nodes.find( + (node) => + node.kind === "property-slot" && + node.properties.property_pointer === pointer, + ); + expect(slot?.properties.presence).toBe("unknown-coverage"); + expect(trace.relations).toContainEqual( + expect.objectContaining({ + target_node_id: slot?.node_id, + relation: "defines", + resolution: "candidate", + }), + ); + expect( + trace.unknowns.some((unknown) => unknown.reason === "unknown-value"), + ).toBe(true); + } +}); + it.each([ ["binding", "speed = accel; return speed;", "speed"], ["property", "state.speed = accel; return state.speed;", "read:/speed"], diff --git a/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts b/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts index eac56882a7..0a2280ce8c 100644 --- a/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts @@ -33,6 +33,150 @@ export const projectSemanticValues = ( target: bindingNode, path: [], }); + if (binding.initializerValue !== undefined) + projectInitializerCandidates( + context, + binding, + binding.initializerValue, + binding.value, + [], + ); + } +}; + +/** Preserve possible initializer flow when later effects make a slot unknown. */ +const projectInitializerCandidates = ( + context: SemanticFlowProjectionContext, + binding: JavaScriptSemanticBinding, + initial: JavaScriptSemanticValue, + current: JavaScriptSemanticValue, + path: readonly string[], +): void => { + const initialContainer = semanticContainer(initial); + const currentContainer = semanticContainer(current); + if (initialContainer === null || currentContainer === null) return; + const currentByName = new Map(); + for (const property of currentContainer.slots) + if (!currentByName.has(property.name)) + currentByName.set(property.name, property); + for (const initialProperty of initialContainer.slots) { + const currentProperty = currentByName.get(initialProperty.name); + if ( + initialProperty.presence !== "present" || + currentProperty === undefined || + currentProperty.presence === "absent" + ) + continue; + const propertyPath = [...path, initialProperty.name]; + if (currentProperty.value.status === "unknown") { + const slot = semanticPropertySlot( + context, + binding.bindingId, + propertyPath, + currentProperty, + ); + projectUnknownInitializer({ + context, + binding, + initial: initialProperty.value, + unknown: currentProperty.value, + target: slot, + path: propertyPath, + }); + } else { + projectInitializerCandidates( + context, + binding, + initialProperty.value, + currentProperty.value, + propertyPath, + ); + } + } +}; + +/** Retain initializer descendants as candidates without asserting the unknown receiver still exists. */ +const projectUnknownInitializer = ({ + context, + binding, + initial, + unknown, + target, + path, +}: { + context: SemanticFlowProjectionContext; + binding: JavaScriptSemanticBinding; + initial: JavaScriptSemanticValue; + unknown: Extract< + JavaScriptSemanticValue, + { status: "unknown" | "ambiguous" | "cycle" } + >; + target: ReturnType; + path: readonly string[]; +}): void => { + if (target === null) return; + const values = + initial.status === "literal" + ? [initial.value] + : initial.status === "union" + ? initial.values + : []; + for (const value of values) { + const literal = addLiteralNode( + context, + binding, + value, + `property:${semanticPropertyPointer(path)}`, + ); + addSemanticGraphRelation(context.state, { + source: literal, + target, + relation: "defines", + resolution: "candidate", + }); + if (literal !== null) + addSemanticGraphUnknown(context.state, { + node_id: literal.node_id, + family: "data-flow", + relation_kinds: ["defines"], + reason: "unknown-value", + detail: `The initializer may no longer determine this property: ${unknown.reason}`, + candidate_node_ids: [target.node_id], + evidence: unknownSemanticEvidence( + context.file, + binding.definitions[0]?.location ?? null, + ), + }); + } + const container = semanticContainer(initial); + if (container === null) return; + for (const property of container.slots) { + if (property.presence !== "present") continue; + const propertyPath = [...path, property.name]; + const slot = semanticPropertySlot( + context, + binding.bindingId, + propertyPath, + { + name: property.name, + presence: "unknown-coverage", + value: unknown, + }, + ); + addSemanticGraphRelation(context.state, { + source: target, + target: slot, + relation: "writes-property", + resolution: "candidate", + }); + projectUnknownInitializer({ + context, + binding, + initial: property.value, + unknown, + target: slot, + path: propertyPath, + }); } }; diff --git a/src/domain/javascript/javascriptSemanticIr.ts b/src/domain/javascript/javascriptSemanticIr.ts index fb8d99dee7..545b952f49 100644 --- a/src/domain/javascript/javascriptSemanticIr.ts +++ b/src/domain/javascript/javascriptSemanticIr.ts @@ -80,6 +80,8 @@ export interface JavaScriptSemanticBinding { readonly mutable: boolean; readonly definitions: readonly JavaScriptSemanticDefinition[]; readonly value: JavaScriptSemanticValue; + /** Value at the single initializer, before later member effects. */ + readonly initializerValue?: JavaScriptSemanticValue; readonly provenance: JavaScriptBindingProvenance; } diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index e1dccfe70e..aff9ca30d6 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -23,6 +23,7 @@ import { import { resolveSemanticBindingState, + semanticBindingLookupAt, type JavaScriptBindingIndex, type JavaScriptSemanticBindingState, type JavaScriptSemanticAnalysisState, @@ -842,7 +843,11 @@ export function* collectSemanticMemberMutationsSteps( continue; } if (t.isIdentifier(expression)) { - const binding = resolveBinding(expression, expression.name); + let binding = resolveBinding(expression, expression.name); + const lookup = + binding === undefined + ? semanticBindingLookupAt(state, expression, expression.name) + : undefined; if (binding === undefined) { for (const path of ambientPaths( expression, @@ -877,8 +882,11 @@ export function* collectSemanticMemberMutationsSteps( arrayPrototypeIterationMayChange(current.path.slice(2))) ) state.arrayIterationUnknown = true; - continue; + if (lookup?.kind !== "uncertain-binding") continue; + binding = lookup.binding; } + const definiteReceiver = + current.definiteReceiver && lookup?.kind !== "uncertain-binding"; // An alias restored from a saved reference can revisit the same binding // at an earlier capture or a shorter getter-receiver path. Only a // repeated capture and path closes a cycle. @@ -969,7 +977,7 @@ export function* collectSemanticMemberMutationsSteps( current.originAt?.start ?? -1 }\u0001${current.originAt?.end ?? -1}\u0001${ current.ownerInitializer?.start ?? -1 - }\u0001${current.ownerInitializer?.end ?? -1}\u0001${widenedGetterResult}\u0001${current.definiteReceiver}\u0001${assignedSources?.[0]?.target?.start ?? -1}\u0001${current.memberReadAt?.start ?? -1}\u0001${current.memberReadAt?.end ?? -1}`; + }\u0001${current.ownerInitializer?.end ?? -1}\u0001${widenedGetterResult}\u0001${definiteReceiver}\u0001${assignedSources?.[0]?.target?.start ?? -1}\u0001${current.memberReadAt?.start ?? -1}\u0001${current.memberReadAt?.end ?? -1}`; let covering = coveringPaths.get(pathState); if (covering === undefined) { covering = new SemanticPropertyPathCoverage(); @@ -1142,9 +1150,7 @@ export function* collectSemanticMemberMutationsSteps( }); else binding.memberWrites.push({ - receiverIdentity: current.definiteReceiver - ? "definite" - : "possible", + receiverIdentity: definiteReceiver ? "definite" : "possible", path, node: mutation, receiverInitializers, @@ -1183,7 +1189,7 @@ export function* collectSemanticMemberMutationsSteps( path: [...initializer.projection, ...path], bindings: nested, definiteReceiver: - current.definiteReceiver && + definiteReceiver && origins.initializers.length + origins.referenceInitializers.length === 1, @@ -1209,7 +1215,7 @@ export function* collectSemanticMemberMutationsSteps( path: copiedPath, bindings: nested, definiteReceiver: - current.definiteReceiver && + definiteReceiver && origins.initializers.length + origins.referenceInitializers.length === 1 && diff --git a/src/domain/javascript/javascriptSemanticProjection.ts b/src/domain/javascript/javascriptSemanticProjection.ts index e5e3217fe6..3ad7cc3af0 100644 --- a/src/domain/javascript/javascriptSemanticProjection.ts +++ b/src/domain/javascript/javascriptSemanticProjection.ts @@ -20,6 +20,7 @@ import { } from "./javascriptSemanticTraversal.js"; import { evaluateSemanticBinding, + evaluateSemanticExpression, evaluateSemanticProvenance, } from "./javascriptSemanticValues.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; @@ -268,6 +269,17 @@ export const immutableSemanticBindings = ( mutable: binding.mutable, definitions: binding.definitions, value: evaluateSemanticBinding(binding, state), + ...(binding.memberWrites.length > 0 && + binding.initializers.length === 1 && + binding.initializers[0]?.projection.length === 0 + ? { + initializerValue: evaluateSemanticExpression( + binding.initializers[0].node, + state, + binding.initializers[0].node, + ), + } + : {}), provenance: evaluateSemanticProvenance(binding, state), })) .sort((left, right) => diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index 31a7f5f311..2f276d85dd 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -615,18 +615,24 @@ const scope = z.strictObject({ bindingsComplete: z.boolean(), bindingIds: strings, }); -const binding = z.strictObject({ - bindingId: z.string(), - scopeId: z.string(), - name: z.string(), - kind: definitionKind, - mutable: z.boolean(), - definitions: z.array( - z.strictObject({ kind: definitionKind, location: range }), - ), - value: semanticValue, - provenance, -}); +const binding = z + .strictObject({ + bindingId: z.string(), + scopeId: z.string(), + name: z.string(), + kind: definitionKind, + mutable: z.boolean(), + definitions: z.array( + z.strictObject({ kind: definitionKind, location: range }), + ), + value: semanticValue, + initializerValue: semanticValue.optional(), + provenance, + }) + .transform(({ initializerValue, ...rest }) => ({ + ...rest, + ...(initializerValue === undefined ? {} : { initializerValue }), + })); const callable = z.strictObject({ callableId: z.string(), kind: z.enum(["function", "class", "method"]), diff --git a/tests/acceptance/applications/withMemberMutationCli.test.ts b/tests/acceptance/applications/withMemberMutationCli.test.ts new file mode 100644 index 0000000000..b35b5bc0e9 --- /dev/null +++ b/tests/acceptance/applications/withMemberMutationCli.test.ts @@ -0,0 +1,91 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { javaScriptSemanticTraceResultSchema } from "../../../src/domain/javascript/javascriptSemanticTraceSchemas.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "preserves uncertainty from a member write through with scope", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-with-member-write-"); + await writeFile( + join(root, "main.cjs"), + ` +const shared = { mode: "initial", keep: 1 }; +const unrelated = {}; +const counter = { n: 1 }; +const removed = { n: 1 }; +const original = { n: 1 }; +const alias = original; +const plain = { value: "plain-before" }; +const nested = { child: { flag: "nested-before", values: ["array-before"] } }; +plain.value = "plain-after"; +with (unrelated) { shared.mode = "updated"; counter.n++; delete removed.n; alias.n = 2; nested.child = replacement; } +module.exports.read = function read() { return shared.mode; }; +module.exports.keep = function keep() { return shared.keep; }; +module.exports.counter = function readCounter() { return counter.n; }; +module.exports.removed = function readRemoved() { return removed.n; }; +module.exports.aliased = function readAliased() { return original.n; }; +module.exports.plain = function readPlain() { return plain.value; }; +`, + ); + const analysis = await cli.run({ + arguments: ["analyze-javascript-application", root, "--format", "json"], + }); + expect(analysis.exitCode, analysis.stderr).toBe(0); + const application = parseEvidence(analysis.json); + const result = javascriptApplicationAnalysisResultSchema.parse( + application.normalized_result, + ); + const observations = result.graph.nodes.flatMap( + ({ observations }) => observations, + ); + for (const [name, status] of [ + ["read", "unknown"], + ["keep", "literal"], + ["counter", "unknown"], + ["removed", "unknown"], + ["aliased", "unknown"], + ["plain", "unknown"], + ] as const) { + const projection = observations.find( + ({ properties }) => + properties.semantic_role === "export-return-shapes" && + properties.exported_name === name, + ); + expect(projection, name).toBeDefined(); + expect(projection?.properties.static_return_shapes).toMatchObject([ + { + value_status: status, + fields: [{ state: status, ...(name === "keep" ? { value: 1 } : {}) }], + }, + ]); + } + const call = await cli.sharedMcpCall?.(); + if (call === undefined) throw new Error("Expected shared MCP session"); + for (const value of [ + "initial", + "plain-before", + "nested-before", + "array-before", + ]) { + const traced = await call("trace_javascript_semantics", { + application, + query: { + seed: { kind: "literal", value }, + direction: "forward-influence", + }, + }); + expect(traced.isError, JSON.stringify(traced.content)).not.toBe(true); + const trace = javaScriptSemanticTraceResultSchema.parse( + parseEvidence(traced.structuredContent).normalized_result, + ); + expect(trace.summary.total_seed_matches).toBeGreaterThan(0); + expect(trace.summary.relevant_unknowns).toBeGreaterThan(0); + } + }, +);