diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts index f2deb2ba51..771fe5887b 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts @@ -1,3 +1,4 @@ +import * as t from "@babel/types"; import { expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; @@ -128,3 +129,114 @@ box.trigger;`; ).toEqual(["box.trigger", "box.trigger"]); expect(effects[0]?.node).not.toBe(effects[1]?.node); }); + +const globalFetchWriteSites = (body: string): string[] => { + const source = `var t={},k=[];${body}`; + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected stored-slot fixture"); + const state = completeSemanticSteps( + indexJavaScriptBindingsSteps(parsed.program), + ); + return state.ambientWriteEffects + .filter( + ({ path }) => + JSON.stringify(path) === JSON.stringify(["global", "fetch"]), + ) + .map(({ node }) => + node === undefined ? "" : source.slice(node.start ?? 0, node.end ?? 0), + ); +}; + +it.each([ + { + name: "an object read from a replaced slot", + body: "var g={};t[k[0]]=g;t[k[1]]=window;g.fetch=replacement;", + sites: [], + }, + { + name: "a literal property value whose slot is replaced", + body: "var o={};var h={p:o};h.p=window;o.fetch=replacement;h.p.fetch=other;", + sites: ["h.p.fetch=other"], + }, + { + name: "a global read back through an unknown slot", + body: "t[k[0]]=window;t[k[1]].fetch=replacement;", + sites: ["t[k[1]].fetch=replacement"], + }, + { + name: "a global read into an alias of an unknown slot", + body: "t[k[0]]=window;var r=t[k[1]];r.fetch=replacement;", + sites: ["r.fetch=replacement"], + }, + { + name: "a nested store through an unknown slot", + body: "t[k[0]]={};t[k[1]].x=window;t[k[2]].x.fetch=replacement;", + sites: ["t[k[2]].x.fetch=replacement"], + }, + { + name: "a store through an instance prototype", + body: "function F(){}var x=new F();x.__proto__.slot=window;F.prototype.slot.fetch=replacement;", + sites: ["F.prototype.slot.fetch=replacement"], + }, + { + name: "a store through an instance constructor", + body: "function F(){}var x=new F();x.constructor.slot=window;F.slot.fetch=replacement;", + sites: ["F.slot.fetch=replacement"], + }, + { + name: "a store into a rest copy", + body: "var src={};var {...copy}=src;copy.slot=window;copy.slot.fetch=replacement;", + sites: ["copy.slot.fetch=replacement"], + }, +])("resolves stored references for $name", ({ body, sites }) => { + expect(globalFetchWriteSites(body)).toEqual(sites); +}); + +it("keeps an object read from a replaced slot as a URL base proof", () => { + const worker = + analyzeJavaScriptStaticSource(`import { Worker } from "node:worker_threads"; +const t={},k=[];const g={};t[k[0]]=g;t[k[1]]=globalThis;g.URL=replacement; +new Worker(new URL("./worker.js", import.meta.url));`).references.find( + (reference) => reference.kind === "worker", + ); + expect(worker?.specifier).toBe("./worker.js"); +}); + +it("indexes unknown-key stores of fresh objects in linear stored-value work", () => { + // Count reads of the stored values' own syntax during ambient indexing only. + const work = (count: number): number => { + const source = `var t={},k=[];${Array.from({ length: count }, (_, i) => `t[k[${String(i)}]]={v:${String(i)}};`).join("")}`; + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected stored-object fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + let counting = false; + let reads = 0; + for (const statement of parsed.program.body) { + if ( + !t.isExpressionStatement(statement) || + !t.isAssignmentExpression(statement.expression) + ) + continue; + const value = statement.expression.right; + const type = value.type; + Object.defineProperty(value, "type", { + enumerable: true, + configurable: true, + get: () => { + if (counting) reads += 1; + return type; + }, + }); + } + counting = true; + completeSemanticSteps(index.indexAmbientWritesSteps()); + counting = false; + expect(index.bindings.ambientWriteEffects).toEqual([]); + return reads; + }; + // Each stored value is examined a bounded number of times. Re-reading every + // earlier value from every later store grows at least quadratically. + expect(work(64)).toBeLessThan(3 * work(32)); +}); diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index fbf1efd884..4a265a2cc4 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -83,6 +83,40 @@ export const retainSemanticAmbientEffect = ( /** Undefined means a cyclic or conflicting projection can select any depth. */ type AmbientProjection = AmbientPath | undefined; +/** + * Select the stored value an object's reference store exposes to a projection, + * or null when the store cannot reach it. A shorter projection only reaches + * stored children when the query covers escaped contents. + */ +const storedValueProjection = ( + suffix: AmbientProjection, + source: SemanticIterationSource, + path: AmbientProjection, + escapedContents: boolean, +): AmbientProjection | null => { + if ( + suffix !== undefined && + path !== undefined && + (!path.every( + (key, index) => + index >= suffix.length || + key === null || + suffix[index] === null || + key === suffix[index], + ) || + (suffix.length < path.length && !escapedContents)) + ) + return null; + return suffix === undefined || path === undefined + ? undefined + : [ + ...source.projection.map((key) => + key === null || typeof key === "object" ? null : String(key), + ), + ...suffix.slice(path.length), + ]; +}; + const joinAmbientProjection = ( left: AmbientProjection, right: AmbientProjection, @@ -129,7 +163,7 @@ export const createAmbientPathResolver = ( capture?: t.Node, escapedContents = false, projection: AmbientProjection = [], - localIdentity?: (node: t.Node, path: AmbientProjection) => void, + receiver?: (node: t.Node, path: AmbientProjection) => void, ): AmbientResolution => { const prior = possibleWrites ? undefined : cached.get(node); if (prior !== undefined) return prior; @@ -138,11 +172,20 @@ export const createAmbientPathResolver = ( const unknownReasons = new Set(); // The caller's write site selects initializers for the written target. // Reads reached through an initializer occur at their own position. - const pending: { + type Pending = { node: t.Node; suffix: AmbientProjection; readsAtSelf?: true; - }[] = [{ node, suffix: projection }]; + }; + const pending: Pending[] = [{ node, suffix: projection }]; + // A receiver query reports the objects a write to `node` mutates, as + // projections that still end at the written key. Once a step consumes that + // key, the item denotes the value read from the written slot. Replacing + // the slot does not mutate that value or the containers it was read from. + const queue = (item: Pending): void => { + if (receiver !== undefined && item.suffix?.length === 0) return; + pending.push(item); + }; // Join projections at their producing expression, rather than enumerating // alias histories. Each key can only widen to a wildcard, and conflicting // depths widen to unknown, so diamonds and cycles have finite state. @@ -213,7 +256,7 @@ export const createAmbientPathResolver = ( // A widened projection is a possible write, never proof of an intrinsic. if (suffix === undefined && !possibleWrites) continue; const continueWith = (node: t.Node, path: AmbientProjection = suffix) => - pending.push({ + queue({ node, suffix: path, ...(current.readsAtSelf && { readsAtSelf: true }), @@ -239,50 +282,21 @@ export const createAmbientPathResolver = ( ); }; if (!t.isIdentifier(root) && facts?.freshObject(root)) { - localIdentity?.(root, suffix); + receiver?.(root, suffix); // The allocation owns itself; a projected child needs its own proof. if (suffix?.length === 0) { hasLocalIdentity = true; resolvedIdentity = true; } for (const [source, path] of facts?.stores.get(root) ?? []) { - const selected = - suffix === undefined || - path === undefined || - path.every( - (key, index) => - index >= suffix.length || - key === null || - suffix[index] === null || - key === suffix[index], - ); - if ( - !selected || - (suffix !== undefined && - path !== undefined && - suffix.length < path.length && - !escapedContents) - ) - continue; - const remaining = - suffix === undefined || path === undefined - ? undefined - : suffix.slice(path.length); - pending.push({ - node: source.node, - readsAtSelf: true, - suffix: - remaining === undefined - ? undefined - : [ - ...source.projection.map((key) => - key === null || typeof key === "object" - ? null - : String(key), - ), - ...remaining, - ], - }); + const stored = storedValueProjection( + suffix, + source, + path, + escapedContents, + ); + if (stored === null) continue; + queue({ node: source.node, readsAtSelf: true, suffix: stored }); } } if ( @@ -340,7 +354,7 @@ export const createAmbientPathResolver = ( escapedContents ? "escape" : "identity", ); if (path === null) continue; - pending.push({ + queue({ node: initializer.node, readsAtSelf: true, suffix: @@ -667,17 +681,19 @@ export function* indexJavaScriptAmbientWritesSteps( }, ); const pendingStores = new Set(storedReferences); - const dependents = new WeakMap>(); + // Each dependent keeps the joined projection its latest query reached. + const dependents = new WeakMap< + t.Node, + Map + >(); while (pendingStores.size > 0) { const source = pendingStores.values().next().value; if (source === undefined) break; pendingStores.delete(source); if (source.target === undefined) continue; const identities = new Map(); + // Record the objects this store mutates, not the values it replaces. ambientWrites(source.target, source.mutation, false, [], (node, path) => { - const users = dependents.get(node) ?? new Set(); - users.add(source); - dependents.set(node, users); identities.set( node, identities.has(node) @@ -685,6 +701,13 @@ export function* indexJavaScriptAmbientWritesSteps( : path, ); }); + for (const [node, path] of identities) { + const users = + dependents.get(node) ?? + new Map(); + users.set(source, path); + dependents.set(node, users); + } // Publish only completed queries; each source/root projection can only widen. for (const [node, path] of identities) { const entries = @@ -703,8 +726,13 @@ export function* indexJavaScriptAmbientWritesSteps( continue; entries.set(source, joined); stores.set(node, entries); - for (const dependent of dependents.get(node) ?? []) - pendingStores.add(dependent); + // A receiver query drops a value read from its written slot, so only a + // nonempty or widened selection can change a dependent's identities. + for (const [dependent, reached] of dependents.get(node) ?? []) { + const stored = storedValueProjection(reached, source, joined, false); + if (stored !== null && stored?.length !== 0) + pendingStores.add(dependent); + } } yield; }