From 4bcea1aa76e3e7ca665197181739c5e27a62c40a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 10 Oct 2026 06:20:10 +0800 Subject: [PATCH] fix(javascript): retain array identity after length mutations Keep unknown element coverage on arrays whose length may have changed, preserving intrinsic length presence and subsequent slot mutations through semantic graph and export-return consumers. --- .../JavaScriptSemanticGraphBuilder.test.ts | 67 ++++++++++--------- .../javascriptSemanticMutationValues.ts | 8 ++- .../applicationMutationPresenceMcp.test.ts | 22 ++++++ 3 files changed, 65 insertions(+), 32 deletions(-) diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts index 7794debf94..216aa309cd 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts @@ -761,37 +761,42 @@ it.each([ }, ); -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(["", "root.length = 0;", "root.length = query(); root[0] = 9;"])( + "retains array length presence without inventing an exact length after %s", + (mutation) => { + const graph = graphFor( + `const root = [, 1]; ${mutation} 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([ ["object spread", 'const root = { known: "value", ...dynamic };'], diff --git a/src/domain/javascript/javascriptSemanticMutationValues.ts b/src/domain/javascript/javascriptSemanticMutationValues.ts index 6036bbd2b0..70a25a33c4 100644 --- a/src/domain/javascript/javascriptSemanticMutationValues.ts +++ b/src/domain/javascript/javascriptSemanticMutationValues.ts @@ -35,7 +35,13 @@ export const invalidateSemanticMutationPath = ( 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; + if (name === "length") + return { + status: "array", + items: [], + unknownItems: true, + omittedItems: null, + }; const observed = value.items.some((item) => item.name === name); const items = value.items.map((item) => item.name === name diff --git a/tests/boundary/mcp/applicationMutationPresenceMcp.test.ts b/tests/boundary/mcp/applicationMutationPresenceMcp.test.ts index 0d4dc57ac8..bece1f9bde 100644 --- a/tests/boundary/mcp/applicationMutationPresenceMcp.test.ts +++ b/tests/boundary/mcp/applicationMutationPresenceMcp.test.ts @@ -232,6 +232,24 @@ it.each([ change: "unknown", baseline: 'const result = {kind:"r",count:2};', }, + { + title: "array length mutation", + body: 'const items = [1]; items.length = query(); const result = {kind:"r", length: items.length, items};', + path: "/length", + presence: "present", + valueState: "unknown", + change: "unknown", + baseline: 'const result = {kind:"r",length:1,items:[2]};', + }, + { + title: "array length mutation followed by another write", + body: 'const items = [1]; items.length = 0; items[0] = 9; const result = {kind:"r", length: items.length, items};', + path: "/length", + presence: "present", + valueState: "unknown", + change: "unknown", + baseline: 'const result = {kind:"r",length:1,items:[2]};', + }, { title: "an array hole", body: 'const result = {kind:"r",items:[,]};', @@ -270,6 +288,10 @@ it.each([ expect(projection.static_return_shapes[0]?.fields).toContainEqual( expect.objectContaining({ path, presence, state: valueState }), ); + if (path === "/length") + expect( + projection.static_return_shapes[0]?.property_coverage, + ).toContainEqual({ path: "/items", status: "partial", omitted: null }); const { client, close } = await createApplicationMcpHarness(); onTestFinished(close); const traced = await client.callTool({