diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index e7ae76acbb..cb3e1e5e9c 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -69,13 +69,12 @@ validation boundaries, and built-in resource acquisition/release. Mutation tracking covers explicit member assignments, updates, deletion, and loop assignment targets, including the supported local aliases and shared -nested references. Calls such as `Object.assign`, `Reflect.set`, and -`Reflect.deleteProperty`, and writes reaching a caller's object through another -function's parameter, are not tracked by this mutation pass. These channels -can leave an initializer-derived literal in the graph even after the runtime -value changes. Treat such a result as an uncovered mutation channel, not as -proof of the current runtime value; a follow-up needs to model that channel -and verify its caller/alias behavior. +nested references. Calls conservatively invalidate exposed mutable references; +callee bodies and built-ins such as `Object.assign`, `Reflect.set`, and +`Reflect.deleteProperty` are not interpreted to establish exact post-call +values. This uncertainty does not prove that a call actually mutated a value. +Interprocedural aliasing beyond the supported call-site references remains +outside this mutation pass. Function fingerprints commit normalized syntax, control-flow shape, relation shape, literal sets, arity, and detected effects without using local names or @@ -146,6 +145,15 @@ Direct return expressions, including expression-bodied arrows, are evaluated through an execution-free value lattice. Literal object fields and direct return sites are represented in the result. Calls, dynamic spreads, computed keys, and parser recovery remain partial or unknown. +Objects and arrays passed to calls, constructors, or tagged templates, or used +as method receivers, are not assumed unchanged after the invocation. Aliases +and shared children in spread and rest copies retain that uncertainty; copied +primitive slots and unrelated containing properties remain known. Object rest +excludes consumed keys, array rest excludes the consumed prefix for known +arrays, and later explicit data properties and methods replace earlier object +spread references. A known array prefix does not expose values from a following +spread. Accessors, custom or uncertain iteration, unknown computed keys, and +unknown spread lengths retain conservative uncertainty. Nested callable returns are not assigned to their parent callable. Projected graph observations carry source ranges but never source text. @@ -171,6 +179,8 @@ value alone does not make an observed property uncertain. `summary.added` and `summary.removed` count presence-level add/remove as well as literal value add/remove; `summary.unknown` does not absorb complete-coverage presence-only gaps. +A partial comparison still records an unknown in the MCP session when matching +uncertain projections produce no changes and `summary.unknown` is zero. The output includes exact selector candidates, omissions, Evidence links, coverage, and limitations; it does not execute JavaScript. When runtime diff --git a/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts index ac9d0de8ac..f28436eb1d 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts @@ -164,7 +164,378 @@ describe("JavaScript semantic values after explicit property mutations", () => { }); }); +describe("JavaScript semantic values after mutable references escape", () => { + it("preserves copied scalar slots while invalidating shared children of an escaped object spread", () => { + expect( + resultValue(` + const source = { kind: "record", child: { value: 1 } }; + const copy = { ...source }; + mutate(copy); + return source; + `), + ).toMatchObject({ + status: "object", + properties: [ + { name: "child", value: { status: "unknown" }, presence: "present" }, + { name: "kind", value: { status: "literal", value: "record" } }, + ], + }); + }); + + it("invalidates shared array children without invalidating copied primitives", () => { + expect( + resultValue(` + const child = { value: 1 }; + const source = [child, "TOKEN"]; + const copy = [...source]; + mutate(copy); + return { token: source[1], value: child.value }; + `), + ).toMatchObject({ + status: "object", + properties: [ + { name: "token", value: { status: "literal", value: "TOKEN" } }, + { name: "value", value: { status: "unknown" } }, + ], + }); + }); + + it("keeps primitive spread arguments known", () => { + expect( + resultValue( + 'const values = ["TOKEN"]; consume(...values); return values[0];', + ), + ).toEqual({ status: "literal", value: "TOKEN" }); + }); + + it("does not treat a member constructor as a call with its container as receiver", () => { + expect( + resultValue(` + const namespace = { token: "TOKEN", Factory: class {} }; + new namespace.Factory(); + return namespace.token; + `), + ).toEqual({ status: "literal", value: "TOKEN" }); + }); + + it("does not reuse an initializer projection cached before call mutations were collected", () => { + expect( + resultValue(` + const source = { value: 1 }; + function observe() { consume(copy); } + mutate(source); + const copy = source.value; + return copy; + `)?.status, + ).toBe("unknown"); + }); + + it.each([ + "mutate(source as unknown);", + "mutate((0, source));", + "let alias; mutate(alias = source);", + "mutate(flag ? source : {});", + "let alias = source; mutate(alias ||= {});", + "let alias = source; mutate(alias ??= {});", + ])("follows a reference through %s", (invocation) => { + expect( + resultValue(` + const source = { value: 1 }; + ${invocation} + return source.value; + `)?.status, + ).toBe("unknown"); + }); +}); + +describe("JavaScript escaped references through async and destructuring syntax", () => { + it("follows an awaited object argument", () => { + const ir = analyzeJavaScriptSemantics(`export async function result() { + const source = { value: 1 }; + mutate(await source); + return source.value; + }`); + expect(onlyCallable(ir, "result").returnSites[0]?.value.status).toBe( + "unknown", + ); + }); + + it.each([ + "const { ...copy } = source; mutate(copy);", + "const { ...copy } = source; mutate(copy.child);", + "const [ ...copy ] = [source.child]; mutate(copy);", + "const [ first, ...copy ] = [null, source.child]; mutate(copy[0]);", + "const [ ...[copy] ] = [source.child]; mutate(copy);", + "const [ ...[copy = source.child] ] = []; mutate(copy);", + "const { value = source.child } = {}; mutate(value);", + "const [ value = source.child ] = []; mutate(value);", + ])("follows shared references through %s", (invocation) => { + expect( + resultValue(` + const source = { kind: "record", child: { value: 1 } }; + ${invocation} + return source; + `), + ).toMatchObject({ + status: "object", + properties: [ + { name: "child", value: { status: "unknown" }, presence: "present" }, + { name: "kind", value: { status: "literal", value: "record" } }, + ], + }); + }); + + it("keeps an unused default reference unchanged when destructuring a defined value", () => { + expect( + resultValue(` + const source = { value: "TOKEN" }; + const { value = source } = { value: 1 }; + mutate(value); + return source.value; + `), + ).toEqual({ status: "literal", value: "TOKEN" }); + }); + + it("keeps an unused nested default reference unchanged", () => { + expect( + resultValue(` + const unused = { value: "TOKEN" }; + const { box: { value = unused } = { value: 1 } } = {}; + mutate(value); + return unused.value; + `), + ).toEqual({ status: "literal", value: "TOKEN" }); + }); + + it("reconsiders a destructuring fallback after later collection finds an escape", () => { + expect( + resultValue(` + const fallback = { value: 1 }; + const source = { child: { value: 2 } }; + function observe() { mutate(copy); } + remove(source); + const { child: copy = fallback } = source; + mutate(copy); + return fallback.value; + `)?.status, + ).toBe("unknown"); + }); + + it("follows a fallback write when the original projection appeared primitive", () => { + expect( + resultValue(` + const fallback = { value: 1 }; + const source = { child: 1 }; + function observe() { copy.value = 2; } + remove(source); + const { child: copy = fallback } = source; + observe(); + return fallback.value; + `)?.status, + ).toBe("unknown"); + }); + + it("keeps fresh rest-copy slot writes from changing the original", () => { + expect( + resultValue(` + const source = { child: { value: "TOKEN" } }; + const { ...copy } = source; + copy.child = {}; + return source.child.value; + `), + ).toEqual({ status: "literal", value: "TOKEN" }); + }); + + it("follows writes to shared children of rest copies", () => { + expect( + resultValue(` + const source = { kind: "record", child: { value: 1 } }; + const { ...copy } = source; + copy.child.value = 2; + return { kind: source.kind, value: source.child.value }; + `), + ).toMatchObject({ + status: "object", + properties: [ + { name: "kind", value: { status: "literal", value: "record" } }, + { name: "value", value: { status: "unknown" } }, + ], + }); + }); + + it.each([ + "const source = { skipped: {}, child: { value: 1 } }; const { skipped, ...copy } = source; mutate(copy); return source.child.value;", + "const source = [{}, { value: 1 }]; const [head, ...copy] = source; mutate(copy[0]); return source[1].value;", + "const source = [{}, { value: 1 }]; const [head, ...copy] = source; copy[0].value = 2; return source[1].value;", + "const source = { child: { value: 1 } }; const copy = { child: {}, ...source }; mutate(copy.child); return source.child.value;", + "const source = { child: { value: 1 } }; const copy = { ...source, [key]: {} }; mutate(copy.child); return source.child.value;", + "const source = [{ value: 1 }]; const copy = [{}, ...source]; mutate(copy[1]); return source[0].value;", + "const source = [{ value: 1 }]; const copy = [...other, ...source]; mutate(copy[0]); return source[0].value;", + "const source = [{ value: 1 }]; mutate([... [...source]]); return source[0].value;", + "const child = { value: 1 }; const source = { child, *[Symbol.iterator]() { yield this.child; } }; const copy = [{}, ...source]; mutate(copy[1]); return child.value;", + "const child = { value: 1 }; const source = { child, *[Symbol.iterator]() { yield null; yield this.child; } }; const [head, ...copy] = source; mutate(copy); return child.value;", + "const child = { value: 1 }; const source = [child]; source[Symbol.iterator] = function* () { yield null; yield this[0]; }; const [head, ...copy] = source; mutate(copy); return child.value;", + "const child = { value: 1 }; const source = [null, child]; source.__proto__ = { *[Symbol.iterator]() { yield this[1]; } }; const copy = [{}, ...source]; mutate(copy[1]); return child.value;", + "const source = [{ value: 1 }, {}]; Array.prototype[Symbol.iterator] = function* () { yield null; yield this[0]; }; const [head, ...copy] = source; mutate(copy[0]); return source[0].value;", + "const source = [null, { value: 1 }]; const prototype = Array.prototype; prototype[Symbol.iterator] = function* () { yield this[1]; }; const copy = [{}, ...source]; mutate(copy[1]); return source[1].value;", + "const source = [null, { value: 1 }]; const other = []; other.__proto__[Symbol.iterator] = function* () { yield this[1]; }; const copy = [{}, ...source]; mutate(copy[1]); return source[1].value;", + "const source = { child: { value: 1 } }; const copy = { ...source, get child() { return source.child; } }; mutate(copy.child); return source.child.value;", + "const source = { child: { value: 1 } }; const copy = { ...source, get child() { return source.child; }, set child(value) {} }; mutate(copy.child); return source.child.value;", + ])("preserves uncertainty for retained shared children: %s", (body) => { + expect(resultValue(body)?.status).toBe("unknown"); + }); + + it("finishes repeated alias branches without expanding identical escape paths", () => { + const declarations = Array.from({ length: 24 }, (_, index) => { + const name = `alias${String(index + 1)}`; + const previous = `alias${String(index)}`; + return `let ${name} = ${previous}; ${name} = ${previous};`; + }).join("\n"); + expect( + resultValue(` + let alias0 = { value: 1 }; + ${declarations} + mutate(alias24); + return alias0.value; + `)?.status, + ).toBe("unknown"); + }); +}); + +describe("JavaScript shared array iteration", () => { + it.each([ + "mutate(...source);", + "mutate?.(...source);", + "new Factory(...source);", + "const copy = [...source]; mutate(copy);", + "const copy = [{}, ...source]; mutate(copy[1]);", + "const copy = [...source]; copy[0].value = 2;", + "const [...copy] = source; mutate(copy);", + "const [...copy] = source; copy[0].value = 2;", + "const wrapper = { source }; const { source: [...copy] } = wrapper; mutate(copy);", + "const [copy] = source; mutate(copy);", + "let copy; [copy] = source; mutate(copy);", + "const { nested: [copy] } = { nested: source }; mutate(copy);", + "for (const copy of source) mutate(copy);", + "let copy; for (copy of source) mutate(copy);", + ])("invalidates an iterable that yields itself through %s", (effect) => { + expect( + resultValue(` + const source = { *[Symbol.iterator]() { yield this; }, value: "TOKEN" }; + ${effect} + return source.value; + `)?.status, + ).toBe("unknown"); + }); + + it.each([ + "const [copy] = source;", + "const copy = [...source];", + "for (const copy of source) {}", + "const { nested: [copy] } = { nested: source };", + "const [...[[copy]]] = [source];", + "const [...{ 0: [copy] }] = [source];", + "for (const [copy] of [source]) {}", + "const { missing: [copy] = source } = {};", + "function consume([copy] = source) {} consume();", + "function* copies() { yield* source; } consume(copies());", + ])("accounts for custom iterator receiver effects in %s", (iteration) => { + expect( + resultValue(` + const source = { *[Symbol.iterator]() { this.value = "changed"; yield this; }, value: "TOKEN" }; + ${iteration} + return source.value; + `)?.status, + ).toBe("unknown"); + }); + + it.each([ + "const [copy] = source;", + "const copy = [...source];", + "for (const copy of source) {}", + "const { nested: [copy] } = { nested: source };", + "const [...[[copy]]] = [source];", + "const [...{ 0: [copy] }] = [source];", + "for (const [copy] of [source]) {}", + ])("preserves ordinary array values after %s", (iteration) => { + expect( + resultValue(`const source = ["TOKEN"]; ${iteration} return source[0];`), + ).toEqual({ status: "literal", value: "TOKEN" }); + }); + + it.each([ + "const { nested: [copy] = source } = { nested: [] };", + "const [[copy] = source] = [[]];", + "const { a: { b: [copy] = source } = { b: [] } } = {};", + "for (const { nested: [copy] = source } of [{ nested: [] }]) {}", + "for (const [[copy] = source] of [[[]]]) {}", + "for (const { nested: [copy] = source } of []) {}", + ])("does not execute unused iterator defaults in %s", (iteration) => { + expect( + resultValue(` + const source = { *[Symbol.iterator]() { yield this; }, value: "TOKEN" }; + ${iteration} + return source.value; + `), + ).toEqual({ status: "literal", value: "TOKEN" }); + }); + + it.each([ + "source[Symbol.iterator] = function* () { yield this; };", + "Array.prototype[Symbol.iterator] = function* () { yield this; };", + ])("invalidates a custom array iterator's root after %s", (override) => { + expect( + resultValue(` + const source = ["TOKEN"]; + ${override} + mutate(...source); + return source[0]; + `)?.status, + ).toBe("unknown"); + }); + + it.each(["globalThis", "global", "window", "self"])( + "retains uncertainty after %s.Array.prototype changes", + (globalName) => { + expect( + resultValue(` + const source = [null, { value: 1 }]; + ${globalName}.Array.prototype[Symbol.iterator] = function* () { yield this[1]; }; + const copy = [{}, ...source]; + mutate(copy[1]); + return source[1].value; + `)?.status, + ).toBe("unknown"); + }, + ); + + it("respects a shadowed global object when checking iteration", () => { + expect( + resultValue(` + const globalThis = { Array: { prototype: {} } }; + globalThis.Array.prototype[Symbol.iterator] = function* () { yield null; }; + const source = [{ value: "TOKEN" }]; + const [head, ...copy] = source; + mutate(copy); + return source[0].value; + `), + ).toEqual({ status: "literal", value: "TOKEN" }); + }); +}); + const unchangedProperties = [ + 'const source = { only: { value: "TOKEN" } }; const { only, ...copy } = source; mutate(copy); return source.only.value;', + 'const source = [{ value: "TOKEN" }]; const [head, ...copy] = source; mutate(copy); return source[0].value;', + 'const source = { child: { value: "TOKEN" } }; const copy = { ...source, child: { value: 2 } }; mutate(copy.child); return source.child.value;', + 'const source = [{ value: "TOKEN" }]; const copy = [{ value: 0 }, ...source]; mutate(copy[0]); return source[0].value;', + 'const source = { child: { value: "TOKEN" } }; const copy = { ...source, ["child"]: { value: 2 } }; mutate(copy); return source.child.value;', + 'const child = { value: "TOKEN" }; const copy = { child, child: {} }; mutate(copy); return child.value;', + 'const source = [{ value: "TOKEN" }, { value: 2 }]; const [head, ...copy] = source; mutate(copy[0]); return source[0].value;', + 'const source = [{ value: "TOKEN" }, { value: 2 }]; const copy = [{}, ...source]; mutate(copy[2]); return source[0].value;', + 'const source = [{ value: "TOKEN" }]; const copy = [, ...source]; mutate(copy[0]); return source[0].value;', + 'const source = [{ value: "TOKEN" }]; const copy = [...source]; mutate(copy["01"]); return source[0].value;', + 'const source = { only: { value: "TOKEN" } }; const { ["only"]: consumed, ...copy } = source; mutate(copy); return source.only.value;', + 'const source = { child: { value: "TOKEN" } }; const copy = { ...source, child() {} }; mutate(copy); return source.child.value;', 'const source = { token: "TOKEN", count: 1 }; source.count = 2; return source.token;', 'const source = { token: "TOKEN", count: 1 }; const alias = source; alias.count++; return source.token;', 'const source = { token: "TOKEN", count: 1 }; delete source.count; return source.token;', diff --git a/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts new file mode 100644 index 0000000000..01a9566ded --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js"; + +const resultValue = (body: string) => + onlyCallable( + analyzeJavaScriptSemantics(`export function result() { ${body} }`), + "result", + ).returnSites[0]?.value; + +describe("reference lifetimes across calls and shallow copies", () => { + it("bounds dynamic destructuring across shared alias branches", () => { + const declarations = Array.from( + { length: 9 }, + (_, index) => + `const alias${index + 1} = { left: alias${index}, right: alias${index} };`, + ).join("\n"); + const pattern = "{ [key]: ".repeat(9) + "[copy]" + " }".repeat(9); + const start = performance.now(); + resultValue( + `const alias0 = [1]; ${declarations} const ${pattern} = alias9; return alias0[0];`, + ); + expect(performance.now() - start).toBeLessThan(2000); + }, 30000); + + it("bounds conditional shallow-copy traversal while retaining shared children", () => { + const declarations = Array.from( + { length: 20 }, + (_, i) => + `const alias${i + 1} = flag ? {...alias${i}, a${i}:{}} : {...alias${i}, b${i}:{}};`, + ).join("\n"); + const start = performance.now(); + expect( + resultValue( + `const alias0 = { value: { token: 1 } }; ${declarations} consume(alias20); return alias0.value.token;`, + )?.status, + ).toBe("unknown"); + expect(performance.now() - start).toBeLessThan(2000); + }, 30000); + + it.each([ + ["child: {}", "child: {}", "literal"], + ["child: {}", "other: {}", "unknown"], + ])( + "retains exclusions common to both branches: %s / %s", + (left, right, status) => { + expect( + resultValue( + `const source = {child:{value:1}}; const copy = flag ? {...source, ${left}} : {...source, ${right}}; consume(copy); return source.child.value;`, + )?.status, + ).toBe(status); + }, + ); + + it.each([ + "mutate(alias)", + "alias.method()", + "new Factory(alias)", + "tag`${alias}`", + "alias.tag``", + "mutate((0, alias))", + ])("drops replaced call origins for %s", (call) => { + expect( + resultValue( + `const source={value:1}; let alias=source; alias={}; ${call}; return source.value;`, + ), + ).toEqual({ status: "literal", value: 1 }); + }); + + it.each([ + "mutate(alias); alias={};", + "if(flag) alias={}; mutate(alias);", + "alias={}; if(flag) mutate(alias);", + "function invoke(){mutate(alias);} alias={}; invoke();", + "alias={}; mutate(alias); alias=source;", + ])("retains uncertain call ordering: %s", (body) => { + expect( + resultValue( + `const source={value:1}; let alias=source; ${body} return source.value;`, + )?.status, + ).toBe("unknown"); + }); + + it.each([ + ["let {...copy}={child};", "copy={child:{}};", "copy.child.value=2;"], + ["let [...copy]=[child];", "copy=[{}];", "copy[0].value=2;"], + ["let {copy=child}={};", "copy={};", "copy.value=2;"], + ["let [copy=child]=[];", "copy={};", "mutate(copy);"], + ])( + "drops replaced rest/default origins: %s", + (declaration, rebind, mutation) => { + expect( + resultValue( + `const child={value:1}; ${declaration} ${rebind} ${mutation} return child.value;`, + ), + ).toEqual({ status: "literal", value: 1 }); + expect( + resultValue( + `const child={value:1}; ${declaration} if(flag) ${rebind} ${mutation} return child.value;`, + )?.status, + ).toBe("unknown"); + }, + ); +}); + +describe("primitive snapshots across calls", () => { + it.each([ + "const [copy] = source;", + "const copy = [...source];", + "for (const copy of source) {}", + ])("preserves snapshots taken before custom iteration: %s", (iteration) => { + expect( + resultValue(` + const source = { *[Symbol.iterator]() { yield this; }, value: 1 }; + const snapshot = source.value; + ${iteration} + return snapshot; + `), + ).toEqual({ status: "literal", value: 1 }); + }); + + it.each([ + "const [copy] = alias;", + "const copy = [...alias];", + "for (const copy of alias) {}", + ])("ignores iterable aliases replaced before %s", (iteration) => { + expect( + resultValue(` + const source = { *[Symbol.iterator]() { yield this; }, value: 1 }; + let alias = source; + alias = []; + ${iteration} + return source.value; + `), + ).toEqual({ status: "literal", value: 1 }); + }); + + it.each([ + ["const snapshot=source.value;", "snapshot"], + ["const {value:snapshot}=source;", "snapshot"], + ["const copy={value:source.value};", "copy.value"], + ['const copy={["snapshot"]:source.value};', "copy.snapshot"], + ['const copy={[""]:source.value};', 'copy[""]'], + ["const copy={[0]:source.value};", "copy[0]"], + ['const copy={[("snapshot" as string)]:source.value};', "copy.snapshot"], + ["const copy=[source.value];", "copy[0]"], + ])("preserves primitive capture before escape: %s", (capture, result) => { + expect( + resultValue( + `const source={value:1}; ${capture} mutate(source); return ${result};`, + ), + ).toEqual({ status: "literal", value: 1 }); + expect( + resultValue( + `const source={value:1}; mutate(source); ${capture} return ${result};`, + )?.status, + ).toBe("unknown"); + }); + + it.each([ + ["const copy={value:source.value};", "copy.value"], + ["const copy=[source.value];", "copy[0]"], + ])( + "retains a literal field's earlier primitive capture: %s", + (copy, read) => { + expect( + resultValue( + `const source={value:1}; ${copy} mutate(source); const snapshot=${read}; mutate(copy); return snapshot;`, + ), + ).toEqual({ status: "literal", value: 1 }); + expect( + resultValue( + `const source={value:1}; mutate(source); ${copy} const snapshot=${read}; mutate(copy); return snapshot;`, + )?.status, + ).toBe("unknown"); + }, + ); + + it.each([ + ["const copy={child:source.child};", "copy.child"], + ["const copy=[source.child];", "copy[0]"], + ])( + "keeps a shared child uncertain across both capture points: %s", + (copy, read) => { + expect( + resultValue( + `const source={child:{value:1}}; ${copy} mutate(source); const snapshot=${read}; mutate(copy); return snapshot.value;`, + )?.status, + ).toBe("unknown"); + }, + ); + + it.each([ + "const alias=source; const snapshot=alias.value; mutate(alias); return snapshot;", + "const alias=source.child; const snapshot=alias.value; mutate(alias); return snapshot;", + ])("captures through an earlier intermediate reference: %s", (body) => { + expect( + resultValue(`const source={value:1,child:{value:1}}; ${body}`), + ).toEqual({ status: "literal", value: 1 }); + }); + + it("does not read a later alias initializer at an earlier capture point", () => { + expect( + resultValue( + "const source={value:1}; var alias; const snapshot=alias?.value; mutate(source); alias=source; return snapshot;", + )?.status, + ).toBe("unknown"); + }); + + it("does not treat a persistent child as freshly allocated with its wrapper", () => { + const ir = analyzeJavaScriptSemantics( + "const child={value:1}; export function result(){const source={child}; const alias=source.child; const snapshot=alias.value; mutate(alias); return snapshot;}", + ); + expect(onlyCallable(ir, "result").returnSites[0]?.value.status).toBe( + "unknown", + ); + }); + + it("keeps persistent sources uncertain across function activations", () => { + const ir = analyzeJavaScriptSemantics( + "const source={value:1}; export function result(){const snapshot=source.value; mutate(source); return snapshot;}", + ); + expect(onlyCallable(ir, "result").returnSites[0]?.value.status).toBe( + "unknown", + ); + }); + + it("keeps reads from earlier loop iterations uncertain", () => { + expect( + resultValue( + "const source={value:1}; for(let i=0;i<2;i++){const snapshot=source.value; mutate(source); if(i===1)return snapshot;}", + )?.status, + ).toBe("unknown"); + }); + + it.each([ + "const copy={child:source.child}; return copy.child.value;", + "const copy=source.child; return copy.value;", + "const copy=[source.child]; return copy[0].value;", + ])("keeps shared children live: %s", (body) => { + expect( + resultValue( + `const source={child:{value:1}}; ${body.replace("return", "mutate(source); return")}`, + )?.status, + ).toBe("unknown"); + }); +}); + +describe("references captured before an intermediate rebind", () => { + it.each([ + ["const saved=alias;", "alias=saved;", "mutate(alias);"], + ["const saved=alias;", "alias=saved;", "alias.value=9;"], + ["const saved={child:alias};", "alias=saved.child;", "mutate(alias);"], + ["const {...saved}={child:alias};", "alias=saved.child;", "mutate(alias);"], + ["const [...saved]=[alias];", "alias=saved[0];", "mutate(alias);"], + ["const {saved=alias}={};", "alias=saved;", "mutate(alias);"], + ])("follows an alias restored from %s", (capture, restore, effect) => { + expect( + resultValue( + `const source={value:1}; let alias=source; ${capture} ${restore} ${effect} return source.value;`, + )?.status, + ).toBe("unknown"); + }); + + it.each([ + ["const saved=alias;", "mutate(saved);"], + ["const saved={child:alias};", "mutate(saved.child);"], + ["const {...saved}={child:alias};", "mutate(saved.child);"], + ["const [...saved]=[alias];", "mutate(saved[0]);"], + ["const {saved=alias}={};", "mutate(saved);"], + ["const [saved=alias]=[];", "mutate(saved);"], + ["const saved=alias;", "saved.method();"], + ["const saved=alias;", "mutate(alias,saved);"], + ["const saved=alias;", "mutate(saved,alias);"], + ])("preserves the original reference captured by %s", (capture, effect) => { + expect( + resultValue( + `const source={value:1}; let alias=source; ${capture} alias={}; ${effect} return source.value;`, + )?.status, + ).toBe("unknown"); + expect( + resultValue( + `const source={value:1}; let alias=source; alias={}; ${capture} ${effect} return source.value;`, + ), + ).toEqual({ status: "literal", value: 1 }); + }); + + it.each([ + [ + "let {...alias}={child:source};", + "alias={child:{}};", + "saved.child.value=9;", + ], + ["let [...alias]=[source];", "alias=[{}];", "saved[0].value=9;"], + ["let {alias=source}={};", "alias={};", "saved.value=9;"], + ["let [alias=source]=[];", "alias={};", "saved.value=9;"], + ])( + "preserves a saved rest/default reference from %s", + (declaration, rebind, effect) => { + expect( + resultValue( + `const source={value:1}; ${declaration} const saved=alias; ${rebind} ${effect} return source.value;`, + )?.status, + ).toBe("unknown"); + expect( + resultValue( + `const source={value:1}; ${declaration} ${rebind} const saved=alias; ${effect} return source.value;`, + ), + ).toEqual({ status: "literal", value: 1 }); + }, + ); +}); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 7bdbdcc096..5a873429e1 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -52,6 +52,19 @@ interface BindPatternInput { readonly mutable: boolean; readonly kind?: JavaScriptSemanticDefinition["kind"]; readonly projection?: readonly (string | number | null)[]; + readonly referenceOnly?: boolean; + readonly copyKind?: "object-rest" | "array-rest"; + readonly copyProjectionOffset?: number; + readonly copyExcludedKeys?: readonly string[]; + readonly copyStartIndex?: number; + readonly fallbackSources?: readonly { + readonly node: t.Node; + readonly projection: readonly (string | number | null)[]; + }[]; + readonly requiredSources?: readonly { + readonly node: t.Node; + readonly projection: readonly (string | number | null)[]; + }[]; } interface AddBindingInput { @@ -279,6 +292,7 @@ const createState = (program: t.Program): JavaScriptSemanticAnalysisState => { bindings: new Map(), }; return { + parentsByNode: new WeakMap(), scopes: [root], scopesById: new Map([[root.scopeId, root]]), scopeByNode: new WeakMap([[program, root]]), @@ -752,12 +766,36 @@ const bindPattern = (input: BindPatternInput): void => { mutable, kind = "variable", projection = [], + referenceOnly = false, + copyKind, + copyProjectionOffset, + copyExcludedKeys, + copyStartIndex, + fallbackSources, + requiredSources, } = input; if (t.isTSParameterProperty(pattern)) { bindPattern({ ...input, pattern: pattern.parameter }); return; } if (t.isIdentifier(pattern)) { + if (referenceOnly) { + const binding = scope.bindings.get(pattern.name); + if (binding !== undefined && initializer !== null) + binding.referenceInitializers.push({ + node: initializer, + projection, + ...(copyKind === undefined ? {} : { copyKind }), + ...(copyProjectionOffset === undefined + ? {} + : { copyProjectionOffset }), + ...(copyExcludedKeys === undefined ? {} : { copyExcludedKeys }), + ...(copyStartIndex === undefined ? {} : { copyStartIndex }), + ...(fallbackSources === undefined ? {} : { fallbackSources }), + ...(requiredSources === undefined ? {} : { requiredSources }), + }); + return; + } addBinding({ state, scope, @@ -778,6 +816,25 @@ const bindPattern = (input: BindPatternInput): void => { kind === "parameter" || kind === "catch" ? initializer : (initializer ?? pattern.right), + requiredSources: [ + ...(requiredSources ?? []), + ...(initializer === null ? [] : [{ node: initializer, projection }]), + ], + }); + bindPattern({ + state, + scope, + mutable, + kind, + pattern: pattern.left, + initializer: pattern.right, + projection: [], + referenceOnly: true, + fallbackSources: [ + ...(fallbackSources ?? []), + ...(initializer === null ? [] : [{ node: initializer, projection }]), + ], + ...(requiredSources === undefined ? {} : { requiredSources }), }); return; } @@ -792,7 +849,7 @@ const bindPattern = (input: BindPatternInput): void => { } if (t.isObjectPattern(pattern)) for (const property of pattern.properties) { - if (t.isRestElement(property)) + if (t.isRestElement(property)) { bindPattern({ ...input, pattern: property.argument, @@ -800,7 +857,23 @@ const bindPattern = (input: BindPatternInput): void => { mutable: true, projection: [], }); - else { + bindPattern({ + ...input, + pattern: property.argument, + projection: [...projection, null], + referenceOnly: true, + copyKind: "object-rest", + copyProjectionOffset: projection.length, + copyExcludedKeys: pattern.properties.flatMap((property) => { + if (t.isRestElement(property)) return []; + const key = semanticStaticPropertyKey( + property.key, + property.computed, + ); + return key === null ? [] : [key]; + }), + }); + } else { const name = semanticStaticPropertyKey(property.key, property.computed); bindPattern({ ...input, @@ -811,12 +884,23 @@ const bindPattern = (input: BindPatternInput): void => { } else if (t.isArrayPattern(pattern)) pattern.elements.forEach((element, index) => { - if (element !== null) + if (element !== null) { bindPattern({ ...input, pattern: element, projection: [...projection, index], }); + if (t.isRestElement(element)) + bindPattern({ + ...input, + pattern: element.argument, + projection: [...projection, null], + referenceOnly: true, + copyKind: "array-rest", + copyProjectionOffset: projection.length, + copyStartIndex: index, + }); + } }); }; @@ -858,8 +942,10 @@ const createBinding = ( kind, mutable, mutatedPaths: [], + escapedPaths: [], definitions: [], initializers: [], + referenceInitializers: [], directOrigins: [], }); diff --git a/src/domain/javascript/javascriptSemanticIterationSources.ts b/src/domain/javascript/javascriptSemanticIterationSources.ts new file mode 100644 index 0000000000..1b9e7d7143 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticIterationSources.ts @@ -0,0 +1,163 @@ +import * as t from "@babel/types"; + +import { semanticStaticPropertyKey } from "./javascriptAstValues.js"; +import { semanticArrayIndex } from "./javascriptSemanticPropertyPaths.js"; + +interface IterationSourceValue { + readonly node: t.Node; + readonly projection: readonly (string | number | null)[]; +} + +/** An implicit iterator receiver, with guards for destructuring defaults. */ +export interface SemanticIterationSource extends IterationSourceValue { + readonly mutation: t.Node; + readonly fallbackSources?: readonly IterationSourceValue[]; + readonly requiredSources?: readonly IterationSourceValue[]; +} + +interface PatternSource extends IterationSourceValue { + // A rest binding is a fresh array whose children retain their source indices. + readonly restStartIndex?: number; +} + +interface SourceGuards { + readonly fallbackSources?: readonly IterationSourceValue[]; + readonly requiredSources?: readonly IterationSourceValue[]; +} + +const add = ( + sources: SemanticIterationSource[], + source: IterationSourceValue, + mutation: t.Node, + guards: SourceGuards, +): void => { + sources.push({ + node: source.node, + projection: source.projection, + mutation, + ...guards, + }); +}; +const project = ( + source: PatternSource | undefined, + key: string | number | null, +): PatternSource | undefined => { + if (source === undefined) return undefined; + const index = key === null ? null : semanticArrayIndex(key); + return { + node: source.node, + projection: [ + ...source.projection, + source.restStartIndex !== undefined && index !== null + ? source.restStartIndex + index + : key, + ], + }; +}; +interface PatternCollection { + readonly sources: SemanticIterationSource[]; + readonly mutation: t.Node; +} + +const visit = ( + context: PatternCollection, + pattern: t.Node, + source: PatternSource | undefined, + guards: SourceGuards = {}, +): void => { + const { sources, mutation } = context; + if (t.isTSParameterProperty(pattern)) { + visit(context, pattern.parameter, source, guards); + return; + } + if (t.isAssignmentPattern(pattern)) { + visit(context, pattern.left, source, { + ...guards, + requiredSources: [ + ...(guards.requiredSources ?? []), + ...(source === undefined ? [] : [source]), + ], + }); + visit( + context, + pattern.left, + { node: pattern.right, projection: [] }, + { + ...guards, + fallbackSources: [ + ...(guards.fallbackSources ?? []), + ...(source === undefined ? [] : [source]), + ], + }, + ); + return; + } + if (t.isObjectPattern(pattern)) { + for (const property of pattern.properties) { + // Object rest creates a fresh object and has no iterable binding pattern. + if (t.isRestElement(property)) continue; + visit( + context, + property.value, + project( + source, + semanticStaticPropertyKey(property.key, property.computed), + ), + guards, + ); + } + return; + } + if (t.isArrayPattern(pattern)) { + if (source !== undefined && source.restStartIndex === undefined) + add(sources, source, mutation, guards); + pattern.elements.forEach((element, index) => { + if (element === null) return; + if (t.isRestElement(element)) + visit( + context, + element.argument, + source === undefined + ? undefined + : { + node: source.node, + projection: source.projection, + restStartIndex: (source.restStartIndex ?? 0) + index, + }, + guards, + ); + else visit(context, element, project(source, index), guards); + }); + return; + } + if (t.isRestElement(pattern)) + visit(context, pattern.argument, undefined, guards); +}; + +/** Describe implicit iteration sources without deciding their runtime values. */ +export const semanticIterationSources = ( + node: t.Node, + parent: t.Node | null, +): readonly SemanticIterationSource[] => { + const sources: SemanticIterationSource[] = []; + const context = { sources, mutation: node }; + if (t.isVariableDeclarator(node) && node.init != null) + visit(context, node.id, { node: node.init, projection: [] }); + else if (t.isAssignmentExpression(node) && node.operator === "=") + visit(context, node.left, { node: node.right, projection: [] }); + else if (t.isForOfStatement(node)) { + add(sources, { node: node.right, projection: [] }, node, {}); + const source = { node: node.right, projection: [null] }; + if (t.isVariableDeclaration(node.left)) { + for (const declaration of node.left.declarations) + visit(context, declaration.id, source); + } else visit(context, node.left, source); + } else if (t.isSpreadElement(node) && t.isArrayExpression(parent)) + add(sources, { node: node.argument, projection: [] }, node, {}); + else if (t.isYieldExpression(node) && node.delegate && node.argument != null) + add(sources, { node: node.argument, projection: [] }, node, {}); + else if (t.isFunction(node)) + for (const parameter of node.params) visit(context, parameter, undefined); + + return sources; +}; diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index c9fa495585..526e937ad4 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -2,77 +2,268 @@ import * as t from "@babel/types"; import { resolveSemanticBindingState, - type JavaScriptSemanticAnalysisState, type JavaScriptSemanticBindingState, + type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; -import { evaluateSemanticBinding } from "./javascriptSemanticValues.js"; +import { + clearSemanticPrimitiveBindingValues, + evaluateSemanticBinding, + evaluateSemanticExpression, +} from "./javascriptSemanticValues.js"; +import { + semanticContainer, + semanticSlotAtPath, +} from "./javascriptSemanticSlots.js"; +import { semanticIterationSources } from "./javascriptSemanticIterationSources.js"; +import { semanticMutationInitializers } from "./javascriptSemanticMutationInitializers.js"; +import { + semanticArrayIndex, + semanticPropertyPathKeyMatches, + type JavaScriptSemanticPropertyPath, +} from "./javascriptSemanticPropertyPaths.js"; +import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; import { semanticStaticPropertyKey, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; -type PropertyPath = readonly (string | number | null)[]; +type PropertyPath = JavaScriptSemanticPropertyPath; +type ValueEffect = "write" | "escape"; -/** Keep explicit property mutations outside the initializer-only value lattice. */ +/** Preserve uncertainty from property writes and references exposed to calls. */ export const collectSemanticMemberMutations = ( program: t.Program, state: JavaScriptSemanticAnalysisState, ): void => { - const parents = new WeakMap(); + const parents = state.parentsByNode; traverseJavaScriptAst(program, { enter: (node, parent) => { if (parent !== null) parents.set(node, parent); }, }); + const recordedEffects = new Set(); + const selections = new Map(); + let arrayIterationUnknown = false; + const pendingReferences: { + readonly initializer: JavaScriptSemanticBindingState["referenceInitializers"][number]; + readonly path: PropertyPath; + readonly bindings: ReadonlySet; + readonly effect: ValueEffect; + readonly mutation?: t.Node; + readonly originAt: t.Node | undefined; + }[] = []; + const iterableReferences: IterableReference[] = []; + const iterableReferenceKeys = new WeakMap>(); + const deferIterable = (reference: IterableReference): void => { + const keys = iterableReferenceKeys.get(reference.node) ?? new Set(); + const key = JSON.stringify([ + reference.projection, + reference.path, + reference.fallbackPath, + reference.effect, + reference.mutation?.start, + reference.mutation?.end, + reference.originAt?.start, + reference.originAt?.end, + reference.iterationOnly, + reference.requiredSources?.map((source) => [ + source.node.start, + source.node.end, + source.projection, + ]), + reference.fallbackSources?.map((source) => [ + source.node.start, + source.node.end, + source.projection, + ]), + [...reference.bindings].sort(compareUnicodeCodePoints), + ]); + if (keys.has(key)) return; + keys.add(key); + iterableReferenceKeys.set(reference.node, keys); + iterableReferences.push(reference); + }; const markValue = ( node: t.Node, path: PropertyPath, bindings: ReadonlySet, - mutation: t.Node, + effect: ValueEffect = "write", + mutation?: t.Node, + originAt: t.Node | undefined = mutation, ): void => { - const unwrapped = unwrapJavaScriptExpression(node).node; - if (unwrapped !== node) { - markValue(unwrapped, path, bindings, mutation); - return; - } - if (t.isIdentifier(node)) { - const binding = resolveSemanticBindingState(state, node, node.name); - if (binding === undefined || bindings.has(binding.bindingId)) return; - const value = evaluateSemanticBinding(binding, state); - if (value.status === "literal" || value.status === "union") return; - binding.mutatedPaths.push(path); - const nested = new Set([...bindings, binding.bindingId]); - for (const initializer of mutationInitializers( - binding, - mutation, - parents, - )) - markValue( - initializer.node, - [...initializer.projection, ...path], - nested, - mutation, + const pending = [{ node, path, bindings, originAt }]; + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined) break; + const expression = unwrapJavaScriptExpression(current.node).node; + if (t.isIdentifier(expression)) { + const binding = resolveSemanticBindingState( + state, + expression, + expression.name, ); - return; - } - if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { - const members: (string | null)[] = []; - let current: t.Node = node; - while ( - t.isMemberExpression(current) || - t.isOptionalMemberExpression(current) + if (binding === undefined) { + if ( + (expression.name === "Array" && + (current.path[0] === "prototype" || current.path[0] === null)) || + (["globalThis", "global", "window", "self"].includes( + expression.name, + ) && + (current.path[0] === "Array" || current.path[0] === null) && + (current.path[1] === "prototype" || current.path[1] === null)) + ) + arrayIterationUnknown = true; + continue; + } + // An alias restored from a saved reference can revisit the same binding + // at an earlier capture. Only a repeated lifetime closes a cycle. + const lifetime = JSON.stringify([ + binding.bindingId, + current.originAt?.start, + current.originAt?.end, + ]); + if (current.bindings.has(lifetime)) continue; + // Union compatible copied-slot selections at each binding. Carrying every + // combination of exclusions through branch diamonds is exponential. + const selectorIndex = current.path.findIndex( + (key) => key !== null && typeof key === "object", + ); + let path = current.path; + const selector = path[selectorIndex]; + if (selector !== null && typeof selector === "object") { + const selectionIdentity = JSON.stringify([ + binding.bindingId, + effect, + path.map((key, index) => + index === selectorIndex + ? { startIndex: selector.startIndex } + : key, + ), + mutation?.start, + mutation?.end, + current.originAt?.start, + current.originAt?.end, + ]); + const previous = selections.get(selectionIdentity); + const excludedKeys = + previous === undefined + ? selector.excludedKeys + : previous.filter((key) => selector.excludedKeys.includes(key)); + if (previous !== undefined && excludedKeys.length === previous.length) + continue; + selections.set(selectionIdentity, excludedKeys); + path = path.map((key, index) => + index === selectorIndex ? { ...selector, excludedKeys } : key, + ); + } + const identity = JSON.stringify([ + binding.bindingId, + effect, + path, + mutation?.start, + mutation?.end, + current.originAt?.start, + current.originAt?.end, + ]); + if (recordedEffects.has(identity)) continue; + const value = + effect === "write" + ? evaluateSemanticBinding(binding, state) + : undefined; + const primitiveWrite = + value?.status === "literal" || value?.status === "union"; + recordedEffects.add(identity); + const nested = new Set([...current.bindings, lifetime]); + const origins = + current.originAt === undefined + ? binding + : semanticMutationInitializers(binding, current.originAt, parents); + if (!primitiveWrite) { + if (effect === "escape") + binding.escapedPaths.push({ path, node: mutation }); + else binding.mutatedPaths.push(path); + clearSemanticPrimitiveBindingValues(state); + for (const initializer of origins.initializers) + pending.push({ + node: initializer.node, + path: [...initializer.projection, ...path], + bindings: nested, + originAt: initializer.node, + }); + } + for (const initializer of origins.referenceInitializers) { + const copiedPath = copiedReferencePath(initializer, path, effect); + if (copiedPath === null) continue; + pendingReferences.push({ + initializer, + path: copiedPath, + bindings: nested, + effect, + originAt: initializer.node, + ...(mutation === undefined ? {} : { mutation }), + }); + } + } else if ( + t.isMemberExpression(expression) || + t.isOptionalMemberExpression(expression) ) { - members.push( - semanticStaticPropertyKey(current.property, current.computed), - ); - current = current.object; + pending.push({ + node: expression.object, + path: [ + semanticStaticPropertyKey(expression.property, expression.computed), + ...current.path, + ], + bindings: current.bindings, + originAt: current.originAt, + }); + } else { + if ( + t.isArrayExpression(expression) && + ((current.path[0] === "__proto__" && + (effect === "escape" || current.path.length > 1)) || + (current.path[0] === "constructor" && + current.path[1] === "prototype")) + ) + arrayIterationUnknown = true; + for (const value of referencedValues( + expression, + current.path, + effect, + )) { + if (value.iterableFallbackPath !== undefined) + deferIterable({ + node: value.node, + projection: [], + path: value.path, + fallbackPath: value.iterableFallbackPath, + bindings: current.bindings, + effect, + originAt: current.originAt, + ...(mutation === undefined ? {} : { mutation }), + }); + else + pending.push({ + ...value, + bindings: current.bindings, + originAt: current.originAt, + }); + } } - markValue(current, [...members.reverse(), ...path], bindings, mutation); - return; } - for (const value of referencedValues(node, path)) - markValue(value.node, value.path, bindings, mutation); + }; + const markEscaped = ( + node: t.Node, + mutation: t.Node, + path: PropertyPath = [], + ): void => markValue(node, path, new Set(), "escape", mutation); + const markReceiver = (callee: t.Node, mutation: t.Node): void => { + const expression = unwrapJavaScriptExpression(callee).node; + if ( + t.isMemberExpression(expression) || + t.isOptionalMemberExpression(expression) + ) + markEscaped(expression.object, mutation); }; const markTarget = (node: t.Node, mutation: t.Node): void => { if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) @@ -80,6 +271,7 @@ export const collectSemanticMemberMutations = ( node.object, [semanticStaticPropertyKey(node.property, node.computed)], new Set(), + "write", mutation, ); else if (t.isRestElement(node)) markTarget(node.argument, mutation); @@ -96,143 +288,245 @@ export const collectSemanticMemberMutations = ( } }; traverseJavaScriptAst(program, { - enter: (node) => { + enter: (node, parent) => { + for (const source of semanticIterationSources(node, parent)) + deferIterable({ + ...source, + path: source.projection, + fallbackPath: source.projection, + bindings: new Set(), + effect: "escape", + originAt: source.mutation, + iterationOnly: true, + }); if (t.isAssignmentExpression(node)) markTarget(node.left, node); else if (t.isUpdateExpression(node)) markTarget(node.argument, node); else if (t.isUnaryExpression(node, { operator: "delete" })) markTarget(node.argument, node); else if (t.isForOfStatement(node) || t.isForInStatement(node)) markTarget(node.left, node); + else if ( + t.isCallExpression(node) || + t.isOptionalCallExpression(node) || + t.isNewExpression(node) + ) { + for (const argument of node.arguments) + if (t.isSpreadElement(argument)) + deferIterable({ + node: argument.argument, + projection: [], + path: [null], + fallbackPath: [], + bindings: new Set(), + effect: "escape", + mutation: node, + originAt: node, + }); + else markEscaped(argument, node); + // Constructing a member does not pass its container as `this`. + if (!t.isNewExpression(node)) markReceiver(node.callee, node); + } else if (t.isTaggedTemplateExpression(node)) { + markReceiver(node.tag, node); + for (const expression of node.quasi.expressions) + markEscaped(expression, node); + } }, }); -}; - -interface StatementPosition { - readonly body: readonly t.Statement[]; - readonly index: number; -} - -const statementPosition = ( - statement: t.Statement, - parents: WeakMap, -): StatementPosition | null => { - const parent = parents.get(statement); - if (!t.isProgram(parent) && !t.isBlockStatement(parent)) return null; - const index = parent.body.indexOf(statement); - return index < 0 ? null : { body: parent.body, index }; -}; - -const directInitializerPosition = ( - node: t.Node, - parents: WeakMap, -): StatementPosition | null => { - const parent = parents.get(node); - if (t.isVariableDeclarator(parent) && parent.init === node) { - const declaration = parents.get(parent); - return t.isVariableDeclaration(declaration) - ? statementPosition(declaration, parents) - : null; - } - if ( - t.isAssignmentExpression(parent) && - parent.operator === "=" && - parent.right === node && - t.isIdentifier(parent.left) - ) { - const statement = parents.get(parent); - return t.isExpressionStatement(statement) && statement.expression === parent - ? statementPosition(statement, parents) - : null; - } - return null; -}; - -const directMutationPosition = ( - node: t.Node, - parents: WeakMap, -): StatementPosition | null => { - if ( - !t.isAssignmentExpression(node) && - !t.isUpdateExpression(node) && - !t.isUnaryExpression(node, { operator: "delete" }) - ) - return null; - const statement = parents.get(node); - return t.isExpressionStatement(statement) && statement.expression === node - ? statementPosition(statement, parents) - : null; -}; - -const mutationInitializers = ( - binding: JavaScriptSemanticBindingState, - mutation: t.Node, - parents: WeakMap, -): JavaScriptSemanticBindingState["initializers"] => { - if (binding.initializers.length < 2) return binding.initializers; - const mutationPosition = directMutationPosition(mutation, parents); - if (mutationPosition === null) return binding.initializers; - const positions = binding.initializers.map(({ node, entryBody }) => - entryBody === undefined - ? directInitializerPosition(node, parents) - : { body: entryBody.body, index: -1 }, - ); - if ( - positions.some( - (position) => - position === null || - position.body !== mutationPosition.body || - position.index >= mutationPosition.index, + // A later effect can make a previously defined destructuring source uncertain. + // Reconsider defaults and iterable origins until their effects are stable. + while (pendingReferences.length > 0 || iterableReferences.length > 0) { + const effectsBefore = recordedEffects.size; + const iterablesBefore = iterableReferences.length; + const iterationBefore: boolean = arrayIterationUnknown; + for (const reference of pendingReferences.splice(0)) { + const { initializer } = reference; + if (referenceSourcesInactive(initializer, state)) + pendingReferences.push(reference); + else if ( + initializer.copyKind === "array-rest" && + initializer.copyProjectionOffset !== undefined + ) { + const offset = initializer.copyProjectionOffset; + const projection = initializer.projection.slice(0, offset); + deferIterable({ + node: initializer.node, + projection, + path: reference.path, + fallbackPath: projection, + bindings: reference.bindings, + effect: reference.effect, + originAt: reference.originAt, + ...(reference.mutation === undefined + ? {} + : { mutation: reference.mutation }), + }); + } else + markValue( + initializer.node, + reference.path, + reference.bindings, + reference.effect, + reference.mutation, + reference.originAt, + ); + } + for (const reference of [...iterableReferences]) { + if (referenceSourcesInactive(reference, state)) continue; + if (reference.iterationOnly) { + const expanded = expandedIterationReferences(reference, state); + if (expanded !== null) { + for (const selected of expanded) deferIterable(selected); + continue; + } + } + const slot = selectedReferenceSlot(reference, state); + // Custom iteration can yield the iterable itself as well as any child. + // Treat its root as escaped, including writes through yielded references. + const knownArray = + !arrayIterationUnknown && + slot?.value.status === "array" && + slot.value.items.every( + (item) => semanticArrayIndex(item.name) !== null, + ); + if (knownArray && reference.iterationOnly) continue; + markValue( + reference.node, + knownArray ? reference.path : reference.fallbackPath, + reference.bindings, + knownArray ? reference.effect : "escape", + reference.mutation, + reference.originAt, + ); + } + if ( + recordedEffects.size === effectsBefore && + iterableReferences.length === iterablesBefore && + arrayIterationUnknown === iterationBefore ) - ) - return binding.initializers; - const latestIndex = positions.reduce( - (latest, position) => Math.max(latest, position?.index ?? -1), - -1, - ); - return binding.initializers.filter( - (_, index) => positions[index]?.index === latestIndex, - ); + break; + } + // Gathering effects can evaluate a primitive projection before its object + // escapes. Final values must use the completed mutation state. + clearSemanticPrimitiveBindingValues(state); }; interface ReferencedValue { readonly node: t.Node; readonly path: PropertyPath; + readonly iterableFallbackPath?: PropertyPath; } +interface IterableReference extends ReferencedValue { + readonly projection: PropertyPath; + readonly fallbackPath: PropertyPath; + readonly bindings: ReadonlySet; + readonly effect: ValueEffect; + readonly mutation?: t.Node; + readonly originAt: t.Node | undefined; + readonly iterationOnly?: boolean; + readonly requiredSources?: readonly { + readonly node: t.Node; + readonly projection: PropertyPath; + }[]; + readonly fallbackSources?: readonly { + readonly node: t.Node; + readonly projection: PropertyPath; + }[]; +} + +const referenceSourcesInactive = ( + reference: Pick, + state: JavaScriptSemanticAnalysisState, +): boolean => + reference.requiredSources?.some( + (source) => selectedReferenceSlot(source, state)?.presence === "absent", + ) === true || + reference.fallbackSources?.some((source) => { + const slot = selectedReferenceSlot(source, state); + return ( + slot?.presence === "present" && + (slot.value.status === "literal" || + slot.value.status === "union" || + slot.value.status === "object" || + slot.value.status === "array") + ); + }) === true; + +const expandedIterationReferences = ( + reference: IterableReference, + state: JavaScriptSemanticAnalysisState, +): readonly IterableReference[] | null => { + if (!t.isForOfStatement(reference.mutation)) return null; + const iterable = reference.mutation.right; + // Expand only the loop's yielded-item selector. Enumerating dynamic property + // selections would multiply paths through shared object aliases. + const source = [ + reference, + ...(reference.requiredSources ?? []), + ...(reference.fallbackSources ?? []), + ].find((source) => source.node === iterable && source.projection[0] === null); + if (source === undefined) return null; + const slot = selectedReferenceSlot({ node: iterable, projection: [] }, state); + const container = slot === undefined ? null : semanticContainer(slot.value); + if (container?.coverage.status !== "complete") return null; + const select = ( + candidate: { readonly node: t.Node; readonly projection: PropertyPath }, + name: string, + ) => ({ + ...candidate, + projection: + candidate.node === iterable && candidate.projection[0] === null + ? [name, ...candidate.projection.slice(1)] + : candidate.projection, + }); + return container.slots.map((slot) => { + const { projection } = select(reference, slot.name); + return { + ...reference, + projection, + path: projection, + fallbackPath: projection, + ...(reference.requiredSources === undefined + ? {} + : { + requiredSources: reference.requiredSources.map((source) => + select(source, slot.name), + ), + }), + ...(reference.fallbackSources === undefined + ? {} + : { + fallbackSources: reference.fallbackSources.map((source) => + select(source, slot.name), + ), + }), + }; + }); +}; + const referencedValues = ( node: t.Node, path: PropertyPath, + effect: ValueEffect, ): readonly ReferencedValue[] => { - const [key, ...remaining] = path; // An initializer owns its slots; only deeper writes can affect shared children. - if (t.isObjectExpression(node)) - return path.length < 2 - ? [] - : node.properties.flatMap((property) => { - if (t.isSpreadElement(property)) - return [{ node: property.argument, path }]; - if (!t.isObjectProperty(property)) return []; - const name = semanticStaticPropertyKey( - property.key, - property.computed, - ); - return key === null || name === null || String(key) === name - ? [{ node: property.value, path: remaining }] - : []; - }); - if (t.isArrayExpression(node)) { - if (path.length < 2) return []; - let uncertainIndex = false; - return node.elements.flatMap((element, index) => { - if (element === null) return []; - if (t.isSpreadElement(element)) { - uncertainIndex = true; - return [{ node: element.argument, path: [null, ...remaining] }]; - } - return uncertainIndex || key === null || String(key) === String(index) - ? [{ node: element, path: remaining }] - : []; - }); + if (t.isObjectExpression(node) || t.isArrayExpression(node)) { + if (effect === "write" && path.length < 2) return []; + return t.isObjectExpression(node) + ? objectReferencedValues(node, path) + : arrayReferencedValues(node, path); + } + if (t.isAssignmentExpression(node)) + return node.operator === "||=" || node.operator === "??=" + ? [ + { node: node.left, path }, + { node: node.right, path }, + ] + : [{ node: node.right, path }]; + if (t.isAwaitExpression(node)) return [{ node: node.argument, path }]; + if (t.isSequenceExpression(node)) { + const last = node.expressions.at(-1); + return last === undefined ? [] : [{ node: last, path }]; } if (t.isConditionalExpression(node)) return [ @@ -244,7 +538,169 @@ const referencedValues = ( { node: node.left, path }, { node: node.right, path }, ]; - if (t.isAssignmentExpression(node, { operator: "&&=" })) - return [{ node: node.right, path }]; return []; }; + +const objectReferencedValues = ( + node: t.ObjectExpression, + path: PropertyPath, +): readonly ReferencedValue[] => { + const [key, ...remaining] = path; + const references: ReferencedValue[] = []; + const overwritten = new Set(); + for (const property of [...node.properties].reverse()) { + if (t.isSpreadElement(property)) { + const selected = key ?? null; + if (typeof selected !== "object" && overwritten.has(String(selected))) + continue; + references.push({ + node: property.argument, + path: [ + typeof selected === "object" + ? { + ...(selected ?? {}), + excludedKeys: [ + ...new Set([ + ...(selected?.excludedKeys ?? []), + ...overwritten, + ]), + ].sort(compareUnicodeCodePoints), + } + : selected, + ...remaining, + ], + }); + continue; + } + const name = semanticStaticPropertyKey(property.key, property.computed); + if (name !== null && overwritten.has(name)) continue; + if ( + t.isObjectProperty(property) && + (name === null || semanticPropertyPathKeyMatches(key ?? null, name)) + ) + references.push({ node: property.value, path: remaining }); + // A prototype setter does not replace an own property from a spread. + if ( + name !== null && + !(t.isObjectMethod(property) && property.kind !== "method") && + !( + t.isObjectProperty(property) && + !property.computed && + !property.shorthand && + name === "__proto__" + ) + ) + overwritten.add(name); + } + return references; +}; + +const arrayReferencedValues = ( + node: t.ArrayExpression, + path: PropertyPath, +): readonly ReferencedValue[] => { + const [key, ...remaining] = path; + const selected = key ?? null; + const selectedIndex = + typeof selected === "object" ? null : semanticArrayIndex(selected); + if (typeof selected !== "object" && selectedIndex === null) return []; + const references: ReferencedValue[] = []; + let minimumIndex = 0; + let uncertainIndex = false; + for (const element of node.elements) { + if (t.isSpreadElement(element)) { + if (selectedIndex === null || selectedIndex >= minimumIndex) { + let sourceKey: PropertyPath[number] = null; + if (!uncertainIndex) { + if (selectedIndex !== null) sourceKey = selectedIndex - minimumIndex; + else if (selected !== null && typeof selected === "object") + sourceKey = { + excludedKeys: selected.excludedKeys.flatMap((name) => { + const index = semanticArrayIndex(name); + return index === null || index < minimumIndex + ? [] + : [String(index - minimumIndex)]; + }), + startIndex: Math.max( + 0, + (selected.startIndex ?? 0) - minimumIndex, + ), + }; + } + references.push({ + node: element.argument, + path: [sourceKey, ...remaining], + iterableFallbackPath: [], + }); + } + uncertainIndex = true; + continue; + } + if ( + element !== null && + (uncertainIndex + ? selectedIndex === null || selectedIndex >= minimumIndex + : semanticPropertyPathKeyMatches(selected, String(minimumIndex))) + ) + references.push({ node: element, path: remaining }); + minimumIndex++; + } + return references; +}; + +const copiedReferencePath = ( + initializer: JavaScriptSemanticBindingState["referenceInitializers"][number], + path: PropertyPath, + effect: ValueEffect, +): PropertyPath | null => { + const offset = initializer.copyProjectionOffset; + if (offset === undefined) return [...initializer.projection, ...path]; + const copiedPath = [...initializer.projection.slice(offset + 1), ...path]; + if (effect === "write" && copiedPath.length < 2) return null; + let [key, ...remaining] = copiedPath; + if (initializer.copyKind === "array-rest") { + const startIndex = initializer.copyStartIndex ?? 0; + if (key === undefined || key === null) + key = { excludedKeys: [], startIndex }; + else if (typeof key === "object") + key = { + excludedKeys: key.excludedKeys.flatMap((name) => { + const index = semanticArrayIndex(name); + return index === null ? [] : [String(index + startIndex)]; + }), + startIndex: (key.startIndex ?? 0) + startIndex, + }; + else { + const index = semanticArrayIndex(key); + if (index === null) return null; + key = index + startIndex; + } + } else { + const excludedKeys = initializer.copyExcludedKeys ?? []; + if (key === undefined || key === null) key = { excludedKeys }; + else if (typeof key === "object") + key = { + ...key, + excludedKeys: [...new Set([...key.excludedKeys, ...excludedKeys])].sort( + compareUnicodeCodePoints, + ), + }; + else if (excludedKeys.includes(String(key))) return null; + } + return [...initializer.projection.slice(0, offset), key, ...remaining]; +}; + +const selectedReferenceSlot = ( + source: { readonly node: t.Node; readonly projection: PropertyPath }, + state: JavaScriptSemanticAnalysisState, +) => { + const path: string[] = []; + for (const key of source.projection) { + if (key === null || typeof key === "object") return undefined; + path.push(String(key)); + } + return semanticSlotAtPath( + evaluateSemanticExpression(source.node, state), + path, + ); +}; diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.test.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.test.ts new file mode 100644 index 0000000000..8f40494099 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.test.ts @@ -0,0 +1,63 @@ +import * as t from "@babel/types"; +import { describe, expect, it } from "vitest"; + +import { semanticMutationInitializers } from "./javascriptSemanticMutationInitializers.js"; +import type { JavaScriptSemanticBindingState } from "./javascriptSemanticState.js"; +import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; + +describe("parameter references copied into function body bindings", () => { + it.each([ + ["var arg = []; mutate(arg);", false], + ["mutate(arg); var arg = [];", true], + ["if (flag) { var arg = []; } mutate(arg);", true], + ])( + "retains the entry reference only when still reachable: %s", + (body, retained) => { + const program = parseJavaScriptSource( + `function result(arg = source) { ${body} }`, + )?.program; + const callable = program?.body[0]; + if (program === undefined || !t.isFunctionDeclaration(callable)) + throw new Error("Expected a parsed function declaration"); + const parameter = callable.params[0]; + if (!t.isAssignmentPattern(parameter) || !t.isIdentifier(parameter.left)) + throw new Error("Expected the source parameter binding"); + const parents = new WeakMap(); + let replacement: t.Node | undefined; + let mutation: t.Node | undefined; + traverseJavaScriptAst(program, { + enter: (node, parent) => { + if (parent !== null) parents.set(node, parent); + if (t.isVariableDeclarator(node) && node.init !== null) + replacement = node.init; + if (t.isCallExpression(node)) mutation = node; + }, + }); + if (replacement === undefined || mutation === undefined) + throw new Error("Expected a body initializer and subsequent effect"); + const entry = { + node: parameter.left, + projection: [], + entryBody: callable.body, + }; + const assigned = { node: replacement, projection: [] }; + const binding: JavaScriptSemanticBindingState = { + bindingId: "body:arg", + scopeId: "body", + name: "arg", + kind: "variable", + mutable: true, + mutatedPaths: [], + escapedPaths: [], + definitions: [], + initializers: [entry, assigned], + referenceInitializers: [], + directOrigins: [], + }; + const origins = semanticMutationInitializers(binding, mutation, parents); + expect(origins.initializers.includes(entry)).toBe(retained); + expect(origins.initializers).toContain(assigned); + }, + ); +}); diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts new file mode 100644 index 0000000000..d80d6b9ad2 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -0,0 +1,228 @@ +import * as t from "@babel/types"; + +import { semanticStaticPropertyKey } from "./javascriptAstValues.js"; +import type { JavaScriptSemanticBindingState } from "./javascriptSemanticState.js"; + +interface StatementPosition { + readonly body: readonly t.Statement[]; + readonly index: number; +} + +const statementPosition = ( + statement: t.Statement, + parents: WeakMap, +): StatementPosition | null => { + const parent = parents.get(statement); + if (!t.isProgram(parent) && !t.isBlockStatement(parent)) return null; + const index = parent.body.indexOf(statement); + return index < 0 ? null : { body: parent.body, index }; +}; + +const directInitializerPosition = ( + node: t.Node, + parents: WeakMap, +): StatementPosition | null => { + const parent = parents.get(node); + if (t.isVariableDeclarator(parent) && parent.init === node) { + const declaration = parents.get(parent); + return t.isVariableDeclaration(declaration) + ? statementPosition(declaration, parents) + : null; + } + if ( + t.isAssignmentExpression(parent) && + parent.operator === "=" && + parent.right === node && + t.isIdentifier(parent.left) + ) { + const statement = parents.get(parent); + return t.isExpressionStatement(statement) && statement.expression === parent + ? statementPosition(statement, parents) + : null; + } + return null; +}; + +const directMutationPosition = ( + node: t.Node, + parents: WeakMap, +): StatementPosition | null => { + if (t.isForOfStatement(node)) return statementPosition(node, parents); + if (t.isVariableDeclarator(node)) { + const declaration = parents.get(node); + return t.isVariableDeclaration(declaration) + ? statementPosition(declaration, parents) + : null; + } + if (t.isSpreadElement(node)) { + const array = parents.get(node); + return t.isArrayExpression(array) + ? semanticCapturePosition(array, parents) + : null; + } + if ( + !t.isAssignmentExpression(node) && + !t.isUpdateExpression(node) && + !t.isUnaryExpression(node, { operator: "delete" }) && + !t.isCallExpression(node) && + !t.isOptionalCallExpression(node) && + !t.isNewExpression(node) && + !t.isTaggedTemplateExpression(node) + ) + return null; + const statement = parents.get(node); + return t.isExpressionStatement(statement) && statement.expression === node + ? statementPosition(statement, parents) + : null; +}; + +const referenceInitializerPosition = ( + node: t.Node, + parents: WeakMap, +): StatementPosition | null => { + // Defaults live inside the declaration pattern rather than its initializer. + let current = node; + while (true) { + const direct = directInitializerPosition(current, parents); + if (direct !== null) return direct; + const parent = parents.get(current); + if (parent === undefined || t.isStatement(parent) || t.isFunction(parent)) + return null; + if (t.isVariableDeclarator(parent)) { + const declaration = parents.get(parent); + return t.isVariableDeclaration(declaration) + ? statementPosition(declaration, parents) + : null; + } + current = parent; + } +}; + +/** Resolve origins at their use or capture, never at a later transitive effect. */ +export const semanticMutationInitializers = ( + binding: JavaScriptSemanticBindingState, + mutation: t.Node, + parents: WeakMap, +): Pick< + JavaScriptSemanticBindingState, + "initializers" | "referenceInitializers" +> => { + const mutationPosition = + directMutationPosition(mutation, parents) ?? + referenceInitializerPosition(mutation, parents); + if (mutationPosition === null) return binding; + const origins = [ + ...binding.initializers.map((initializer) => ({ + initializer, + position: + initializer.entryBody === undefined + ? directInitializerPosition(initializer.node, parents) + : { body: initializer.entryBody.body, index: -1 }, + })), + ...binding.referenceInitializers.map((initializer) => ({ + initializer, + position: referenceInitializerPosition(initializer.node, parents), + })), + ]; + if ( + origins.some( + ({ position }) => + position === null || + position.body !== mutationPosition.body || + position.index >= mutationPosition.index, + ) + ) + return binding; + const latestIndex = origins.reduce( + (latest, { position }) => Math.max(latest, position?.index ?? -1), + -1, + ); + const retained = new Set( + origins + .filter(({ position }) => position?.index === latestIndex) + .map(({ initializer }) => initializer), + ); + return { + initializers: binding.initializers.filter((initializer) => + retained.has(initializer), + ), + referenceInitializers: binding.referenceInitializers.filter((initializer) => + retained.has(initializer), + ), + }; +}; + +/** Locate a capture only in an unconditional declaration or literal initializer. */ +export const semanticCapturePosition = ( + node: t.Node, + parents: WeakMap, +): StatementPosition | null => { + let current = node; + while (true) { + const direct = directInitializerPosition(current, parents); + if (direct !== null) return direct; + const parent = parents.get(current); + if ( + t.isObjectProperty(parent) && + parent.value === current && + semanticStaticPropertyKey(parent.key, parent.computed) !== null + ) + current = parent; + else if (t.isObjectExpression(parent) || t.isArrayExpression(parent)) + current = parent; + else return null; + } +}; + +/** Intermediate values must themselves have been initialized before capture. */ +export const semanticBindingPrecedesCapture = ( + binding: JavaScriptSemanticBindingState, + capture: t.Node, + parents: WeakMap, +): boolean => { + const initializer = binding.initializers[0]; + if (binding.initializers.length !== 1 || initializer === undefined) + return false; + const sourcePosition = directInitializerPosition(initializer.node, parents); + const capturePosition = semanticCapturePosition(capture, parents); + return ( + sourcePosition !== null && + capturePosition !== null && + sourcePosition.body === capturePosition.body && + sourcePosition.index < capturePosition.index + ); +}; + +/** Ignore later escapes only while reading earlier references in one activation. */ +export const semanticEscapeFollowsCapture = ( + binding: JavaScriptSemanticBindingState, + capture: t.Node, + escape: t.Node | undefined, + parents: WeakMap, +): boolean => { + const initializer = binding.initializers[0]; + if ( + escape === undefined || + initializer === undefined || + !semanticBindingPrecedesCapture(binding, capture, parents) + ) + return false; + // A literal allocates here. Aliases are evaluated at the same capture point, + // so an outer/persistent origin still receives all of its own escape effects. + if ( + !t.isObjectExpression(initializer.node) && + !t.isArrayExpression(initializer.node) && + !t.isIdentifier(initializer.node) && + !t.isMemberExpression(initializer.node) && + !t.isOptionalMemberExpression(initializer.node) + ) + return false; + const capturePosition = semanticCapturePosition(capture, parents); + const escapePosition = directMutationPosition(escape, parents); + return ( + capturePosition !== null && + escapePosition !== null && + capturePosition.body === escapePosition.body && + capturePosition.index < escapePosition.index + ); +}; diff --git a/src/domain/javascript/javascriptSemanticMutationValues.ts b/src/domain/javascript/javascriptSemanticMutationValues.ts index 70a25a33c4..dbbc9d4572 100644 --- a/src/domain/javascript/javascriptSemanticMutationValues.ts +++ b/src/domain/javascript/javascriptSemanticMutationValues.ts @@ -1,9 +1,42 @@ -import type { JavaScriptSemanticValue } from "./javascriptSemanticValueTypes.js"; +import type { + JavaScriptSemanticProperty, + JavaScriptSemanticValue, +} from "./javascriptSemanticValueTypes.js"; +import { + semanticPropertyPathKeyMatches, + type JavaScriptSemanticPropertyPath, +} from "./javascriptSemanticPropertyPaths.js"; + +/** Invalidate escaped object references without rewriting their containing slots. */ +export const invalidateSemanticEscapedPath = ( + value: JavaScriptSemanticValue, + path: JavaScriptSemanticPropertyPath, +): JavaScriptSemanticValue => { + if (value.status !== "object" && value.status !== "array") return value; + const [key, ...remaining] = path; + if (key === undefined) + return { + status: "unknown", + reason: "This object reference may have been mutated by a call.", + }; + const invalidateChild = ( + property: JavaScriptSemanticProperty, + ): JavaScriptSemanticProperty => + semanticPropertyPathKeyMatches(key, property.name) + ? { + ...property, + value: invalidateSemanticEscapedPath(property.value, remaining), + } + : property; + return value.status === "object" + ? { ...value, properties: value.properties.map(invalidateChild) } + : { ...value, items: value.items.map(invalidateChild) }; +}; /** Invalidate only slots that an explicit mutation can affect. */ export const invalidateSemanticMutationPath = ( value: JavaScriptSemanticValue, - path: readonly (string | number | null)[], + path: JavaScriptSemanticPropertyPath, ): JavaScriptSemanticValue => { const [key, ...remaining] = path; const unknown: JavaScriptSemanticValue = { @@ -11,13 +44,20 @@ export const invalidateSemanticMutationPath = ( reason: "This property may have been mutated.", }; if (key === undefined || key === null) return unknown; - if (value.status === "object") { - const name = String(key); - const observed = value.properties.some( - (property) => property.name === name, + if (value.status === "object" || value.status === "array") { + if (value.status === "array" && key === "length") + return { + status: "array", + items: [], + unknownItems: true, + omittedItems: null, + }; + const slots = value.status === "object" ? value.properties : value.items; + const observed = slots.some((property) => + semanticPropertyPathKeyMatches(key, property.name), ); - const properties = value.properties.map((property) => - property.name === name + const properties = slots.map((property) => + semanticPropertyPathKeyMatches(key, property.name) ? { ...property, ...(remaining.length === 0 @@ -27,37 +67,15 @@ export const invalidateSemanticMutationPath = ( } : property, ); - if (!observed) - properties.push({ name, value: unknown, presence: "unknown-coverage" }); - return { ...value, properties }; - } - if (value.status === "array") { - 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 { - 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 - ? { - ...item, - presence: - remaining.length === 0 - ? ("unknown-coverage" as const) - : item.presence, - value: invalidateSemanticMutationPath(item.value, remaining), - } - : item, - ); - if (!observed) - items.push({ name, presence: "unknown-coverage", value: unknown }); - return { ...value, items }; + if (!observed && typeof key !== "object") + properties.push({ + name: String(key), + value: unknown, + presence: "unknown-coverage", + }); + return value.status === "object" + ? { ...value, properties } + : { ...value, items: properties }; } return unknown; }; diff --git a/src/domain/javascript/javascriptSemanticPropertyPaths.ts b/src/domain/javascript/javascriptSemanticPropertyPaths.ts new file mode 100644 index 0000000000..9bee0e9b7a --- /dev/null +++ b/src/domain/javascript/javascriptSemanticPropertyPaths.ts @@ -0,0 +1,37 @@ +/** Slots retained by a shallow copy after exclusions or an array prefix. */ +export interface JavaScriptSemanticPropertySelection { + readonly excludedKeys: readonly string[]; + readonly startIndex?: number; +} + +/** Internal mutation path, including selected children of a shallow copy. */ +export type JavaScriptSemanticPropertyPath = readonly ( + | string + | number + | null + | JavaScriptSemanticPropertySelection +)[]; + +/** Read only canonical JavaScript array indices, excluding named properties. */ +export const semanticArrayIndex = (key: string | number): number | null => { + const index = Number(key); + return Number.isInteger(index) && + index >= 0 && + index < 4_294_967_295 && + String(index) === String(key) + ? index + : null; +}; + +/** Test an exact key, wildcard, or copied-slot selection against an own key. */ +export const semanticPropertyPathKeyMatches = ( + key: JavaScriptSemanticPropertyPath[number], + name: string, +): boolean => { + if (key === null) return true; + if (typeof key !== "object") return String(key) === name; + if (key.excludedKeys.includes(name)) return false; + if (key.startIndex === undefined) return true; + const index = semanticArrayIndex(name); + return index !== null && index >= key.startIndex; +}; diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index cd5f40e4bd..28ca97371e 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -1,5 +1,7 @@ import * as t from "@babel/types"; +import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropertyPaths.js"; + import type { JavaScriptModuleOrigin, JavaScriptSemanticDefinition, @@ -15,6 +17,16 @@ interface JavaScriptSemanticInitializer { readonly entryBody?: t.BlockStatement; } +/** Additional references from destructuring copies and possible defaults. */ +interface JavaScriptSemanticReferenceInitializer extends JavaScriptSemanticInitializer { + readonly copyKind?: "object-rest" | "array-rest"; + readonly copyProjectionOffset?: number; + readonly copyExcludedKeys?: readonly string[]; + readonly copyStartIndex?: number; + readonly fallbackSources?: readonly JavaScriptSemanticInitializer[]; + readonly requiredSources?: readonly JavaScriptSemanticInitializer[]; +} + /** Mutable binding state used only while constructing the immutable IR. */ export interface JavaScriptSemanticBindingState { readonly bindingId: string; @@ -22,9 +34,14 @@ export interface JavaScriptSemanticBindingState { readonly name: string; kind: JavaScriptSemanticDefinition["kind"]; mutable: boolean; - readonly mutatedPaths: (readonly (string | number | null)[])[]; + readonly mutatedPaths: JavaScriptSemanticPropertyPath[]; + readonly escapedPaths: { + readonly path: JavaScriptSemanticPropertyPath; + readonly node: t.Node | undefined; + }[]; readonly definitions: JavaScriptSemanticDefinition[]; readonly initializers: JavaScriptSemanticInitializer[]; + readonly referenceInitializers: JavaScriptSemanticReferenceInitializer[]; readonly directOrigins: JavaScriptModuleOrigin[]; } @@ -40,6 +57,7 @@ export interface JavaScriptSemanticScopeState { /** Shared state for semantic collection and evaluation. */ export interface JavaScriptSemanticAnalysisState { + readonly parentsByNode: WeakMap; readonly scopes: JavaScriptSemanticScopeState[]; readonly scopesById: Map; readonly scopeByNode: WeakMap; diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index 2c0b855a47..51dd8da71b 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -1,8 +1,17 @@ import * as t from "@babel/types"; +import { + semanticCapturePosition, + semanticEscapeFollowsCapture, + semanticBindingPrecedesCapture, +} from "./javascriptSemanticMutationInitializers.js"; + import { semanticSlotAtPath } from "./javascriptSemanticSlots.js"; -import { invalidateSemanticMutationPath } from "./javascriptSemanticMutationValues.js"; +import { + invalidateSemanticEscapedPath, + invalidateSemanticMutationPath, +} from "./javascriptSemanticMutationValues.js"; import type { JavaScriptBindingProvenance } from "./javascriptSemanticIr.js"; import type { @@ -46,6 +55,7 @@ interface EvaluationContext { readonly state: JavaScriptSemanticAnalysisState; readonly bindings: ReadonlySet; readonly expressionDepth: number; + readonly capturePoint?: t.Node; readonly primitiveBindingValues: Map; } @@ -54,6 +64,25 @@ const primitiveBindingValuesByState = new WeakMap< Map >(); +const primitiveExpressionValuesByState = new WeakMap< + JavaScriptSemanticAnalysisState, + WeakMap +>(); + +const capturedBindingValuesByState = new WeakMap< + JavaScriptSemanticAnalysisState, + WeakMap> +>(); + +/** Discard primitive projections evaluated before mutation collection finished. */ +export const clearSemanticPrimitiveBindingValues = ( + state: JavaScriptSemanticAnalysisState, +): void => { + primitiveBindingValuesByState.delete(state); + capturedBindingValuesByState.delete(state); + primitiveExpressionValuesByState.delete(state); +}; + const primitiveBindingValuesFor = ( state: JavaScriptSemanticAnalysisState, ): Map => { @@ -115,6 +144,20 @@ const evaluateBinding = ( ): JavaScriptSemanticValue => { if (context.bindings.has(binding.bindingId)) return { status: "cycle", reason: `Alias cycle at ${binding.name}.` }; + if ( + context.capturePoint !== undefined && + !semanticBindingPrecedesCapture( + binding, + context.capturePoint, + context.state.parentsByNode, + ) + ) { + const { capturePoint: _capturePoint, ...liveContext } = context; + return evaluateBinding(binding, { + ...liveContext, + primitiveBindingValues: primitiveBindingValuesFor(context.state), + }); + } const cached = context.primitiveBindingValues.get(binding.bindingId); if (cached !== undefined) return cached; if (binding.initializers.length === 0) @@ -146,15 +189,41 @@ const evaluateBinding = ( if (initializer === undefined) return { status: "unknown", reason: "Missing binding initializer." }; const nested = nestedContext(context, binding.bindingId); - const value = projectValue( + let value = projectValue( evaluateExpression(initializer.node, nested), initializer.projection, ); - const projected = binding.mutatedPaths.reduce( + if (!isPrimitive(value)) { + const captureContext = primitiveCaptureContext(initializer.node, nested); + if (captureContext !== undefined) { + const captured = projectValue( + evaluateExpression(initializer.node, captureContext), + initializer.projection, + ); + if (isPrimitive(captured)) value = captured; + } + } + const mutated = binding.mutatedPaths.reduce( invalidateSemanticMutationPath, value, ); + const projected = binding.escapedPaths.reduce( + (value, escape) => + context.capturePoint !== undefined && + semanticEscapeFollowsCapture( + binding, + context.capturePoint, + escape.node, + context.state.parentsByNode, + ) + ? value + : invalidateSemanticEscapedPath(value, escape.path), + mutated, + ); + // Point-local memoization also bounds failed speculative captures. Only a + // primitive result is allowed back into ordinary value evaluation. if ( + context.capturePoint !== undefined || projected.status === "literal" || projected.status === "union" || (isSemanticResourceLimit(projected) && @@ -301,7 +370,7 @@ const evaluateObject = ( name, presence: "present", value: t.isObjectProperty(property) - ? evaluateExpression(property.value, nestedContext(context)) + ? evaluateCapturedExpression(property.value, nestedContext(context)) : { status: "unknown", reason: "Object method or accessor value is not a primitive.", @@ -351,7 +420,7 @@ const evaluateArray = ( items.push({ name: String(items.length), presence: "present", - value: evaluateExpression(element, nestedContext(context)), + value: evaluateCapturedExpression(element, nestedContext(context)), }); } return unknownItems @@ -648,9 +717,68 @@ const nestedContext = ( ): EvaluationContext => ({ state: context.state, primitiveBindingValues: context.primitiveBindingValues, + ...(context.capturePoint === undefined + ? {} + : { capturePoint: context.capturePoint }), expressionDepth: context.expressionDepth + 1, bindings: bindingId === undefined ? context.bindings : new Set([...context.bindings, bindingId]), }); + +const isPrimitive = (value: JavaScriptSemanticValue): boolean => + value.status === "literal" || value.status === "union"; + +const primitiveCaptureContext = ( + node: t.Node, + context: EvaluationContext, +): EvaluationContext | undefined => { + if ( + context.capturePoint !== undefined || + t.isObjectExpression(node) || + t.isArrayExpression(node) || + semanticCapturePosition(node, context.state.parentsByNode) === null + ) + return undefined; + let captures = capturedBindingValuesByState.get(context.state); + if (captures === undefined) { + captures = new WeakMap(); + capturedBindingValuesByState.set(context.state, captures); + } + let values = captures.get(node); + if (values === undefined) { + values = new Map(); + captures.set(node, values); + } + return { ...context, capturePoint: node, primitiveBindingValues: values }; +}; + +const evaluateCapturedExpression = ( + node: t.Node, + context: EvaluationContext, +): JavaScriptSemanticValue => { + // A literal field captures its primitive at construction, even when its + // containing object is subsequently read at another capture point. + let values = primitiveExpressionValuesByState.get(context.state); + const cached = values?.get(node); + if (cached !== undefined) return cached; + let value = evaluateExpression(node, context); + if (!isPrimitive(value)) { + const captureContext = primitiveCaptureContext(node, context); + if (captureContext !== undefined) { + const captured = evaluateExpression(node, captureContext); + if (isPrimitive(captured)) value = captured; + } + } + // Never publish values evaluated at somebody else's capture point. Like + // binding caches, these entries are discarded whenever effects change. + if (context.capturePoint === undefined && isPrimitive(value)) { + if (values === undefined) { + values = new WeakMap(); + primitiveExpressionValuesByState.set(context.state, values); + } + values.set(node, value); + } + return value; +}; diff --git a/src/server/registerApplicationTools/compareExportShapes.ts b/src/server/registerApplicationTools/compareExportShapes.ts index f8e26ae268..de811fd86b 100644 --- a/src/server/registerApplicationTools/compareExportShapes.ts +++ b/src/server/registerApplicationTools/compareExportShapes.ts @@ -41,7 +41,9 @@ export const registerCompareJavaScriptExportShapesTool = ( const comparison = javaScriptExportShapeComparisonResultSchema.parse( result.value.normalized_result, ); - const unknown = comparison.summary.unknown > 0; + const unknown = + comparison.summary.unknown > 0 || + comparison.coverage.status !== "complete-within-inputs"; return recordResult( { ...options, delivery: server.delivery }, contract, diff --git a/src/server/registerApplicationTools/helpers.ts b/src/server/registerApplicationTools/helpers.ts index 8fcbcee564..dd881887bd 100644 --- a/src/server/registerApplicationTools/helpers.ts +++ b/src/server/registerApplicationTools/helpers.ts @@ -31,12 +31,14 @@ const unknownRegistration = ( | "javascript-export-shape" | "source-to-bundle-comparison", ) => ({ - question: + // Scope unknown identity to the comparison while keeping exact replays stable. + question: `${ kind === "application-version-comparison" ? "Which application entities remain unmatched or ambiguous across these versions?" : kind === "source-to-bundle-comparison" ? "Which historical source files remain unmatched or ambiguously mapped to the shipped bundle?" - : "Which selected JavaScript export return shapes remain dynamic, incomplete, or ambiguously paired?", + : "Which selected JavaScript export return shapes remain dynamic, incomplete, or ambiguously paired?" + } (comparison ${evidence.evidence_id})`, severity: "medium" as const, domain: kind, supporting_evidence_ids: [evidence.evidence_id], diff --git a/tests/acceptance/applications/applicationWorkflowCli.test.ts b/tests/acceptance/applications/applicationWorkflowCli.test.ts index 3ea95f3af9..9abbd4f0db 100644 --- a/tests/acceptance/applications/applicationWorkflowCli.test.ts +++ b/tests/acceptance/applications/applicationWorkflowCli.test.ts @@ -457,7 +457,205 @@ describe("application workflow CLI input", () => { }); }); +describe("application workflow CLI copy boundaries", () => { + it("compares source values excluded from escaped rest and spread copies", async () => { + const root = await createTestTempDirectory("rea-copy-boundary-cli-"); + temporary.push(root); + const sources = [ + `export default function make() { + function mutate(value) { value.changed = true; } + const objectRest = { only: { value: "TOKEN" } }; + const { only, ...restObject } = objectRest; + mutate(restObject); + const arrayRest = [{ value: "TOKEN" }]; + const [head, ...restArray] = arrayRest; + mutate(restArray); + const objectSpread = { child: { value: "TOKEN" } }; + const spreadObject = { ...objectSpread, child: {} }; + mutate(spreadObject.child); + const arraySpread = [{ value: "TOKEN" }]; + const spreadArray = [{}, ...arraySpread]; + mutate(spreadArray[0]); + return { + kind: "copy-boundary", + objectRest: objectRest.only.value, + arrayRest: arrayRest[0].value, + objectSpread: objectSpread.child.value, + arraySpread: arraySpread[0].value, + }; + }`, + `export default function make() { + return { + kind: "copy-boundary", + objectRest: "UPDATED", arrayRest: "UPDATED", + objectSpread: "UPDATED", arraySpread: "UPDATED", + }; + }`, + ]; + const [left, right] = await analyzeCliSources(root, sources); + const inputPath = join(root, "comparison.json"); + await writeFile( + inputPath, + JSON.stringify({ + left, + right, + left_module_path: "parser.mjs", + left_export_name: "default", + right_module_path: "parser.mjs", + right_export_name: "default", + }), + ); + const compared = await runCli([ + "compare-javascript-export-shapes", + inputPath, + "--json", + ]); + expect(compared).toMatchObject({ + normalized_result: { + summary: { added: 0, removed: 0, changed: 4, unknown: 0 }, + coverage: { status: "complete-within-inputs" }, + changes: expect.arrayContaining( + ["/arrayRest", "/arraySpread", "/objectRest", "/objectSpread"].map( + (path) => + expect.objectContaining({ + status: "changed", + path, + left: { availability: "literal", value: "TOKEN" }, + right: { availability: "literal", value: "UPDATED" }, + }), + ), + ), + }, + }); + }, 20_000); +}); + +describe("application workflow CLI and MCP capture lifetimes", () => { + it("compares captured primitives and source values after aliases are replaced", async () => { + const root = await createTestTempDirectory("rea-capture-lifetime-cli-"); + temporary.push(root); + const fields = [ + "rebound", + "rest", + "snapshot", + "destructured", + "objectSnapshot", + "arraySnapshot", + ]; + const sources = [ + `export default function make() { + function mutate(value) { value.value = "MUTATED"; } + const original = {value: "TOKEN"}; + let alias = original; + alias = {}; + mutate(alias); + const child = {value: "TOKEN"}; + let {...rest} = {child}; + rest = {child: {}}; + rest.child.value = "MUTATED"; + const source = {value: "TOKEN"}; + const snapshot = source.value; + const {value: destructured} = source; + const objectSnapshot = {value: source.value}; + const arraySnapshot = [source.value]; + mutate(source); + return { + kind: "capture-boundary", + rebound: original.value, rest: child.value, + snapshot, destructured, + objectSnapshot: objectSnapshot.value, arraySnapshot: arraySnapshot[0], + }; + }`, + `export default function make() { return ${JSON.stringify({ + kind: "capture-boundary", + ...Object.fromEntries(fields.map((field) => [field, "UPDATED"])), + })}; }`, + ]; + const [left, right] = await analyzeCliSources(root, sources); + // Execute the authored fixtures independently of static analysis. + for (const [index, value] of ["TOKEN", "UPDATED"].entries()) { + const { stdout } = await execute(process.execPath, [ + "--input-type=module", + "--eval", + "const module = await import(process.argv[1]); console.log(JSON.stringify(module.default()));", + join(root, String(index), "parser.mjs"), + ]); + expect(JSON.parse(stdout)).toEqual({ + kind: "capture-boundary", + ...Object.fromEntries(fields.map((field) => [field, value])), + }); + } + const input = { + left, + right, + left_module_path: "parser.mjs", + left_export_name: "default", + right_module_path: "parser.mjs", + right_export_name: "default", + }; + const inputPath = join(root, "comparison.json"); + await writeFile(inputPath, JSON.stringify(input)); + const expected = { + normalized_result: { + summary: { added: 0, removed: 0, changed: 6, unknown: 0 }, + coverage: { status: "complete-within-inputs" }, + changes: expect.arrayContaining( + fields.map((field) => + expect.objectContaining({ + path: `/${field}`, + status: "changed", + left: { availability: "literal", value: "TOKEN" }, + right: { availability: "literal", value: "UPDATED" }, + }), + ), + ), + }, + }; + expect( + await runCli(["compare-javascript-export-shapes", inputPath, "--json"]), + ).toMatchObject(expected); + expect(await compareThroughStdioMcp(input)).toMatchObject(expected); + }, 20_000); +}); + describe("application workflow CLI export Evidence", () => { + it("preserves uncertainty after a helper can mutate an awaited return object", async () => { + const root = await createTestTempDirectory("rea-awaited-export-shape-cli-"); + temporary.push(root); + const sources = [ + `export default async function make() { + const result = { kind: "record" }; + function mutate(value) { value.extra = 1; } + mutate(await result); + return result; + }`, + 'export default async function make() { return { kind: "record", extra: 1 }; }', + ]; + const [left, right] = await analyzeCliSources(root, sources); + const compared = await runCli([ + "compare-javascript-export-shapes", + JSON.stringify({ + left, + right, + left_module_path: "parser.mjs", + left_export_name: "default", + right_module_path: "parser.mjs", + right_export_name: "default", + }), + "--json", + ]); + expect(compared).toMatchObject({ + operation: "compare_javascript_export_shapes", + normalized_result: { + summary: { added: 0, removed: 0, changed: 0 }, + coverage: { status: "partial" }, + changes: expect.arrayContaining([ + expect.objectContaining({ status: "unknown" }), + ]), + }, + }); + }, 20_000); + it("compares exact export shapes from file-backed Evidence", async () => { const root = await createTestTempDirectory("rea-export-shape-cli-"); temporary.push(root); @@ -647,6 +845,54 @@ describe("application workflow CLI validation", () => { }, 20_000); }); +const compareThroughStdioMcp = async ( + input: Record, +): Promise => { + const transport = new StdioClientTransport({ + command: process.execPath, + args: [resolve("scripts/rea.mjs"), "mcp"], + cwd: process.cwd(), + env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" }, + stderr: "pipe", + }); + const client = new Client({ + name: "javascript-capture-parity", + version: "1", + }); + try { + await client.connect(transport); + const response = await client.callTool({ + name: "compare_javascript_export_shapes", + arguments: input, + }); + expect(response.isError).not.toBe(true); + return response.structuredContent; + } finally { + try { + await client.close(); + } finally { + await transport.close(); + } + } +}; + +const analyzeCliSources = ( + root: string, + sources: readonly string[], +): Promise => + Promise.all( + sources.map(async (source, index) => { + const applicationRoot = join(root, String(index)); + await mkdir(applicationRoot); + await writeFile(join(applicationRoot, "parser.mjs"), source); + return runCli([ + "analyze-javascript-application", + applicationRoot, + "--json", + ]); + }), + ); + const runCli = async ( arguments_: readonly string[], environment: Readonly> = {}, diff --git a/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts b/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts index 0039be5c1a..ed429c35e9 100644 --- a/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts +++ b/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts @@ -348,6 +348,64 @@ describe("JavaScript export return-shape uncertain presence", () => { }); describe("JavaScript export matching uncertain projections", () => { + it.each([ + ["Object.assign", "Object.assign(result, { extra: 1 });"], + ["local helper", "mutate(result);"], + ["alias", "const alias = result; mutate(alias);"], + ["destructuring", "const { value } = { value: result }; mutate(value);"], + ["shared wrapper", "mutate({ value: result });"], + ["spread arguments", "mutate(...[result]);"], + ["optional call", "mutate?.(result);"], + ["constructor", "new Mutator(result);"], + ["tagged template", "tag`${result}`;"], + ])( + "does not infer an addition after %s can mutate the return value", + async (_description, invocation) => { + const graphs = await analyzeSources({ + left: ` + function mutate(value) { (value.value ?? value).extra = 1; } + function Mutator(value) { value.extra = 1; } + function tag(_strings, value) { value.extra = 1; } + export default function make() { + const result = { kind: "record" }; + ${invocation} + return result; + } + `, + right: 'export default () => ({ kind: "record", extra: 1 });', + }); + const result = compare(...graphs); + expect(result.summary).toMatchObject({ + added: 0, + removed: 0, + changed: 0, + }); + expect(result.coverage.status).toBe("partial"); + expect(result.changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ status: "unknown" }), + ]), + ); + }, + ); + + it("keeps sibling properties known when a method can mutate its receiver", async () => { + const graphs = await analyzeSources({ + left: `export default function make() { + const result = { kind: "record", tokens: [] }; + result.tokens.push({ extra: 1 }); + return result; + }`, + right: + 'export default () => ({ kind: "record", tokens: [{ extra: 1 }] });', + }); + const result = compare(...graphs); + expect(result.summary).toMatchObject({ added: 0, removed: 0, changed: 0 }); + expect(result.coverage.status).toBe("partial"); + for (const inventory of result.property_inventories) + expect(inventory.properties).toContain("/kind"); + }); + it.each(["delete result.count;", "result.count = query();"])( "omits matching uncertain projections after %s", async (mutation) => { diff --git a/tests/boundary/mcp/applicationExportShapeMcp.test.ts b/tests/boundary/mcp/applicationExportShapeMcp.test.ts index 8ba6bc0bf5..e336e3f749 100644 --- a/tests/boundary/mcp/applicationExportShapeMcp.test.ts +++ b/tests/boundary/mcp/applicationExportShapeMcp.test.ts @@ -12,6 +12,7 @@ import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/j import { javaScriptExportShapeComparisonResultSchema } from "../../../src/domain/javascript/javascriptExportShapeComparisonSchemas.js"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; import { APPLICATION_TOOL_CONTRACTS } from "../../../src/contracts/applicationToolContracts.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; import { JAVASCRIPT_EXPORT_SHAPE_COMPARISON_EXAMPLE } from "../../../src/contracts/javascript/javascriptExportShapeComparisonExample.js"; it("executes the advertised export presence example with matching analyzed Evidence", async () => { @@ -253,7 +254,161 @@ describe("application workflow MCP parity", () => { }); }); +describe("repeated partial export comparisons", () => { + it.each(["inline", "retained"] as const)( + "retains distinct partial comparisons and idempotent concurrent repeats with %s Evidence", + async (mode) => { + const pairs = await Promise.all( + Array.from({ length: 4 }, async (_, index) => { + const source = `export default function make() { + const result = { kind: "record-${index}", count: 1 }; + delete result.count; + return result; + } + export { make as named };`; + const [left, right] = await Promise.all([ + analyzeSourceEvidence(source), + analyzeSourceEvidence(source), + ]); + return { left, right }; + }), + ); + const { client, session, close } = await createApplicationMcpHarness(); + onTestFinished(close); + if (mode === "retained") + for (const { left, right } of pairs) + for (const evidence of [left, right]) + expect(session.recordEvidence(evidence).ok).toBe(true); + const compare = async ( + pair: (typeof pairs)[number], + exportName = "default", + ) => { + const response = await client.callTool({ + name: "compare_javascript_export_shapes", + arguments: { + left: + mode === "inline" + ? pair.left + : { + kind: "retained-evidence", + evidence_id: pair.left.evidence_id, + }, + right: + mode === "inline" + ? pair.right + : { + kind: "retained-evidence", + evidence_id: pair.right.evidence_id, + }, + left_module_path: "parser.mjs", + left_export_name: exportName, + right_module_path: "parser.mjs", + right_export_name: exportName, + }, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const evidence = parseEvidence(response.structuredContent); + expect(evidence.normalized_result).toMatchObject({ + changes: [], + coverage: { status: "partial" }, + }); + return evidence.evidence_id; + }; + const [firstPair, secondPair, ...remaining] = pairs; + if (firstPair === undefined || secondPair === undefined) + throw new Error("Expected distinct comparison fixtures"); + const firstId = await compare(firstPair); + const firstUnknowns = session.listUnknowns({ + domain: "javascript-export-shape", + }); + expect(firstUnknowns).toHaveLength(1); + const secondId = await compare(secondPair); + expect(secondId).not.toBe(firstId); + const selectedId = await compare(firstPair, "named"); + expect(selectedId).not.toBe(firstId); + const concurrentIds = await Promise.all( + [firstPair, secondPair, ...remaining, ...remaining].map((pair) => + compare(pair), + ), + ); + expect(concurrentIds.slice(0, 2)).toEqual([firstId, secondId]); + const evidenceIds = new Set([ + firstId, + secondId, + selectedId, + ...concurrentIds, + ]); + expect(evidenceIds.size).toBe(pairs.length + 1); + const unknowns = session.listUnknowns({ + domain: "javascript-export-shape", + }); + expect(unknowns).toHaveLength(pairs.length + 1); + expect(unknowns).toEqual(expect.arrayContaining(firstUnknowns)); + expect( + new Set( + unknowns.flatMap( + ({ supporting_evidence_ids }) => supporting_evidence_ids, + ), + ), + ).toEqual(evidenceIds); + expect(unknowns.every(({ revision }) => revision === 1)).toBe(true); + const records = session.exportEvidenceBundle().records; + for (const id of evidenceIds) + expect(records.some(({ evidence_id }) => evidence_id === id)).toBe( + true, + ); + const retained = session.exportEvidenceBundle(); + await Promise.all(pairs.map((pair) => compare(pair))); + expect(session.exportEvidenceBundle()).toEqual(retained); + }, + ); +}); + describe("source-produced export comparison inventories", () => { + it("records partial comparison coverage even when matching unknown projections produce no changes", async () => { + const source = `export default function make() { + const result = { kind: "record", count: 1 }; + delete result.count; + return result; + }`; + const [left, right] = await Promise.all([ + analyzeSourceEvidence(source), + analyzeSourceEvidence(source), + ]); + const { client, session, close } = await createApplicationMcpHarness(); + onTestFinished(close); + const response = await client.callTool({ + name: "compare_javascript_export_shapes", + arguments: { + left, + right, + left_module_path: "parser.mjs", + left_export_name: "default", + right_module_path: "parser.mjs", + right_export_name: "default", + }, + }); + expect(response.isError).not.toBe(true); + expect(response.structuredContent).toMatchObject({ + normalized_result: { + changes: [], + summary: { added: 0, removed: 0, changed: 0, unknown: 0 }, + coverage: { status: "partial" }, + }, + }); + const { evidence_id: evidenceId } = z + .object({ evidence_id: z.string() }) + .parse(response.structuredContent); + expect(session.exportEvidenceBundle().unknowns).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + domain: "javascript-export-shape", + supporting_evidence_ids: [evidenceId], + }), + ]), + ); + }); + it("retains large source-produced export inventories through comparison schemas", async () => { const fields = Array.from( { length: 64 },