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.
This commit is contained in:
morluto committed 2026-10-09 20:18:52 +08:00
1 parent 63bf91405c
commit 475a0f84da
23 files changed
+831 -560

No files matched your search

@@ -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([]);
},
);