From 475a0f84dac63d710e032bc7f731c1a3bda624ec Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:03:19 +0800 Subject: [PATCH] fix(javascript)!: preserve property paths and uncertain slot presence Use one explicit object/array slot model and JSON-pointer property identity across mutation analysis, return projection, and semantic graphs. Require observed presence for discriminants and resolve writes only through retained object or array receivers. BREAKING CHANGE: projected return fields require explicit presence and semantic object operations carry full property paths. --- ...JavaScriptApplicationEmptyLiterals.test.ts | 78 +++++- .../JavaScriptSemanticGraphBuilder.test.ts | 243 +++++++++++++++- ...JavaScriptSemanticGraphObjectProjection.ts | 175 ++++++++---- .../JavaScriptSemanticGraphProjection.test.ts | 259 ------------------ .../JavaScriptSemanticGraphValueProjection.ts | 64 +++-- ...scriptExportShapeComparisonSchemas.test.ts | 15 + .../javascriptExportShapeComparisonSchemas.ts | 2 +- .../javascriptExportShapeSelection.ts | 26 +- ...avascriptSemanticAnalysis.dataflow.test.ts | 35 ++- ...criptSemanticAnalysis.member_depth.test.ts | 6 +- ...tSemanticAnalysis.member_mutations.test.ts | 3 + ...vascriptSemanticAnalysis.rejection.test.ts | 6 +- src/domain/javascript/javascriptSemanticIr.ts | 2 +- .../javascriptSemanticMemberMutations.ts | 11 +- .../javascriptSemanticMutationValues.ts | 35 +-- .../javascript/javascriptSemanticObjects.ts | 133 ++++++--- .../javascriptSemanticResourceLimits.ts | 2 +- .../javascript/javascriptSemanticReturns.ts | 69 ++--- .../javascript/javascriptSemanticSlots.ts | 108 ++++++++ .../javascriptSemanticValueTypes.ts | 15 +- .../javascript/javascriptSemanticValues.ts | 77 ++---- .../javascriptExportShapeComparison.test.ts | 13 +- .../mcp/applicationExportShapeMcp.test.ts | 14 +- 23 files changed, 831 insertions(+), 560 deletions(-) delete mode 100644 src/application/javascript/JavaScriptSemanticGraphProjection.test.ts create mode 100644 src/domain/javascript/javascriptSemanticSlots.ts diff --git a/src/application/javascript/JavaScriptApplicationEmptyLiterals.test.ts b/src/application/javascript/JavaScriptApplicationEmptyLiterals.test.ts index 1503bf634..b200b91ea 100644 --- a/src/application/javascript/JavaScriptApplicationEmptyLiterals.test.ts +++ b/src/application/javascript/JavaScriptApplicationEmptyLiterals.test.ts @@ -106,6 +106,8 @@ it("keeps exact values for empty literals without synthetic application labels", it.each([ { version: 3, sources: [""], names: [], mappings: "AAAA" }, + { version: 3, sourceRoot: null, sources: [""], names: [], mappings: "AAAA" }, + { version: 3, sourceRoot: "", sources: [""], names: [], mappings: "AAAA" }, { version: 3, sections: [ @@ -126,7 +128,9 @@ it.each([ identity.strategy === "source-map-original" ? identity.original_source : null, - sources: observations.map(({ properties }) => properties.source), + sources: observations.map( + ({ source_map_reference }) => source_map_reference?.source_name, + ), })); expect(originals).toEqual([{ originalSource: "", sources: [""] }]); }); @@ -267,3 +271,75 @@ it("selects an empty context-bridge API with an exact empty seed", async () => { }); expect(matches.map(({ node_id: nodeId }) => apis.get(nodeId))).toEqual([""]); }); + +it("pairs indexed source-map leaves by raw source name and root", async () => { + const map = (value: number) => + JSON.stringify({ + version: 3, + sections: ["one", "two"].map((name, line) => ({ + offset: { line, column: 0 }, + map: { + version: 3, + sourceRoot: `../${name}`, + sources: ["same.js"], + sourcesContent: [`export const value = ${String(value)};`], + names: [], + mappings: "AAAA", + }, + })), + }); + const left = await analyzeSource("//# sourceMappingURL=app.js.map", { + "app.js.map": map(1), + }); + const right = await analyzeSource("//# sourceMappingURL=app.js.map", { + "app.js.map": map(2), + }); + const matching = matchJavaScriptApplicationVersions( + left.graph.nodes, + right.graph.nodes, + ); + const originals = matching.pairs.filter( + ({ left: node }) => node.kind === "source-module", + ); + expect(originals).toHaveLength(2); + for (const pair of originals) { + expect(pair).toMatchObject({ + basis: "source-map-identity", + confidence: "high", + }); + expect(pair.left.identity).toMatchObject({ + original_source: "same.js", + source_root: expect.any(String), + }); + if (pair.left.identity.strategy !== "source-map-original") + throw new Error("Expected source identity"); + expect(pair.right.identity).toMatchObject({ + source_root: pair.left.identity.source_root, + }); + } +}); + +it("reports malformed source roots without manufacturing an original path", async () => { + const output = await analyzeSource("//# sourceMappingURL=app.js.map", { + "app.js.map": JSON.stringify({ + version: 3, + sourceRoot: 42, + sources: ["a.js"], + names: [], + mappings: "AAAA", + }), + }); + expect( + output.graph.nodes.filter(({ kind }) => kind === "source-module"), + ).toEqual([]); + expect( + output.graph.nodes.flatMap(({ observations }) => observations), + ).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + limitation: "Source map sourceRoot must be a string or null.", + }), + evidence: expect.objectContaining({ state: "unavailable" }), + }), + ); +}); diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts index 03a8231dc..c8a63e944 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts @@ -147,7 +147,9 @@ it("retains resource-limit reasons at nested object property slots", () => { expect.objectContaining({ family: "object-flow", reason: "resource-limit", - detail: expect.stringMatching(/Unknown value at property:value/u), + detail: expect.stringMatching( + /Unknown value at property:\/nested\/value/u, + ), }), ); }); @@ -625,3 +627,242 @@ it("only records node loss when the budget rejects a new identity", () => { expect(blocked).toBeNull(); expect(state.fileNodesDropped).toBe(true); }); + +it("keeps full static property identities distinct through backward provenance", () => { + const graph = graphFor(` + const root = { a: { id: 'left' }, b: { id: 'right' }, 'a.id': 'dotted', '': { 'a/b~c': 'escaped' } }; + const left = root.a.id; + const right = root['b']['id']; + const missing = root.id; + const dotted = root['a.id']; + const escaped = root['']['a/b~c']; + `); + const slots = graph.nodes.filter(({ kind }) => kind === "property-slot"); + for (const [pointer, literal] of [ + ["/a/id", "left"], + ["/b/id", "right"], + ["/a.id", "dotted"], + ["//a~1b~0c", "escaped"], + ["/id", null], + ]) { + const slot = slots.find( + ({ properties }) => properties.property_pointer === pointer, + ); + if (slot === undefined) throw new Error(`Missing slot ${pointer}`); + expect(slot.properties.presence).toBe( + literal === null ? "absent" : "present", + ); + const trace = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "semantic-node", node_id: slot.node_id }, + direction: "backward-provenance", + allowed_relations: ["defines"], + }); + expect( + trace.nodes + .filter(({ kind }) => kind === "literal") + .map(({ properties }) => properties.value), + ).toEqual(literal === null ? [] : [literal]); + const reads = graph.relations.filter( + ({ source_node_id, relation }) => + source_node_id === slot.node_id && relation === "reads-property", + ); + expect(reads).toHaveLength(1); + expect(reads[0]?.resolution).toBe( + literal === null ? "candidate" : "resolved", + ); + } +}); + +it("targets nested writes and destructuring without inventing leaf-level root slots", () => { + const graph = graphFor(` + const root = { left: { id: 'before' }, right: { id: 'untouched' } }; + root.left.id = produce(); + const { right: { id: selected } } = root; + const [first, missing] = [1, , 3]; + `); + const rootSlots = graph.nodes.filter( + ({ kind, properties }) => + kind === "property-slot" && properties.name === "id", + ); + expect( + rootSlots.map(({ properties }) => properties.property_pointer).sort(), + ).toEqual(["/left/id", "/right/id"]); + const left = rootSlots.find( + ({ properties }) => properties.property_pointer === "/left/id", + ); + const right = rootSlots.find( + ({ properties }) => properties.property_pointer === "/right/id", + ); + expect(left?.properties.presence).toBe("unknown-coverage"); + const write = graph.nodes.find( + ({ kind, properties }) => + kind === "expression" && properties.operation_kind === "write", + ); + expect(graph.relations).toContainEqual( + expect.objectContaining({ + source_node_id: write?.node_id, + target_node_id: left?.node_id, + relation: "writes-property", + resolution: "resolved", + }), + ); + const selected = graph.nodes.find( + ({ kind, label }) => kind === "binding" && label === "selected", + ); + expect(graph.relations).toContainEqual( + expect.objectContaining({ + source_node_id: right?.node_id, + target_node_id: selected?.node_id, + relation: "destructures", + resolution: "resolved", + }), + ); +}); + +it("keeps dynamic intermediate receivers unresolved instead of selecting a static leaf", () => { + const graph = graphFor(` + const root = { id: 'root', a: { id: 'nested' } }; + const selected = root[key].id; + `); + expect( + graph.relations.filter(({ relation }) => relation === "reads-property"), + ).toEqual([]); + expect(graph.unknowns).toContainEqual( + expect.objectContaining({ + family: "object-flow", + relation_kinds: ["reads-property"], + }), + ); + const selected = graph.nodes.find( + ({ kind, label }) => kind === "binding" && label === "selected", + ); + if (selected === undefined) throw new Error("Missing selected binding"); + const trace = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "semantic-node", node_id: selected.node_id }, + direction: "backward-provenance", + allowed_relations: ["defines"], + }); + expect(trace.nodes.filter(({ kind }) => kind === "literal")).toEqual([]); +}); + +it.each([ + { + initializer: "{}", + path: "missing.id", + pointer: "/missing/id", + resolved: false, + }, + { + initializer: "{ a: null }", + path: "a.id", + pointer: "/a/id", + resolved: false, + }, + { initializer: "{ a: 1 }", path: "a.id", pointer: "/a/id", resolved: false }, + { initializer: "{ a: {} }", path: "a.id", pointer: "/a/id", resolved: true }, + { initializer: "{ a: [] }", path: "a[0]", pointer: "/a/0", resolved: true }, +])( + "admits a write only through a retained container receiver: $initializer.$path", + ({ initializer, path, pointer, resolved }) => { + const graph = graphFor( + `const root = ${initializer}; root.${path} = produce();`, + ); + const write = graph.nodes.find( + ({ kind, properties }) => + kind === "expression" && properties.operation_kind === "write", + ); + const slot = graph.nodes.find( + ({ kind, properties }) => + kind === "property-slot" && properties.property_pointer === pointer, + ); + if (write === undefined || slot === undefined) + throw new Error("Expected write and target slot"); + expect(graph.relations).toContainEqual( + expect.objectContaining({ + source_node_id: write.node_id, + target_node_id: slot.node_id, + relation: "writes-property", + resolution: resolved ? "resolved" : "candidate", + }), + ); + const trace = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "semantic-node", node_id: write.node_id }, + direction: "forward-influence", + allowed_relations: ["writes-property"], + }); + expect(trace.nodes.some(({ node_id }) => node_id === slot.node_id)).toBe( + resolved, + ); + if (!resolved) + expect(graph.unknowns).toContainEqual( + expect.objectContaining({ + node_id: slot.node_id, + relation_kinds: ["writes-property"], + detail: expect.stringContaining( + "receiver is unresolved or not a retained container", + ), + }), + ); + }, +); + +it("retains array length presence without inventing an exact length", () => { + const graph = graphFor("const root = [, 1]; const length = root.length;"); + const slot = graph.nodes.find( + ({ kind, properties }) => + kind === "property-slot" && properties.property_pointer === "/length", + ); + expect(slot?.properties).toMatchObject({ + name: "length", + presence: "present", + value_status: "unknown", + }); + const trace = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "property", name: "length" }, + direction: "forward-influence", + allowed_relations: ["reads-property"], + }); + expect(trace.seed_node_ids).toEqual([slot?.node_id]); + expect(trace.relations).toContainEqual( + expect.objectContaining({ + source_node_id: slot?.node_id, + relation: "reads-property", + resolution: "resolved", + }), + ); + expect( + graph.relations.some( + ({ target_node_id, relation }) => + target_node_id === slot?.node_id && relation === "defines", + ), + ).toBe(false); +}); + +it.each(["{}", "unknownRoot", "{ a: null }"])( + "preserves the requested deep leaf name after a blocked prefix: %s", + (initializer) => { + const graph = graphFor( + `const root = ${initializer}; const selected = root.a.b.c;`, + ); + const slot = graph.nodes.find( + ({ kind, properties }) => + kind === "property-slot" && properties.property_pointer === "/a/b/c", + ); + expect(slot).toMatchObject({ + label: "c", + properties: { + name: "c", + property_path: ["a", "b", "c"], + presence: "unknown-coverage", + value_status: "unknown", + }, + }); + const trace = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "property", name: "c" }, + direction: "backward-provenance", + allowed_relations: ["defines"], + }); + expect(trace.seed_node_ids).toEqual([slot?.node_id]); + expect(trace.nodes.filter(({ kind }) => kind === "literal")).toEqual([]); + }, +); diff --git a/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts b/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts index e0e3b78e8..cf301dc68 100644 --- a/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts @@ -1,3 +1,12 @@ +import { + semanticSlotAtPath, + semanticContainer, + semanticPropertyPointer, +} from "../../domain/javascript/javascriptSemanticSlots.js"; +import type { + JavaScriptSemanticSlotPresence, + JavaScriptSemanticValue, +} from "../../domain/javascript/javascriptSemanticValueTypes.js"; import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascript/javascriptSemanticGraph.js"; import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraph.js"; import type { JavaScriptSemanticObjectOperation } from "../../domain/javascript/javascriptSemanticIr.js"; @@ -14,56 +23,115 @@ import { semanticPropertySlot } from "./JavaScriptSemanticGraphValueProjection.j export const projectSemanticObjects = ( context: SemanticFlowProjectionContext, ): void => { + const values = new Map( + context.ir.bindings.map((binding) => [binding.bindingId, binding.value]), + ); for (const operation of context.ir.objectOperations) { const occurrence = addObjectOccurrence(context, operation); - const slot = - operation.objectBindingId === null || operation.propertyName === null - ? null - : semanticPropertySlot( - context, - operation.objectBindingId, - operation.propertyName, - ); - if (operation.kind === "read") - addSemanticGraphRelation(context.state, { - source: slot, - target: occurrence, - relation: "reads-property", - resolution: - operation.resolution === "complete" ? "resolved" : "candidate", - }); - else if (operation.kind === "write") - addSemanticGraphRelation(context.state, { - source: occurrence, - target: slot, - relation: "writes-property", - resolution: - operation.resolution === "complete" ? "resolved" : "candidate", - }); - else if (operation.kind === "destructure") - addSemanticGraphRelation(context.state, { - source: slot, + const target = objectTarget(context, values, operation); + const ends = objectRelationEnds(context, operation, occurrence, target); + const resolved = + operation.resolution === "complete" && + (operation.kind === "write" + ? target.receiverResolved + : target.presence === "present"); + addSemanticGraphRelation(context.state, { + ...ends, + relation: OBJECT_RELATIONS[operation.kind], + resolution: resolved ? "resolved" : "candidate", + }); + if (!resolved) + addObjectUnknown(context, operation, target.slot ?? occurrence, target); + } +}; + +const OBJECT_RELATIONS = { + read: "reads-property", + write: "writes-property", + destructure: "destructures", + spread: "spreads", +} as const; + +interface ObjectTarget { + readonly objectNode: JavaScriptSemanticGraphNode | null | undefined; + readonly slot: JavaScriptSemanticGraphNode | null; + readonly presence: JavaScriptSemanticSlotPresence; + readonly receiverResolved: boolean; +} + +const objectTarget = ( + context: SemanticFlowProjectionContext, + values: ReadonlyMap, + operation: JavaScriptSemanticObjectOperation, +): ObjectTarget => { + const binding = + operation.objectBindingId === null + ? undefined + : context.bindingNodes.get(operation.objectBindingId); + const value = + operation.objectBindingId === null + ? undefined + : values.get(operation.objectBindingId); + if ( + operation.objectBindingId === null || + operation.propertyPath === null || + value === undefined + ) + return { + objectNode: binding, + slot: null, + presence: "unknown-coverage", + receiverResolved: false, + }; + const fact = semanticSlotAtPath(value, operation.propertyPath); + if (operation.propertyPath.length === 0) + return { + objectNode: binding, + slot: null, + presence: fact.presence, + receiverResolved: semanticContainer(value) !== null, + }; + const slot = semanticPropertySlot( + context, + operation.objectBindingId, + operation.propertyPath, + fact, + ); + const receiver = semanticSlotAtPath( + value, + operation.propertyPath.slice(0, -1), + ); + return { + objectNode: slot, + slot, + presence: fact.presence, + receiverResolved: + receiver.presence === "present" && + semanticContainer(receiver.value) !== null, + }; +}; + +const objectRelationEnds = ( + context: SemanticFlowProjectionContext, + operation: JavaScriptSemanticObjectOperation, + occurrence: JavaScriptSemanticGraphNode | null, + target: ObjectTarget, +) => { + switch (operation.kind) { + case "read": + return { source: target.slot, target: occurrence }; + case "write": + return { source: occurrence, target: target.slot }; + case "destructure": + return { + source: target.slot, target: operation.targetBindingId === null ? undefined : context.bindingNodes.get(operation.targetBindingId), - relation: "destructures", - resolution: - operation.resolution === "complete" ? "resolved" : "candidate", - }); - else - addSemanticGraphRelation(context.state, { - source: - operation.objectBindingId === null - ? undefined - : context.bindingNodes.get(operation.objectBindingId), - target: occurrence, - relation: "spreads", - resolution: - operation.resolution === "complete" ? "resolved" : "candidate", - }); - if (operation.resolution !== "complete") - addObjectUnknown(context, operation, slot ?? occurrence); + }; + case "spread": + return { source: target.objectNode, target: occurrence }; } }; @@ -76,9 +144,9 @@ const addObjectOccurrence = ( roleKey: operation.objectOperationId, location: operation.location, label: - operation.propertyName === null + operation.propertyPath === null ? operation.kind - : `${operation.kind}:${operation.propertyName}`, + : `${operation.kind}:${semanticPropertyPointer(operation.propertyPath)}`, functionNodeId: operation.ownerCallableId === null ? null @@ -86,7 +154,8 @@ const addObjectOccurrence = ( null), properties: { operation_kind: operation.kind, - property_name: operation.propertyName, + property_path: + operation.propertyPath === null ? null : [...operation.propertyPath], }, }); @@ -94,15 +163,9 @@ const addObjectUnknown = ( context: SemanticFlowProjectionContext, operation: JavaScriptSemanticObjectOperation, node: JavaScriptSemanticGraphNode | null, + target: Pick, ): void => { - const relation = - operation.kind === "read" - ? "reads-property" - : operation.kind === "write" - ? "writes-property" - : operation.kind === "spread" - ? "spreads" - : "destructures"; + const relation = OBJECT_RELATIONS[operation.kind]; addSemanticGraphUnknown( context.state, createJavaScriptSemanticGraphUnknown({ @@ -110,7 +173,7 @@ const addObjectUnknown = ( family: "object-flow", relation_kinds: [relation], reason: "ambiguous-target", - detail: `Static ${operation.kind} object identity is partial.`, + detail: `Static ${operation.kind} property ${operation.propertyPath === null ? "path is unresolved" : semanticPropertyPointer(operation.propertyPath)} has ${target.presence} presence; its receiver is ${target.receiverResolved ? "a retained container" : "unresolved or not a retained container"}; object identity is ${operation.resolution}.`, candidate_node_ids: [], evidence: unknownSemanticEvidence(context.file, operation.location), }), diff --git a/src/application/javascript/JavaScriptSemanticGraphProjection.test.ts b/src/application/javascript/JavaScriptSemanticGraphProjection.test.ts deleted file mode 100644 index 086319bdc..000000000 --- a/src/application/javascript/JavaScriptSemanticGraphProjection.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { expect, it } from "vitest"; - -import { compareCodePoints } from "../../domain/canonicalOrdering.js"; -import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; -import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js"; -import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraph.js"; -import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js"; -import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; -import { - constructSemanticGraphNode, - createSemanticGraphProjectionState, -} from "./JavaScriptSemanticGraphConstruction.js"; -import { - createSemanticCallableOwnerLookup, - createSemanticNodeRangeLookup, - createSemanticCallSiteLookup, -} from "./JavaScriptSemanticGraphProjection.js"; -import { sourceRangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js"; - -const fixtureFile: JavaScriptArtifactFile = { - path: "app.js", - container_sha256: "a".repeat(64), - sha256: "b".repeat(64), - bytes: 0, - inventory_artifact_id: "fixture-app", - kind: "javascript", - unpacked: false, - text: { included: true, value: "" }, -}; - -const nodesFor = (ir: JavaScriptSemanticIr) => { - const state = createSemanticGraphProjectionState({ nodes: [] }); - - return new Map( - ir.callables.map((callable) => [ - callable.callableId, - constructSemanticGraphNode( - fixtureFile, - { - kind: "function", - roleKey: callable.callableId, - label: callable.name, - location: callable.location, - functionNodeId: null, - }, - state, - ), - ]), - ); -}; - -// Reference behavior before the optimization, including stable ties and -// fallback when the most deeply nested callable has no retained graph node. -const previousOwner = ( - location: JavaScriptSourceRange | null, - ir: JavaScriptSemanticIr, - nodes: ReadonlyMap, -) => { - if (location === null) return undefined; - const compare = ( - left: JavaScriptSourceRange["start"], - right: JavaScriptSourceRange["start"], - ) => left.line - right.line || left.column - right.column; - const key = (range: JavaScriptSourceRange) => - `${String(range.start.line).padStart(12, "0")}:${String(range.start.column).padStart(12, "0")}`; - return ir.callables - .filter( - (callable) => - compare(callable.location.start, location.start) <= 0 && - compare(callable.location.end, location.end) >= 0, - ) - .sort((left, right) => - compareCodePoints(key(right.location), key(left.location)), - ) - .map((callable) => nodes.get(callable.callableId)) - .find((node) => node !== undefined); -}; - -const rangesFor = (ir: JavaScriptSemanticIr) => [ - null, - ...ir.callables.map((callable) => callable.location), - ...ir.bindings.flatMap((binding) => - binding.definitions.map((definition) => definition.location), - ), - ...ir.references.map((reference) => reference.location), - { start: { line: 999, column: 0 }, end: { line: 999, column: 1 } }, -]; - -it("matches the prior owner selection across nested functions, arrows and methods", () => { - const ir = analyzeJavaScriptSemantics(` - const outside = 1; - function outer(parameter) { - function inner(value) { return value + parameter; } - const callback = argument => inner(argument); - return callback(outside); - } - class Example { method(value) { return () => outer(value); } } - `); - const nodes = nodesFor(ir); - const lookup = createSemanticCallableOwnerLookup(ir, nodes); - for (const range of rangesFor(ir)) - expect(lookup(range)).toBe(previousOwner(range, ir, nodes)); -}); - -it("reads retained node membership at query time and keeps file indexes independent", () => { - const ir = analyzeJavaScriptSemantics( - "function outer() { function inner() { return 1; } return inner(); }", - ); - const inner = ir.callables.find((callable) => callable.name === "inner"); - if (inner === undefined) throw new Error("Missing inner fixture callable"); - const nodes = nodesFor(ir); - const otherNodes = nodesFor(ir); - const lookup = createSemanticCallableOwnerLookup(ir, nodes); - const otherLookup = createSemanticCallableOwnerLookup(ir, otherNodes); - const before = lookup(inner.location); - expect(before?.label).toBe("inner"); - nodes.delete(inner.callableId); - expect(lookup(inner.location)?.label).toBe("outer"); - expect(otherLookup(inner.location)?.label).toBe("inner"); - if (before === undefined) throw new Error("Missing retained fixture node"); - nodes.set(inner.callableId, before); - expect(lookup(inner.location)).toBe(before); -}); - -it("preserves stable ties and non-nested overlapping ranges", () => { - const original = analyzeJavaScriptSemantics( - "function first() { return 1; } function second() { return 2; } function third() { return 3; }", - ); - const range = (start: number, end: number): JavaScriptSourceRange => ({ - start: { line: 1, column: start }, - end: { line: 1, column: end }, - }); - const ir = { - ...original, - callables: original.callables.map((callable, index) => ({ - ...callable, - location: index === 0 ? range(0, 90) : range(20, index === 1 ? 60 : 80), - })), - }; - const nodes = nodesFor(ir); - const lookup = createSemanticCallableOwnerLookup(ir, nodes); - for (const query of [ - range(25, 30), - range(25, 70), - range(0, 80), - range(80, 90), - range(91, 92), - ]) - expect(lookup(query)).toBe(previousOwner(query, ir, nodes)); -}); - -it("matches the prior result across a compiled-style collection of sibling callables", () => { - const ir = analyzeJavaScriptSemantics( - Array.from( - { length: 300 }, - (_, index) => - `function f${String(index)}(value) { return value + ${String(index)}; }`, - ).join(""), - ); - const nodes = nodesFor(ir); - const lookup = createSemanticCallableOwnerLookup(ir, nodes); - for (const range of rangesFor(ir)) - expect(lookup(range)).toBe(previousOwner(range, ir, nodes)); -}); - -it("returns contained nodes in original order, including repeated and overlapping entries", () => { - const ir = analyzeJavaScriptSemantics(` - function outer(value) { function inner() { return value; } return inner(); } - const sibling = () => outer(1); - `); - const nodes = [...nodesFor(ir).values()].reverse(); - const repeated = nodes[0]; - if (repeated === undefined) throw new Error("Missing range fixture node"); - nodes.push( - repeated, - constructSemanticGraphNode( - fixtureFile, - { - kind: "module", - roleKey: "module", - location: null, - label: "module", - functionNodeId: null, - }, - createSemanticGraphProjectionState({ nodes: [] }), - ), - ); - const lookup = createSemanticNodeRangeLookup(nodes); - const compare = ( - left: JavaScriptSourceRange["start"], - right: JavaScriptSourceRange["start"], - ) => left.line - right.line || left.column - right.column; - for (const range of rangesFor(ir)) { - if (range === null) continue; - const expected = nodes.filter( - ({ identity }) => - identity.source_range !== null && - compare(range.start, identity.source_range.start) <= 0 && - compare(range.end, identity.source_range.end) >= 0, - ); - expect(lookup(range)).toEqual(expected); - } - expect( - createSemanticNodeRangeLookup([])( - repeated.identity.source_range ?? { - start: { line: 1, column: 0 }, - end: { line: 1, column: 1 }, - }, - ), - ).toEqual([]); -}); - -it("keeps first-call matching and current retained membership for exact locations", () => { - const original = analyzeJavaScriptSemantics( - "function target(value) { return value; } target(1); target(2);", - ); - const first = original.callSites[0]; - const node = [...nodesFor(original).values()][0]; - if (first === undefined || node === undefined) - throw new Error("Missing call fixture"); - const duplicate = { ...first, callSiteId: `${first.callSiteId}:duplicate` }; - const ir = { - ...original, - callSites: [first, duplicate, ...original.callSites.slice(1)], - }; - const nodes = new Map( - ir.callSites.map((call) => [ - call.callSiteId, - constructSemanticGraphNode( - fixtureFile, - { - kind: "call-site", - roleKey: call.callSiteId, - location: call.location, - label: call.callSiteId, - functionNodeId: null, - }, - createSemanticGraphProjectionState({ nodes: [] }), - ), - ]), - ); - const lookup = createSemanticCallSiteLookup(ir, nodes); - const previous = (range: JavaScriptSourceRange) => { - const call = ir.callSites.find((candidate) => - sourceRangesEqual(candidate.location, range), - ); - return call === undefined ? undefined : nodes.get(call.callSiteId); - }; - for (const call of ir.callSites) - expect(lookup(call.location)).toBe(previous(call.location)); - nodes.delete(first.callSiteId); - expect(lookup(first.location)).toBeUndefined(); - expect(nodes.has(duplicate.callSiteId)).toBe(true); - nodes.set(first.callSiteId, node); - expect(lookup(first.location)).toBe(node); - expect( - lookup({ start: { line: 99, column: 0 }, end: { line: 99, column: 1 } }), - ).toBeUndefined(); -}); diff --git a/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts b/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts index 93bd79ca7..dc3c72d5f 100644 --- a/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts @@ -1,4 +1,8 @@ -import type { JsonValue } from "../../domain/jsonValue.js"; +import { + semanticContainer, + semanticPropertyPointer, + type JavaScriptSemanticSlot, +} from "../../domain/javascript/javascriptSemanticSlots.js"; import type { JavaScriptSemanticBinding, JavaScriptSemanticValue, @@ -24,7 +28,7 @@ export const projectSemanticValues = ( binding, value: binding.value, target: bindingNode, - role: "binding", + path: [], }); } }; @@ -34,11 +38,13 @@ interface ValueProjectionInput { readonly binding: JavaScriptSemanticBinding; readonly value: JavaScriptSemanticValue; readonly target: ReturnType; - readonly role: string; + readonly path: readonly string[]; } const projectValue = (input: ValueProjectionInput): void => { - const { context, binding, value, target, role } = input; + const { context, binding, value, target, path } = input; + const role = + path.length === 0 ? "binding" : `property:${semanticPropertyPointer(path)}`; if (target === null) return; if (value.status === "literal") { const literal = addLiteralNode(context, binding, value.value, role); @@ -58,31 +64,33 @@ const projectValue = (input: ValueProjectionInput): void => { resolution: "candidate", }); } - else if (value.status === "object") - for (const property of value.properties) { + else if (value.status === "object" || value.status === "array") { + const container = semanticContainer(value); + for (const property of container.slots) { + const propertyPath = [...path, property.name]; const slot = semanticPropertySlot( context, binding.bindingId, - property.name, + propertyPath, + property, ); + if (property.presence === "absent") continue; addSemanticGraphRelation(context.state, { source: target, target: slot, relation: "writes-property", - resolution: - value.unknownProperties || property.presence === "unknown-coverage" - ? "candidate" - : "resolved", - }); - projectValue({ - context, - binding, - value: property.value, - target: slot, - role: `property:${property.name}`, + resolution: property.presence === "present" ? "resolved" : "candidate", }); + if (property.presence === "present") + projectValue({ + context, + binding, + value: property.value, + target: slot, + path: propertyPath, + }); } - else if (value.status === "unknown" && value.resourceLimit !== undefined) { + } else if (value.status === "unknown" && value.resourceLimit !== undefined) { const location = binding.definitions[0]?.location ?? null; const evidence = observedSemanticEvidence(context.file, location); const isPropertyValue = role.startsWith("property:"); @@ -123,21 +131,27 @@ const addLiteralNode = ( properties: { value }, }); -/** Create one canonical property slot for a binding/property identity. */ +/** Create one canonical slot for a root binding and exact static property path. */ export const semanticPropertySlot = ( context: SemanticFlowProjectionContext, objectBindingId: string, - name: string, + path: readonly string[], + fact: JavaScriptSemanticSlot, ) => retainSemanticGraphNode(context.state, context.file, { kind: "property-slot", - roleKey: `property:${objectBindingId}:${name}`, + roleKey: `property:${JSON.stringify([objectBindingId, path])}`, location: null, - label: name, + label: fact.name, functionNodeId: context.bindingNodes.get(objectBindingId)?.function_node_id ?? null, properties: { - name, + name: fact.name, + property_path: [...path], + property_pointer: semanticPropertyPointer(path), object_binding_id: objectBindingId, - } satisfies Readonly>, + presence: fact.presence, + value_status: fact.value.status, + value_coverage: semanticContainer(fact.value)?.coverage ?? null, + }, }); diff --git a/src/domain/javascript/javascriptExportShapeComparisonSchemas.test.ts b/src/domain/javascript/javascriptExportShapeComparisonSchemas.test.ts index b8ab01aea..067452b7e 100644 --- a/src/domain/javascript/javascriptExportShapeComparisonSchemas.test.ts +++ b/src/domain/javascript/javascriptExportShapeComparisonSchemas.test.ts @@ -71,6 +71,7 @@ it("parses projected fields into literal, union, or unknown values", () => { projectedExportReturnShapesSchema.safeParse( projectionWithField({ path: "/kind", + presence: "present", state: "unknown", value: "invented", reason: "Dynamic property", @@ -81,6 +82,7 @@ it("parses projected fields into literal, union, or unknown values", () => { projectedExportReturnShapesSchema.safeParse( projectionWithField({ path: "/kind", + presence: "present", state: "union", value: ["success", "failure"], reason: null, @@ -88,3 +90,16 @@ it("parses projected fields into literal, union, or unknown values", () => { ).success, ).toBe(true); }); + +it("rejects return fields that omit slot presence", () => { + expect( + projectedExportReturnShapesSchema.safeParse( + projectionWithField({ + path: "/kind", + state: "literal", + value: "result", + reason: null, + }), + ).success, + ).toBe(false); +}); diff --git a/src/domain/javascript/javascriptExportShapeComparisonSchemas.ts b/src/domain/javascript/javascriptExportShapeComparisonSchemas.ts index 9a1fae67c..9e0671d2b 100644 --- a/src/domain/javascript/javascriptExportShapeComparisonSchemas.ts +++ b/src/domain/javascript/javascriptExportShapeComparisonSchemas.ts @@ -75,7 +75,7 @@ export const compareJavaScriptExportShapesInputSchema = z const projectedReturnFieldShape = { path: jsonPointerSchema, - presence: z.enum(["present", "absent", "unknown-coverage"]).optional(), + presence: z.enum(["present", "absent", "unknown-coverage"]), }; const projectedReturnFieldSchema = z.discriminatedUnion("state", [ z.strictObject({ diff --git a/src/domain/javascript/javascriptExportShapeSelection.ts b/src/domain/javascript/javascriptExportShapeSelection.ts index c20df16bd..6a0ecc120 100644 --- a/src/domain/javascript/javascriptExportShapeSelection.ts +++ b/src/domain/javascript/javascriptExportShapeSelection.ts @@ -10,9 +10,6 @@ import { import { compareCodePoints } from "../canonicalOrdering.js"; type Shape = ProjectedExportReturnShapes["static_return_shapes"][number]; -type Field = Shape["fields"][number]; -type NormalizedField = Field & { presence: NonNullable }; -type NormalizedShape = Omit & { fields: NormalizedField[] }; type SelectorResult = JavaScriptExportShapeComparisonResult["left"]; type SelectorBase = Omit; type ReturnShapeSelection = @@ -58,7 +55,7 @@ export type SelectedJavaScriptExport = /** Complete return-shape inventory retained from an authenticated projection. */ export interface RetainedJavaScriptExportShapes { - readonly shapes: readonly NormalizedShape[]; + readonly shapes: readonly Shape[]; readonly omitted: number; } @@ -129,24 +126,13 @@ export const selectJavaScriptExport = ( }; }; -/** Retain every variant and normalize legacy presence on comparison-owned copies. */ +/** Retain every authenticated variant with its explicit slot presence. */ export const retainJavaScriptExportShapes = ( selection: SelectedJavaScriptExport, -): RetainedJavaScriptExportShapes => { - const shapes = ( - selection.projection?.static_return_shapes ?? [] - ).map((shape) => ({ - ...shape, - fields: shape.fields.map((field) => ({ - ...field, - // Legacy unknown fields did not distinguish values from uncertain slots. - presence: - field.presence ?? - (field.state === "unknown" ? "unknown-coverage" : "present"), - })), - })); - return { shapes, omitted: 0 }; -}; +): RetainedJavaScriptExportShapes => ({ + shapes: selection.projection?.static_return_shapes ?? [], + omitted: 0, +}); const selectorBase = ( side: JavaScriptExportShapeSideInput, diff --git a/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts index ba8351c45..55ce3705a 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts @@ -17,21 +17,21 @@ describe("JavaScript semantic analysis: dataflow 1", () => { `); expect( - ir.objectOperations.map(({ kind, propertyName, resolution }) => ({ + ir.objectOperations.map(({ kind, propertyPath, resolution }) => ({ kind, - propertyName, + propertyPath, resolution, })), ).toEqual( expect.arrayContaining([ { kind: "destructure", - propertyName: "token", + propertyPath: ["token"], resolution: "complete", }, - { kind: "spread", propertyName: null, resolution: "complete" }, - { kind: "write", propertyName: "count", resolution: "complete" }, - { kind: "read", propertyName: "token", resolution: "complete" }, + { kind: "spread", propertyPath: [], resolution: "complete" }, + { kind: "write", propertyPath: ["count"], resolution: "complete" }, + { kind: "read", propertyPath: ["token"], resolution: "complete" }, ]), ); }); @@ -50,11 +50,11 @@ describe("JavaScript semantic analysis: dataflow 1", () => { ({ kind }) => kind === "destructure", ); - expect(destructures.map(({ propertyName }) => propertyName)).toEqual([ - "token", - "token", - "token", - "1", + expect(destructures.map(({ propertyPath }) => propertyPath)).toEqual([ + ["token"], + ["token"], + ["token"], + ["1"], ]); expect( destructures.every(({ resolution }) => resolution === "complete"), @@ -127,10 +127,19 @@ describe("JavaScript semantic analysis: dataflow 1", () => { unknownProperties: false, omittedProperties: 0, properties: expect.arrayContaining([ - { name: "depth", value: { status: "literal", value: 1 } }, - { name: "type", value: { status: "literal", value: "heading" } }, + { + name: "depth", + presence: "present", + value: { status: "literal", value: 1 }, + }, + { + name: "type", + presence: "present", + value: { status: "literal", value: "heading" }, + }, { name: "text", + presence: "present", value: { status: "unknown", reason: "Unsupported CallExpression value.", diff --git a/src/domain/javascript/javascriptSemanticAnalysis.member_depth.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.member_depth.test.ts index 59d153a8f..fa2eafa8e 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.member_depth.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.member_depth.test.ts @@ -62,7 +62,11 @@ it("invalidates a deep mutation while retaining unrelated literal properties", ( expect(onlyBinding(ir, "root").value).toEqual({ status: "object", properties: [ - { name: "untouched", value: { status: "literal", value: "retained" } }, + { + name: "untouched", + presence: "present", + value: { status: "literal", value: "retained" }, + }, { name: "next", presence: "unknown-coverage", diff --git a/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts index 8b040144a..c0d3066a3 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts @@ -93,6 +93,9 @@ const unchangedProperties = [ 'const source = { nested: { token: "TOKEN", count: 1 } }; const { nested } = source; nested.count = 2; return source.nested.token;', 'const child = { token: "TOKEN", count: 1 }; const source = { child }; source.child.count = 2; return child.token;', 'const source = ["TOKEN", 1]; source[1] = 2; return source[0];', + 'const source = ["TOKEN"]; source.extra = 2; return source[0];', + 'const source = ["TOKEN"]; source["01"] = 2; return source[0];', + 'const source = ["TOKEN"]; source[1000000000] = 2; return source[0];', 'const child = { token: "TOKEN", count: 1 }; const source = [child]; source[0].count = 2; return child.token;', 'const source = { token: "TOKEN" }; source.added = 2; return source.token;', 'const source = { token: "TOKEN", count: 1, other: 1 }; source.count = 2; source.other = 2; return source.token;', diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejection.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejection.test.ts index 0df194a05..0c8495767 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.rejection.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejection.test.ts @@ -144,7 +144,11 @@ describe("JavaScript semantic analysis: rejection 1", () => { expect(recovered.returnSites[0]?.value).toMatchObject({ status: "object", properties: expect.arrayContaining([ - { name: "type", value: { status: "literal", value: "item" } }, + { + name: "type", + presence: "present", + value: { status: "literal", value: "item" }, + }, expect.objectContaining({ name: "text", value: expect.objectContaining({ status: "unknown" }), diff --git a/src/domain/javascript/javascriptSemanticIr.ts b/src/domain/javascript/javascriptSemanticIr.ts index 38a55ef41..14ce12d12 100644 --- a/src/domain/javascript/javascriptSemanticIr.ts +++ b/src/domain/javascript/javascriptSemanticIr.ts @@ -380,7 +380,7 @@ export interface JavaScriptSemanticObjectOperation { readonly ownerCallableId: string | null; readonly objectBindingId: string | null; readonly targetBindingId: string | null; - readonly propertyName: string | null; + readonly propertyPath: readonly string[] | null; readonly resolution: "complete" | "partial"; } diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 325e48169..2c6ebe9e8 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -6,10 +6,7 @@ import { } from "./javascriptSemanticState.js"; import { evaluateSemanticBinding } from "./javascriptSemanticValues.js"; import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; -import { - semanticStaticPropertyKey, - semanticStaticPropertyName, -} from "./javascriptAstValues.js"; +import { semanticStaticPropertyKey } from "./javascriptAstValues.js"; type PropertyPath = readonly (string | number | null)[]; @@ -105,13 +102,11 @@ const referencedValues = ( if (t.isSpreadElement(property)) return [{ node: property.argument, path }]; if (!t.isObjectProperty(property)) return []; - const name = semanticStaticPropertyName( + const name = semanticStaticPropertyKey( property.key, property.computed, ); - return key === null || - (name === "" && !t.isStringLiteral(property.key, { value: "" })) || - String(key) === name + return key === null || name === null || String(key) === name ? [{ node: property.value, path: remaining }] : []; }); diff --git a/src/domain/javascript/javascriptSemanticMutationValues.ts b/src/domain/javascript/javascriptSemanticMutationValues.ts index d9946ff3a..46127f614 100644 --- a/src/domain/javascript/javascriptSemanticMutationValues.ts +++ b/src/domain/javascript/javascriptSemanticMutationValues.ts @@ -32,25 +32,26 @@ export const invalidateSemanticMutationPath = ( return { ...value, properties }; } if (value.status === "array") { - const index = typeof key === "number" ? key : Number(key); - if ( - !Number.isSafeInteger(index) || - index < 0 || - String(index) !== String(key) - ) - return unknown; - const items = value.items.map((item, position) => - position === index - ? invalidateSemanticMutationPath(item, remaining) + const name = String(key); + // Array length writes can remove every index; ordinary named properties + // and sparse indices affect only their own slot. + if (name === "length") return unknown; + const observed = value.items.some((item) => item.name === name); + const items = value.items.map((item) => + item.name === name + ? { + ...item, + presence: + remaining.length === 0 + ? ("unknown-coverage" as const) + : item.presence, + value: invalidateSemanticMutationPath(item.value, remaining), + } : item, ); - const itemPresence = { - ...value.itemPresence, - ...(remaining.length === 0 || value.items[index] === undefined - ? { [index]: "unknown-coverage" as const } - : {}), - }; - return { ...value, items, itemPresence }; + if (!observed) + items.push({ name, presence: "unknown-coverage", value: unknown }); + return { ...value, items }; } return unknown; }; diff --git a/src/domain/javascript/javascriptSemanticObjects.ts b/src/domain/javascript/javascriptSemanticObjects.ts index 63a001c47..3a8414776 100644 --- a/src/domain/javascript/javascriptSemanticObjects.ts +++ b/src/domain/javascript/javascriptSemanticObjects.ts @@ -38,20 +38,24 @@ export const collectJavaScriptSemanticObjects = ( t.isSpreadElement(node) && parent !== null && t.isObjectExpression(parent) - ) + ) { + const identity = expressionObjectIdentity(node.argument, state); addObjectOperation( { node, kind: "spread", ownerCallableId, - objectBindingId: expressionBindingId(node.argument, state), + objectBindingId: identity.bindingId, targetBindingId: null, - propertyName: null, + propertyPath: identity.path, }, state, output, ); - else if (t.isVariableDeclarator(node) && t.isObjectPattern(node.id)) + } else if ( + t.isVariableDeclarator(node) && + (t.isObjectPattern(node.id) || t.isArrayPattern(node.id)) + ) collectDestructuring(node, ownerCallableId, state, output); }, exit: (node) => { @@ -75,8 +79,14 @@ const collectMember = ( context: ObjectCollectionContext, ): void => { const { state, output } = context; - const name = semanticStaticPropertyKey(node.property, node.computed); - if (name === null) return; + // A chain is one property access; retaining every prefix duplicates work and + // would make full paths quadratic in deeply nested producer expressions. + if ( + (t.isMemberExpression(parent) || t.isOptionalMemberExpression(parent)) && + parent.object === node + ) + return; + const identity = expressionObjectIdentity(node, state); const write = (t.isAssignmentExpression(parent) && parent.left === node) || (t.isUpdateExpression(parent) && parent.argument === node); @@ -87,9 +97,9 @@ const collectMember = ( node, kind: "read", ownerCallableId, - objectBindingId: expressionBindingId(node.object, state), + objectBindingId: identity.bindingId, targetBindingId: null, - propertyName: name, + propertyPath: identity.path, }, state, output, @@ -99,9 +109,9 @@ const collectMember = ( node, kind: write ? "write" : "read", ownerCallableId, - objectBindingId: expressionBindingId(node.object, state), + objectBindingId: identity.bindingId, targetBindingId: null, - propertyName: name, + propertyPath: identity.path, }, state, output, @@ -114,30 +124,41 @@ const collectDestructuring = ( state: JavaScriptSemanticAnalysisState, output: JavaScriptSemanticObjectOperation[], ): void => { - if (!t.isObjectPattern(node.id)) return; - const objectBindingId = expressionBindingId(node.init, state); - for (const property of node.id.properties) { - if (!t.isObjectProperty(property)) continue; - const name = semanticStaticPropertyKey(property.key, property.computed); - if (name === null) continue; - const target = bindingIdentifier(property.value); + const source = expressionObjectIdentity(node.init, state); + const visit = (pattern: t.Node, path: readonly string[]): void => { + if (t.isObjectPattern(pattern)) { + for (const property of pattern.properties) { + if (!t.isObjectProperty(property)) continue; + const name = semanticStaticPropertyKey(property.key, property.computed); + if (name !== null) visit(property.value, [...path, name]); + } + return; + } + if (t.isArrayPattern(pattern)) { + pattern.elements.forEach((element, index) => { + if (element !== null && !t.isRestElement(element)) + visit(element, [...path, String(index)]); + }); + return; + } + const target = bindingIdentifier(pattern); + if (target === null) return; addObjectOperation( { - node: property, + node: pattern, kind: "destructure", ownerCallableId, - objectBindingId, + objectBindingId: source.bindingId, targetBindingId: - target === null - ? null - : (resolveSemanticBindingState(state, target, target.name) - ?.bindingId ?? null), - propertyName: name, + resolveSemanticBindingState(state, target, target.name)?.bindingId ?? + null, + propertyPath: source.path === null ? null : [...source.path, ...path], }, state, output, ); - } + }; + visit(node.id, []); }; interface AddObjectOperationInput { @@ -146,7 +167,7 @@ interface AddObjectOperationInput { readonly ownerCallableId: string | null; readonly objectBindingId: string | null; readonly targetBindingId: string | null; - readonly propertyName: string | null; + readonly propertyPath: readonly string[] | null; } const addObjectOperation = ( @@ -161,27 +182,57 @@ const addObjectOperation = ( ownerCallableId: input.ownerCallableId, objectBindingId: input.objectBindingId, targetBindingId: input.targetBindingId, - propertyName: input.propertyName, + propertyPath: input.propertyPath, resolution: - input.kind === "spread" - ? input.objectBindingId === null - ? "partial" - : "complete" - : input.objectBindingId === null || - input.propertyName === null || - (input.kind === "destructure" && input.targetBindingId === null) - ? "partial" - : "complete", + input.objectBindingId === null || + input.propertyPath === null || + (input.kind === "destructure" && input.targetBindingId === null) + ? "partial" + : "complete", }); }; -const expressionBindingId = ( +interface ObjectIdentity { + readonly bindingId: string | null; + readonly path: readonly string[] | null; +} + +const expressionObjectIdentity = ( node: t.Node | null | undefined, state: JavaScriptSemanticAnalysisState, -): string | null => - t.isIdentifier(node) - ? (resolveSemanticBindingState(state, node, node.name)?.bindingId ?? null) - : null; +): ObjectIdentity => { + const path: string[] = []; + let current = node; + let staticPath = true; + while (current !== null && current !== undefined) { + if ( + t.isMemberExpression(current) || + t.isOptionalMemberExpression(current) + ) { + const name = semanticStaticPropertyKey( + current.property, + current.computed, + ); + if (name === null) staticPath = false; + else path.push(name); + current = current.object; + } else if ( + t.isTSAsExpression(current) || + t.isTSTypeAssertion(current) || + t.isTSNonNullExpression(current) || + t.isTSSatisfiesExpression(current) + ) + current = current.expression; + else break; + } + return { + bindingId: t.isIdentifier(current) + ? (resolveSemanticBindingState(state, current, current.name)?.bindingId ?? + null) + : null, + path: staticPath ? path.reverse() : null, + }; +}; const bindingIdentifier = (node: t.Node): t.Identifier | null => { if (t.isIdentifier(node)) return node; diff --git a/src/domain/javascript/javascriptSemanticResourceLimits.ts b/src/domain/javascript/javascriptSemanticResourceLimits.ts index 32155e339..5e2ae5a96 100644 --- a/src/domain/javascript/javascriptSemanticResourceLimits.ts +++ b/src/domain/javascript/javascriptSemanticResourceLimits.ts @@ -91,7 +91,7 @@ export const semanticResourceLimitsIn = ( else if (value.status === "object") for (const property of value.properties) pending.push(property.value); else if (value.status === "array") - for (const item of value.items) pending.push(item); + for (const item of value.items) pending.push(item.value); } return [...found].sort(); }; diff --git a/src/domain/javascript/javascriptSemanticReturns.ts b/src/domain/javascript/javascriptSemanticReturns.ts index 74183f357..d9b84a24b 100644 --- a/src/domain/javascript/javascriptSemanticReturns.ts +++ b/src/domain/javascript/javascriptSemanticReturns.ts @@ -1,5 +1,10 @@ import * as t from "@babel/types"; +import { + semanticContainer, + semanticPropertyPointer, +} from "./javascriptSemanticSlots.js"; + import type { JavaScriptSemanticCallable, JavaScriptSemanticModuleLink, @@ -244,54 +249,24 @@ const flattenValue = ( }); return; } - if (value.status === "object") { - coverage.push( - value.unknownProperties - ? { path, status: "partial", omitted: value.omittedProperties } - : { path, status: "complete", omitted: 0 }, - ); - for (const property of value.properties) - flattenValue( - property.value, - `${path}/${escapePointer(property.name)}`, - fields, - coverage, - property.presence ?? "present", - ); + if ("reason" in value) { + fields.push({ + path, + presence, + state: "unknown", + value: null, + reason: value.reason, + }); return; } - if (value.status === "array") { - coverage.push( - value.unknownItems - ? { path, status: "partial", omitted: value.omittedItems } - : { path, status: "complete", omitted: 0 }, + const container = semanticContainer(value); + coverage.push({ path, ...container.coverage }); + for (const slot of container.slots) + flattenValue( + slot.value, + `${path}${semanticPropertyPointer([slot.name])}`, + fields, + coverage, + slot.presence, ); - value.items.forEach((item, index) => - flattenValue( - item, - `${path}/${String(index)}`, - fields, - coverage, - value.itemPresence?.[index] ?? "present", - ), - ); - // Sparse mutation metadata retains uncertain slots without expanding arrays. - for (const [index, itemPresence] of Object.entries( - value.itemPresence ?? {}, - )) { - if (Number(index) < value.items.length) continue; - flattenValue(value, `${path}/${index}`, fields, coverage, itemPresence); - } - return; - } - fields.push({ - path, - presence, - state: "unknown", - value: null, - reason: value.reason, - }); }; - -const escapePointer = (value: string): string => - value.replaceAll("~", "~0").replaceAll("/", "~1"); diff --git a/src/domain/javascript/javascriptSemanticSlots.ts b/src/domain/javascript/javascriptSemanticSlots.ts new file mode 100644 index 000000000..61b61041d --- /dev/null +++ b/src/domain/javascript/javascriptSemanticSlots.ts @@ -0,0 +1,108 @@ +import type { + JavaScriptSemanticProperty, + JavaScriptSemanticValue, +} from "./javascriptSemanticValueTypes.js"; + +/** A container's own slot, using the value lattice's canonical presence model. */ +export type JavaScriptSemanticSlot = JavaScriptSemanticProperty; + +/** Static container inventory; partial siblings do not weaken retained slots. */ +export interface JavaScriptSemanticContainer { + readonly slots: readonly JavaScriptSemanticSlot[]; + readonly coverage: + | { readonly status: "complete"; readonly omitted: 0 } + | { readonly status: "partial"; readonly omitted: number | null }; +} + +/** Read the same slot inventory for object, array, and return projections. */ +export function semanticContainer( + value: Extract< + JavaScriptSemanticValue, + { readonly status: "object" | "array" } + >, +): JavaScriptSemanticContainer; +export function semanticContainer( + value: JavaScriptSemanticValue, +): JavaScriptSemanticContainer | null; +export function semanticContainer( + value: JavaScriptSemanticValue, +): JavaScriptSemanticContainer | null { + if (value.status === "object") + return { + slots: value.properties, + coverage: value.unknownProperties + ? { status: "partial", omitted: value.omittedProperties } + : { status: "complete", omitted: 0 }, + }; + if (value.status === "array") + return { + slots: value.items, + coverage: value.unknownItems + ? { status: "partial", omitted: value.omittedItems } + : { status: "complete", omitted: 0 }, + }; + return null; +} + +/** Resolve a full static own-property path without conflating missing siblings. */ +export const semanticSlotAtPath = ( + value: JavaScriptSemanticValue, + path: readonly string[], +): JavaScriptSemanticSlot => { + const leafName = path.at(-1) ?? ""; + let current: JavaScriptSemanticSlot = { + name: "", + presence: "present", + value, + }; + for (const [index, name] of path.entries()) { + const container = semanticContainer(current.value); + if (current.presence !== "present" || container === null) + return { + name: leafName, + presence: "unknown-coverage", + value: + "reason" in current.value + ? { + ...current.value, + reason: `${current.value.reason} Cannot continue ${semanticPropertyPointer(path)} through receiver ${semanticPropertyPointer(path.slice(0, index))}.`, + } + : { + status: "unknown", + reason: `Cannot resolve ${semanticPropertyPointer(path)} through ${current.value.status} receiver ${semanticPropertyPointer(path.slice(0, index))}.`, + }, + }; + // Array position inventories omit the intrinsic non-enumerable length slot. + // Its own presence is guaranteed even when no exact length is retained. + if (current.value.status === "array" && name === "length") { + current = { + name, + presence: "present", + value: { + status: "unknown", + reason: "Array length is present but its value is not retained.", + }, + }; + continue; + } + const observed = container.slots.find((slot) => slot.name === name); + current = observed ?? { + name, + presence: + container.coverage.status === "complete" + ? "absent" + : "unknown-coverage", + value: { + status: "unknown", + reason: `Own property ${name} was not observed.`, + }, + }; + } + return current; +}; + +/** Encode path segments as an unambiguous JSON Pointer, preserving empty keys. */ +export const semanticPropertyPointer = (path: readonly string[]): string => + path + .map((key) => `/${key.replaceAll("~", "~0").replaceAll("/", "~1")}`) + .join(""); diff --git a/src/domain/javascript/javascriptSemanticValueTypes.ts b/src/domain/javascript/javascriptSemanticValueTypes.ts index 9d4917b64..67d4ee06f 100644 --- a/src/domain/javascript/javascriptSemanticValueTypes.ts +++ b/src/domain/javascript/javascriptSemanticValueTypes.ts @@ -23,10 +23,7 @@ type JavaScriptSemanticObjectValue = { type JavaScriptSemanticArrayValue = { readonly status: "array"; - readonly items: readonly JavaScriptSemanticValue[]; - readonly itemPresence?: Readonly< - Record - >; + readonly items: readonly JavaScriptSemanticProperty[]; } & ( | { readonly unknownItems: false; @@ -56,9 +53,15 @@ export type JavaScriptSemanticValue = readonly resourceLimit?: JavaScriptSemanticResourceLimit; }; -/** One statically named object-literal property. */ +/** One statically named own slot in an object or array. */ export interface JavaScriptSemanticProperty { readonly name: string; readonly value: JavaScriptSemanticValue; - readonly presence?: "unknown-coverage"; + readonly presence: JavaScriptSemanticSlotPresence; } + +/** Own-slot presence, independent of its value and sibling coverage. */ +export type JavaScriptSemanticSlotPresence = + | "present" + | "absent" + | "unknown-coverage"; diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index cf8ca5288..84db35553 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -1,5 +1,7 @@ import * as t from "@babel/types"; +import { semanticSlotAtPath } from "./javascriptSemanticSlots.js"; + import { invalidateSemanticMutationPath } from "./javascriptSemanticMutationValues.js"; import type { @@ -195,9 +197,12 @@ const evaluateTemplate = ( let candidates = [""]; for (let index = 0; index < node.quasis.length; index += 1) { const quasi = node.quasis[index]; - // Semantic template evaluation retains its established raw fallback for - // synthetic AST inputs; exact atom readers remain cooked-only. - const text = quasi?.value.cooked ?? quasi?.value.raw ?? ""; + const text = quasi?.value.cooked; + if (text === undefined || text === null) + return { + status: "unknown", + reason: "Template text has no cooked representation.", + }; if (exceedsSemanticTemplateByteBudget(candidates, [text])) return semanticResourceLimitUnknown("primitive-bytes"); candidates = candidates.map((prefix) => `${prefix}${text}`); @@ -235,6 +240,7 @@ const evaluateObject = ( for (const [name] of propertiesByName) propertiesByName.set(name, { name, + presence: "present", value: { status: "unknown", reason: "A later property may overwrite this value.", @@ -268,6 +274,7 @@ const evaluateObject = ( } propertiesByName.set(name, { name, + presence: "present", value: t.isObjectProperty(property) ? evaluateExpression(property.value, nestedContext(context)) : { @@ -298,8 +305,7 @@ const evaluateArray = ( node: t.ArrayExpression, context: EvaluationContext, ): JavaScriptSemanticValue => { - const items: JavaScriptSemanticValue[] = []; - const itemPresence: Record = {}; + const items: JavaScriptSemanticProperty[] = []; let unknownItems = false; let omittedItems: number | null = 0; for (const element of node.elements) { @@ -310,21 +316,24 @@ const evaluateArray = ( break; } if (element === null) { - itemPresence[items.length] = "absent"; items.push({ - status: "unknown", - reason: "Array hole has no primitive value.", + name: String(items.length), + presence: "absent", + value: { status: "unknown", reason: "Array hole has no own value." }, }); continue; } - items.push(evaluateExpression(element, nestedContext(context))); + items.push({ + name: String(items.length), + presence: "present", + value: evaluateExpression(element, nestedContext(context)), + }); } return unknownItems - ? { status: "array", items, itemPresence, unknownItems: true, omittedItems } + ? { status: "array", items, unknownItems: true, omittedItems } : { status: "array", items, - itemPresence, unknownItems: false, omittedItems: 0, }; @@ -436,46 +445,16 @@ const projectValue = ( value: JavaScriptSemanticValue, projection: readonly (string | number | null)[], ): JavaScriptSemanticValue => { - let current = value; - for (const key of projection) { - if (key === null) - return { + if (projection.includes(null)) + return { status: "unknown", reason: "Cannot project a dynamic property." }; + const path = projection.map((key) => String(key)); + const slot = semanticSlotAtPath(value, path); + return slot.presence === "present" + ? slot.value + : { status: "unknown", - reason: "Cannot project a dynamic property.", + reason: `Own property ${path.join("/")} is ${slot.presence}.`, }; - if (current.status === "object" && typeof key === "string") { - const property = current.properties.find(({ name }) => name === key); - if (property === undefined) - return { - status: current.unknownProperties ? "unknown" : "unknown", - reason: `Object property ${key} was not observed.`, - }; - current = property.value; - } else if (current.status === "array") { - const index = typeof key === "number" ? key : Number(key); - if ( - !Number.isSafeInteger(index) || - index < 0 || - String(index) !== String(key) - ) - return { - status: "unknown", - reason: `Array property ${String(key)} is not a canonical index.`, - }; - const item = current.items[index]; - if (item === undefined) - return { - status: "unknown", - reason: `Array item ${String(key)} missing.`, - }; - current = item; - } else - return { - status: "unknown", - reason: `Cannot project ${String(key)} from ${current.status}.`, - }; - } - return current; }; const provenanceForBinding = ( diff --git a/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts b/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts index 5b1b260e7..5be94b62d 100644 --- a/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts +++ b/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts @@ -463,7 +463,7 @@ describe("JavaScript export localized mutation uncertainty", () => { }); describe("JavaScript export property presence boundaries", () => { - it("normalizes omitted legacy presence before comparing identical literals and unions", async () => { + it("rejects return-shape observations that omit required slot presence", async () => { const source = 'export default () => ({ kind: "result", count: flag ? 1 : 2, nested: { enabled: true } });'; const [left, right] = await analyzeSources({ left: source, right: source }); @@ -513,9 +513,14 @@ describe("JavaScript export property presence boundaries", () => { [right, { ...left, graph }], ] as const) { const result = compare(sides[0], sides[1]); - expect(result.coverage.paired_variants).toBe(1); - expect(result.changes).toEqual([]); - expect(result.coverage.status).toBe("complete-within-inputs"); + expect(result.coverage.paired_variants).toBe(0); + expect([result.left.status, result.right.status]).toContain( + "unavailable", + ); + expect(result.changes).toEqual([ + expect.objectContaining({ status: "unknown", path: "" }), + ]); + expect(result.coverage.status).toBe("partial"); } }); diff --git a/tests/boundary/mcp/applicationExportShapeMcp.test.ts b/tests/boundary/mcp/applicationExportShapeMcp.test.ts index 17a0dc22d..a35b92abc 100644 --- a/tests/boundary/mcp/applicationExportShapeMcp.test.ts +++ b/tests/boundary/mcp/applicationExportShapeMcp.test.ts @@ -41,7 +41,7 @@ it("executes the advertised export presence example with matching analyzed Evide }); expect(response.isError).not.toBe(true); expect(response.structuredContent).toMatchObject({ - result: { + normalized_result: { left: { status: "selected" }, right: { status: "selected" }, summary: { added: 1, removed: 1, changed: 0, unknown: 0 }, @@ -102,7 +102,7 @@ it("traces example semantic modules with their parsed artifact digests", async ( }); expect(response.isError).not.toBe(true); expect(response.structuredContent).toMatchObject({ - result: { + normalized_result: { nodes: expect.arrayContaining([ expect.objectContaining({ node_id: module.node_id, @@ -161,10 +161,8 @@ describe("application workflow MCP parity", () => { expect(relativeApplication.isError).not.toBe(true); expect(relativeApplication.structuredContent).toMatchObject({ evidence_id: left.value.evidence_id, - result: { input_path: leftAnalysis.input_path }, - evidence: { - subject: { local_path: left.value.subject?.local_path }, - }, + normalized_result: { input_path: leftAnalysis.input_path }, + subject: { local_path: left.value.subject?.local_path }, }); const full = await client.callTool({ name: "compare_javascript_export_shapes", @@ -172,7 +170,7 @@ describe("application workflow MCP parity", () => { }); expect(full.isError).not.toBe(true); expect(full.structuredContent).toMatchObject({ - result: { + normalized_result: { summary: { added: 1, removed: 0, changed: 0, unknown: 0 }, property_inventories: expect.arrayContaining([ expect.objectContaining({ @@ -231,7 +229,7 @@ describe("application workflow MCP parity", () => { }); expect(semantic.isError).not.toBe(true); expect(semantic.structuredContent).toMatchObject({ - result: { + normalized_result: { source_evidence_id: left.value.evidence_id, source_graph_id: analyzed.semantic_graph.graph_id, summary: { total_seed_matches: 1 },