diff --git a/src/domain/javascript/javascriptSemanticProjectedSources.ts b/src/domain/javascript/javascriptSemanticProjectedSources.ts index 8345f85d85..7f48a69548 100644 --- a/src/domain/javascript/javascriptSemanticProjectedSources.ts +++ b/src/domain/javascript/javascriptSemanticProjectedSources.ts @@ -363,6 +363,27 @@ const projectedSourceFactsAtRead = ( ) return undefined; const sources: ProjectedSource[] = []; + const sourceKeys = new WeakMap>(); + const captureIds = new WeakMap(); + let nextCaptureId = 0; + const appendSource = (source: ProjectedSource): void => { + const captures = semanticNormalizeCapturedSelections( + source.capturedSelections, + )?.map(({ at, depth, ownEnumerable }) => { + let id = captureIds.get(at); + if (id === undefined) { + id = nextCaptureId++; + captureIds.set(at, id); + } + return [id, depth, ownEnumerable]; + }); + const key = JSON.stringify([source.path, captures]); + const keys = sourceKeys.get(source.node) ?? new Set(); + if (keys.has(key)) return; + keys.add(key); + sourceKeys.set(source.node, keys); + sources.push(source); + }; for (const effect of writes) { if (!retainEligibility && superseded(effect)) continue; if ( @@ -389,7 +410,7 @@ const projectedSourceFactsAtRead = ( choice === undefined ? [] : [choice], ); if (selection !== null) - sources.push({ + appendSource({ node: source.node, path: [...selection.projection, ...suffix], ...(selection.capturedSelectionDepth === undefined @@ -415,7 +436,7 @@ const projectedSourceFactsAtRead = ( for (const initializer of read.binding.initializers) { if (semanticReferenceIterationUnknown(initializer, state)) return undefined; if (semanticReferenceSourcesInactive(initializer, state)) continue; - sources.push({ + appendSource({ node: initializer.node, path: [...initializer.projection, ...path], }); @@ -433,7 +454,7 @@ const projectedSourceFactsAtRead = ( "identity", ); if (projection !== null) - sources.push({ + appendSource({ node: initializer.node, path: [...projection, ...suffix], capturedSelections: diff --git a/src/domain/javascript/javascriptSemanticSharedChildWrites.test.ts b/src/domain/javascript/javascriptSemanticSharedChildWrites.test.ts index d397871bfb..671286fb61 100644 --- a/src/domain/javascript/javascriptSemanticSharedChildWrites.test.ts +++ b/src/domain/javascript/javascriptSemanticSharedChildWrites.test.ts @@ -1,3 +1,4 @@ +import { createContext, runInContext, runInNewContext } from "node:vm"; import { expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; @@ -120,15 +121,34 @@ it.each( .replaceAll("SLOT", value.slot) .replaceAll("READ", value.read), expression: shape.read.replaceAll("READ", value.read), - // The public module/delegate owner retains unknown after the copy's child - // escapes into the new parent. Factory result selection can exclude it. - expected: shared ? !value.host || value.slot !== "make" : value.host, + // The new local parent shares the same descendant. Its own slot write + // replaces the host value; an independent descendant keeps the old one. + expected: shared ? !value.host : value.host, + localCall: value.slot === "load", })), ), ), )( "distinguishes shared and newly allocated descendant receivers: $source", - ({ source, expression, expected }) => { + ({ source, expression, expected, localCall }) => { + const hostCalls: string[] = []; + const module = { + require: (specifier: string) => { + hostCalls.push(specifier); + return {}; + }, + }; + const context = createContext({ module }); + expect(source.endsWith(expression + ";")).toBe(true); + runInContext(source.slice(0, -expression.length - 1), context); + if (expected || localCall) { + runInContext(expression, context); + } else { + expect(() => runInContext(expression, context)).toThrow( + expect.objectContaining({ name: "TypeError" }), + ); + } + expect(hostCalls).toEqual(expected ? ["./shared.cjs"] : []); const result = analyzeJavaScriptStaticSource(source); expect(result.parse_error_count).toBe(0); expect( @@ -143,6 +163,54 @@ it.each( }, ); +it.each([ + { + kind: "external call", + iterator: false, + source: + "const child={loader:module};const box={child};external(child);box.child.loader.require('./uncertain.cjs');", + }, + { + kind: "custom iterator", + iterator: true, + source: + "const child={loader:module};const box={child};const values=[child];values[Symbol.iterator]=external;for(const selected of values)selected.loader={};box.child.loader.require('./uncertain.cjs');", + }, +])( + "retains host eligibility across an opaque shared receiver: $kind", + ({ source, iterator }) => { + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.references).toContainEqual( + expect.objectContaining({ kind: "require", specifier: null }), + ); + // Both executions fit the same unknown input: one retains the host loader, + // the other replaces it. Neither outcome can prove the static alternative absent. + for (const preserveHost of [true, false]) { + const calls: string[] = []; + const module = { + require: (specifier: string) => { + calls.push(specifier); + return {}; + }, + }; + const external = iterator + ? function* (this: readonly { loader: unknown }[]) { + yield preserveHost ? { loader: {} } : this[0]!; + } + : (child: { loader: unknown }) => { + if (!preserveHost) child.loader = {}; + }; + try { + runInNewContext(source, { module, external }); + } catch (error) { + expect(error).toMatchObject({ name: "TypeError" }); + expect(preserveHost).toBe(false); + } + expect(calls).toEqual(preserveHost ? ["./uncertain.cjs"] : []); + } + }, +); + it.each(["module", "{}"])( "selects the later write to a shared projected receiver: %s", (next) => { diff --git a/tests/acceptance/applications/javascriptBuiltinDefaultsCli.test.ts b/tests/acceptance/applications/javascriptBuiltinDefaultsCli.test.ts index 36cb537aee..96a18ae8cd 100644 --- a/tests/acceptance/applications/javascriptBuiltinDefaultsCli.test.ts +++ b/tests/acceptance/applications/javascriptBuiltinDefaultsCli.test.ts @@ -53,7 +53,7 @@ it("preserves earlier and sibling effects without inventing replaced cached-modu require('node:fs').readFileSync('wrong-direct-loader.json');`, ); await writeFile( - join(root, "imports.ts"), + join(root, "imports.cts"), `import fs = require('node:fs'); const other = require('fs'); const read = other.readFileSync; observe(read); read('escaped.json'); fs.readFileSync = custom; other.readFileSync('wrong-import-equals.json');`, @@ -65,7 +65,7 @@ it("preserves earlier and sibling effects without inventing replaced cached-modu require('fs').readFileSync('wrong-late-loader.json');`, ); await writeFile( - join(root, "replacement.ts"), + join(root, "replacement.cts"), `import fs = require('fs'); const other=require('fs'); fs={}; fs.readFileSync=custom; other.readFileSync('replacement-kept.json');`, ); await writeFile(