From 653067d799591ce2b8fdc84e99c160ac17e2860f Mon Sep 17 00:00:00 2001 From: Yann Roberto <165009858+shoyann@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:24:45 +0800 Subject: [PATCH] fix(javascript): resolve module-relative Worker URL references (#2104) * fix(javascript): resolve module-relative Worker URL references * fix(javascript): validate Worker URL paths independently of suffixes * fix(javascript): preserve directory-form Worker URL paths * fix(javascript): unwrap type assertions in Worker URL references * fix(javascript): validate Worker bindings before URL resolution * fix(javascript): leave drive-letter Worker URLs unresolved Use WHATWG file URL parsing before pathname decoding to detect drive-letter forms whose resource identity depends on the unmodeled source URL scheme. Preserve encoded and ordinary relative pipe paths, and cover false matches through the compiled CLI and resolver boundary tests. * fix(javascript): preserve Worker URL identity boundaries Recognize known Worker bindings before filtering callee spellings so ESM aliases and transparent constructor wrappers reach URL analysis. Keep root-relative module URLs unknown without a modeled runtime base root. Index direct source-local global writes with the shared mutation collector before promoting URL and browser Worker constructors. Preserve lexical shadowing and unrelated property identities, and exercise all three review regressions through the compiled CLI. * fix(javascript): invalidate mutated import.meta URL bases Track source-local import.meta writes alongside ambient global writes so Worker URL expressions keep an unknown base after a possible URL mutation. Tag the identity roots independently and cover direct mutation and unrelated property cases through the compiled CLI. * fix(javascript): close Worker URL identity and pathname gaps Resolve ambient object identities before appending written properties, and retain possible source-local aliases across rebinding. Reuse intrinsic invocation handling for aliased and bound mutators, array spreads, and transparent TypeScript wrappers while preserving Electron write semantics. Keep malformed-percent nested Worker URL pathnames unknown because the base scheme is unavailable and Node rejects these file URLs. Preserve tolerant HTML URL handling, valid escapes, and query/fragment evidence. Cover the new cases and unrelated writes through the compiled CLI with existing-file decoys. All 591 targeted tests and npm run check pass. * fix(javascript): bound alias expansion and classify Worker paths Join ambient identity projections instead of enumerating alias histories. Memoize repeated value and provenance queries while preserving capture, depth, and cycle-dependent uncertainty. Keep Node Worker string arguments unresolved without filesystem or cwd context, and admit qualified global URL constructors through identity checks. Validate three 40-level alias branch shapes through the compiled CLI, 925 source tests, 25 CLI/boundary tests, and the full npm run check gate. * fix(server): preserve partial Evidence target-content provenance Pass the exact named contract returnsTargetContent flag through the shared partial-Evidence error delivery path introduced by the shortlist integration. Exercise successful and failed recording through in-memory MCP for marked and unmarked contracts, preserving source payload and original failures. Observe both shared-handler emission and wire delivery so the SDK error-marker fallback cannot hide omissions. Suppressing the flag fails exactly the two marked cases. Validation: check:fast, check, 736 semantic tests, 29 MCP transport/error tests and six compiled static Worker CLI journeys pass. No live target/provider execution or hosted publication. * fix(javascript): structurally share semantic dependency proofs --------- Co-authored-by: baron --- .../JavaScriptArtifactGraphFindings.ts | 43 +- .../JavaScriptArtifactPathResolution.test.ts | 128 ++++ .../JavaScriptArtifactPathResolution.ts | 80 ++- .../javascriptElectronMemberWrites.ts | 43 +- .../javascriptIntrinsicInvocation.ts | 50 +- .../javascript/javascriptModuleInterop.ts | 110 +-- .../javascriptSemanticAmbientWrites.test.ts | 57 ++ .../javascriptSemanticAmbientWrites.ts | 301 +++++++++ .../javascript/javascriptSemanticAnalysis.ts | 26 +- .../javascriptSemanticDependencies.ts | 81 +++ .../javascriptSemanticMemberMutations.ts | 6 +- ...scriptSemanticMutationInitializers.test.ts | 1 + .../javascript/javascriptSemanticState.ts | 9 +- .../javascriptSemanticValues.memo.test.ts | 227 +++++++ .../javascript/javascriptSemanticValues.ts | 195 +++++- .../javascript/javascriptStaticAnalysis.ts | 7 +- .../javascriptStaticAnalysisCalls.ts | 102 ++- .../javascriptStaticAnalysisHelpers.ts | 25 +- .../javascriptWorkerUrlCli.test.ts | 626 ++++++++++++++++++ 19 files changed, 1946 insertions(+), 171 deletions(-) create mode 100644 src/domain/javascript/javascriptSemanticAmbientWrites.test.ts create mode 100644 src/domain/javascript/javascriptSemanticAmbientWrites.ts create mode 100644 src/domain/javascript/javascriptSemanticDependencies.ts create mode 100644 src/domain/javascript/javascriptSemanticValues.memo.test.ts create mode 100644 tests/acceptance/applications/javascriptWorkerUrlCli.test.ts diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index 95dbab319..3e92ff1a4 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -376,10 +376,14 @@ const resolveReference = ( ): ResolvedReference | null => { const specifier = reference.specifier; if (specifier === null) return null; - const chunk = resolveBundlerChunkReference(context, sourcePath, reference); + const isWorker = + reference.kind === "worker" || reference.kind === "service-worker"; + const chunk = isWorker + ? null + : resolveBundlerChunkReference(context, sourcePath, reference); if (chunk !== null) return chunk; const module = - reference.module_runtime === undefined + isWorker || reference.module_runtime === undefined ? undefined : context.moduleNodes.get( moduleLookupKey(sourcePath, reference.module_runtime, specifier), @@ -391,24 +395,23 @@ const resolveReference = ( : { path: `${sourcePath}#module:${specifier}`, file, node: module }; } // Worker scripts are URLs relative to the script, not module specifiers. - const path = - reference.kind === "worker" || reference.kind === "service-worker" - ? resolveArtifactPathByContext({ - declaredPath: specifier, - sourcePath, - context: "url-reference", - files: context.filesByPath, - }).resolved_path - : resolveArtifactPath( - specifier, - sourcePath, - context.filesByPath, - reference.kind === "require" || - reference.kind === "static-import" || - reference.kind === "dynamic-import" - ? moduleKindForJavaScriptReference(sourcePath, reference.kind) - : undefined, - ); + const path = isWorker + ? resolveArtifactPathByContext({ + declaredPath: specifier, + sourcePath, + context: "url-reference", + files: context.filesByPath, + }).resolved_path + : resolveArtifactPath( + specifier, + sourcePath, + context.filesByPath, + reference.kind === "require" || + reference.kind === "static-import" || + reference.kind === "dynamic-import" + ? moduleKindForJavaScriptReference(sourcePath, reference.kind) + : undefined, + ); if (path === null) return null; const file = context.filesByPath.get(path); const node = context.fileNodes.get(path); diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index e44d2666a..174ef8308 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -1979,15 +1979,143 @@ describe("TypeScript NodeNext emitted-extension resolution", () => { }); }); +describe("url-reference directory and empty-segment semantics", () => { + it.each([ + "./worker.js/.", + "./worker.js/child/..", + "./worker.js/", + "/dist/worker.js/.", + "./worker.js/.?cache=1#entry", + ])("keeps directory-form URLs distinct from files: %s", (declaredPath) => { + expect( + resolve({ + declaredPath, + sourcePath: "dist/app.mjs", + context: "url-reference", + files: fileMap([file("dist/worker.js", "root")]), + }), + ).toMatchObject({ + declared_path: declaredPath, + resolution_status: "not-found", + resolved_path: null, + }); + }); + + it.each(["./nested//worker.js", "./nested//../worker.js"])( + "does not collapse empty URL path segments: %s", + (declaredPath) => { + expect( + resolve({ + declaredPath, + sourcePath: "dist/app.mjs", + context: "url-reference", + files: fileMap([ + file("dist/worker.js", "root"), + file("dist/nested/worker.js", "root"), + ]), + }), + ).toMatchObject({ resolved_path: null }); + }, + ); + + it.each([ + ["./nested/.././worker.js?cache=1#entry", "dist/worker.js"], + ["./.worker.js", "dist/.worker.js"], + ])("resolves exact file URLs with dots: %s", (declaredPath, resolvedPath) => { + expect( + resolve({ + declaredPath, + sourcePath: "dist/app.mjs", + context: "url-reference", + files: fileMap([file(resolvedPath, "root")]), + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: resolvedPath, + }); + }); +}); + describe("url-reference JavaScript artifact path resolution", () => { it.each([ + ["C|?cache=1#entry", "dist/C|"], + ["/c:/worker.js", "c:/worker.js"], + ["/./c|/worker.js", "c|/worker.js"], + ["../c|/worker.js", "c|/worker.js"], + ["/nested/../c|/worker.js", "c|/worker.js"], + ["c|/../worker.js", "dist/worker.js"], + ])("leaves drive-letter URL paths unresolved: %s", (declaredPath, decoy) => { + const resolution = resolve({ + declaredPath, + sourcePath: "dist/app.mjs", + context: "url-reference", + files: fileMap([file(decoy, "root")]), + }); + expect(resolution).toMatchObject({ + declared_path: declaredPath, + resolution_status: "unavailable", + resolved_path: null, + }); + expect(resolution.limitations.length).toBeGreaterThan(0); + }); + + it.each([ + ["./c|/worker.js", "dist/c|/worker.js"], + ["c|name/worker.js", "dist/c|name/worker.js"], + ["/%63|/worker.js", "c|/worker.js"], + ["/c%3A/worker.js", "c:/worker.js"], + ])( + "retains non-drive URL path identities: %s", + (declaredPath, resolvedPath) => { + expect( + resolve({ + declaredPath, + sourcePath: "dist/app.mjs", + context: "url-reference", + files: fileMap([file(resolvedPath, "root")]), + }), + ).toMatchObject({ + declared_path: declaredPath, + resolution_status: "resolved", + resolved_path: resolvedPath, + }); + }, + ); + + it.each(["?value=", "#value="])( + "admits the pathname independently of its %s suffix", + (suffix) => { + for (const value of ["%2F", "%2E", "%5C", "%00", "%FF", "\\", "\0"]) { + const declaredPath = `./worker.js${suffix}${value}`; + expect( + resolve({ + declaredPath, + sourcePath: "dist/app.js", + context: "url-reference", + files: fileMap([file("dist/worker.js", "root")]), + }), + declaredPath, + ).toMatchObject({ + declared_path: declaredPath, + resolution_status: "resolved", + resolved_path: "dist/worker.js", + }); + } + }, + ); + + it.each([ + "app\0.js.map?valid=1", + "nested\\worker.js#valid", "app%00.js.map", "app%FF.js.map", "app%C0%AF.js.map", "app%2ejs.map", "nested%2fworker.js", "nested%5cworker.js", + "h\nttp://[", "../../outside.js.map", + "../../outside/.", ])("rejects an inadmissible URL path: %s", (declaredPath) => { const resolution = resolve({ declaredPath, diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index f8530f7f0..1096a177f 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -153,17 +153,15 @@ const resolvePath = (input: ResolveArtifactPathInput): CandidateResolution => { input.declaredPath.startsWith("#") ) return resolvePackageImportAlias(input, input.declaredPath); - const rejected = rejectDeclaration(input.declaredPath); - if (rejected !== null) return rejected; const candidate = contextualCandidate(input); if (typeof candidate !== "string") return candidate; const confined = confineCandidate(candidate); if (typeof confined !== "string") return confined; - if (input.context === "html-reference") + if (input.context === "html-reference" || input.context === "url-reference") return ( resolveFileCandidates(input, [confined]) ?? unresolvedCandidate("not-found", [ - `The exact HTML resource ${confined} was not found among the selected application files.`, + `The exact ${input.context === "html-reference" ? "HTML" : "URL"} resource ${confined} was not found among the selected application files.`, ]) ); const isModuleUrlPath = @@ -201,10 +199,14 @@ const contextualCandidate = ( input: ResolveArtifactPathInput, ): string | CandidateResolution => { const { context } = input; + // URL suffixes are evidence, not pathname bytes. Admit only the path while + // retaining the complete declaration on the resolution result. let declared = - context === "html-reference" || context === "url-reference" + context === "url-reference" ? stripQueryAndFragment(input.declaredPath) : input.declaredPath; + const rejected = rejectDeclaration(declared); + if (rejected !== null) return rejected; if (context === "html-reference") return htmlCandidate(input); if (context === "module-specifier") { const fileUrl = fileUrlPath(declared); @@ -230,12 +232,39 @@ const contextualCandidate = ( "URL schemes and protocol-relative URLs are outside this local artifact path context.", ]); if (context === "url-reference") { + // A file base gives raw drive-letter segments special meaning, including + // after dot segments reach the root. Parse before percent decoding so an + // encoded drive-like filename stays literal. The synthetic base establishes + // only this ambiguity; it does not establish the source's runtime scheme. + const sourceUrlPath = input.sourcePath + .split("/") + .map(encodeURIComponent) + .join("/"); + const fileReference = URL.parse(declared, `file:///${sourceUrlPath}`); + if (fileReference === null) + return unresolvedCandidate("rejected", [ + "The URL reference cannot be parsed as a URL.", + ]); + if (/^\/[A-Za-z]:(?:\/|$)/u.test(fileReference.pathname)) + return unresolvedCandidate("unavailable", [ + "Drive-letter URL paths cannot be mapped without the source URL scheme and filesystem root.", + ]); const decoded = percentDecodeUrlPath(declared); if (decoded === null) return unresolvedCandidate("rejected", [ "The URL reference path percent-decodes to NUL or to bytes that are not UTF-8.", ]); - declared = decoded; + // Empty URL segments are significant; canonical filesystem paths cannot + // preserve them, including their interaction with following dot segments. + if (decoded.includes("//")) + return unresolvedCandidate("unavailable", [ + "Repeated URL path separators cannot be mapped to an exact canonical artifact file.", + ]); + return exactUrlPathCandidate( + decoded, + relativeCandidate(input.sourcePath, decoded), + "URL", + ); } return relativeCandidate(input.sourcePath, declared); }; @@ -689,37 +718,34 @@ const htmlCandidate = ( const basePath = decodedBase.startsWith("/") ? decodedBase.slice(1) : posix.join(posix.dirname(input.sourcePath), decodedBase); - if (declaredPath === "") return emptyHtmlReference(decodedBase, basePath); + if (declaredPath === "") + return exactUrlPathCandidate(decodedBase, basePath, "HTML"); return posix.join(htmlBaseDirectory(decodedBase, basePath), declaredPath); }; -const emptyHtmlReference = ( - base: string, - basePath: string, +const exactUrlPathCandidate = ( + declared: string, + candidate: string, + kind: "HTML" | "URL", ): string | CandidateResolution => { - if (!htmlBaseIsDirectory(base)) return basePath; - const confined = confineCandidate(basePath); + if (!urlPathIsDirectory(declared)) return candidate; + const confined = confineCandidate(candidate); if (typeof confined !== "string") return confined; return unresolvedCandidate("not-found", [ - `The HTML reference resolves to directory ${confined || "."}, not an exact selected application file.`, + `The ${kind} reference resolves to directory ${confined || "."}, not an exact selected application file.`, ]); }; -/** - * The directory a relative HTML reference resolves against. A base path - * ending in "/" or in a "." or ".." segment is already a directory, because a - * relative reference resolves against the base URL's directory and those - * segments are dropped rather than stepped through. - */ -const htmlBaseIsDirectory = (base: string): boolean => - base.endsWith("/") || - base.endsWith("/.") || - base.endsWith("/..") || - base === "." || - base === ".."; +/** Whether a URL path retains directory form after dot-segment normalization. */ +const urlPathIsDirectory = (path: string): boolean => + path.endsWith("/") || + path.endsWith("/.") || + path.endsWith("/..") || + path === "." || + path === ".."; const htmlBaseDirectory = (base: string, basePath: string): string => - htmlBaseIsDirectory(base) ? basePath : posix.dirname(basePath); + urlPathIsDirectory(base) ? basePath : posix.dirname(basePath); const confineCandidate = (candidate: string): string | CandidateResolution => { const normalized = normalizeJoinedLogicalPath(candidate); @@ -862,8 +888,6 @@ const resolveDirectory = ( files: input.files, ...(input.moduleKind === undefined ? {} : { moduleKind: input.moduleKind }), }; - const rejected = rejectDeclaration(entryInput.declaredPath); - if (rejected !== null) return rejected; const candidate = contextualCandidate(entryInput); if (typeof candidate !== "string") return candidate; const confined = confineCandidate(candidate); diff --git a/src/domain/javascript/javascriptElectronMemberWrites.ts b/src/domain/javascript/javascriptElectronMemberWrites.ts index 003b495e0..14741af36 100644 --- a/src/domain/javascript/javascriptElectronMemberWrites.ts +++ b/src/domain/javascript/javascriptElectronMemberWrites.ts @@ -52,13 +52,17 @@ export interface JavaScriptIntrinsicInvocation { | undefined; } -/** Index member and binding writes through caller-proven lexical identities. */ +/** + * Index writes through possible lexical object identities. Resolve the complete + * object before appending the written key: self-reference properties can denote + * the same object, while replacing that property is a distinct write. + */ export function* collectJavaScriptIdentityWritesSteps( program: t.Program, origin: ( root: t.Node, kind: "binding" | "member" | "opaque-call", - ) => readonly string[] | undefined, + ) => readonly (readonly (string | null)[])[], intrinsicInvocation: ( node: t.CallExpression | t.OptionalCallExpression, ) => JavaScriptIntrinsicInvocation | undefined, @@ -84,8 +88,7 @@ export function* collectJavaScriptIdentityWritesSteps( if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { const invocation = intrinsicInvocation(node); if (invocation?.kind === "opaque") { - const scope = origin(node, "opaque-call"); - if (scope !== undefined) retain(node, scope); + for (const scope of origin(node, "opaque-call")) retain(node, scope); return; } const callee = invocation?.path; @@ -114,20 +117,20 @@ export function* collectJavaScriptIdentityWritesSteps( // A known mutator with an opaque argument list can overwrite any // captured intrinsic. Do not turn an unexamined target into proof. if (target === undefined || t.isSpreadElement(target)) { - const scope = origin(node, "opaque-call"); - if (scope !== undefined) retain(node, scope); + for (const scope of origin(node, "opaque-call")) + retain(node, scope); } if (t.isNode(target) && !t.isSpreadElement(target)) { - const { root, members } = electronMemberPath(target); - const exported = origin(root, "member"); - if (exported !== undefined) { + const identities = origin(target, "member"); + if (identities.length > 0) { const key = keyedMutators.has(method ?? "") ? [staticString(arguments_?.[1]) ?? null] : []; - retain(node, [...exported, ...members, ...key]); + for (const identity of identities) + retain(node, [...identity, ...key]); } else if (!freshObject(target)) { - const scope = origin(node, "opaque-call"); - if (scope !== undefined) retain(node, scope); + for (const scope of origin(node, "opaque-call")) + retain(node, scope); } } } @@ -145,13 +148,19 @@ export function* collectJavaScriptIdentityWritesSteps( if (target === undefined) return; for (const leaf of semanticWriteTargets(target)) { if (t.isIdentifier(leaf)) { - const exported = origin(leaf, "binding"); - if (exported !== undefined) retain(node, exported); + for (const identity of origin(leaf, "binding")) + retain(node, identity); continue; } - const { root, members } = electronMemberPath(leaf); - const exported = origin(root, "member"); - if (exported !== undefined) retain(node, [...exported, ...members]); + const member = unwrapJavaScriptExpression(leaf).node; + if ( + !t.isMemberExpression(member) && + !t.isOptionalMemberExpression(member) + ) + continue; + const key = semanticStaticPropertyKey(member.property, member.computed); + for (const identity of origin(member.object, "member")) + retain(node, [...identity, key]); } }, }); diff --git a/src/domain/javascript/javascriptIntrinsicInvocation.ts b/src/domain/javascript/javascriptIntrinsicInvocation.ts index 20885069f..b46f28b42 100644 --- a/src/domain/javascript/javascriptIntrinsicInvocation.ts +++ b/src/domain/javascript/javascriptIntrinsicInvocation.ts @@ -6,13 +6,52 @@ import { unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; -const arrayArguments = (array: t.Node | undefined) => - t.isArrayExpression(array) && - array.elements.every((element) => t.isExpression(element)) - ? array.elements.filter((element): element is t.Expression => +/** Expand only literal array spreads whose positional arguments are all known. */ +export const expandJavaScriptInvocationArguments = ( + arguments_: readonly t.CallExpression["arguments"][number][], +): t.CallExpression["arguments"] => { + const expanded: t.CallExpression["arguments"] = []; + const pending = [...arguments_].reverse(); + while (pending.length > 0) { + const argument = pending.pop(); + if (argument === undefined) break; + const array = t.isSpreadElement(argument) + ? unwrapJavaScriptExpression(argument.argument).node + : undefined; + if ( + t.isArrayExpression(array) && + array.elements.every( + (element) => t.isExpression(element) || t.isSpreadElement(element), + ) + ) + for (let index = array.elements.length - 1; index >= 0; index -= 1) { + const element = array.elements[index]; + if (element !== null && element !== undefined) pending.push(element); + } + else expanded.push(argument); + } + return expanded; +}; + +const arrayArguments = (node: t.Node | undefined) => { + const array = + node === undefined ? undefined : unwrapJavaScriptExpression(node).node; + if ( + !t.isArrayExpression(array) || + array.elements.some((element) => element === null) + ) + return undefined; + const expanded = expandJavaScriptInvocationArguments( + array.elements.filter( + (element): element is t.Expression | t.SpreadElement => element !== null, + ), + ); + return expanded.every((element) => t.isExpression(element)) + ? expanded.filter((element): element is t.Expression => t.isExpression(element), ) : undefined; +}; const invocationArguments = ( method: "call" | "apply", @@ -36,7 +75,8 @@ export const resolveJavaScriptIntrinsicInvocation = ( ): JavaScriptIntrinsicInvocation | undefined => { const visited = new Set(); let callee: t.Node = node.callee; - let arguments_: JavaScriptIntrinsicInvocation["arguments"] = node.arguments; + let arguments_: JavaScriptIntrinsicInvocation["arguments"] = + expandJavaScriptInvocationArguments(node.arguments); let receiver: t.Node | undefined; let explicitReceiver = false; for (;;) { diff --git a/src/domain/javascript/javascriptModuleInterop.ts b/src/domain/javascript/javascriptModuleInterop.ts index 35e1c9ef1..c2e26d6fe 100644 --- a/src/domain/javascript/javascriptModuleInterop.ts +++ b/src/domain/javascript/javascriptModuleInterop.ts @@ -19,7 +19,10 @@ import { electronMemberPath, type JavaScriptIntrinsicInvocation, } from "./javascriptElectronMemberWrites.js"; -import { resolveJavaScriptIntrinsicInvocation } from "./javascriptIntrinsicInvocation.js"; +import { + expandJavaScriptInvocationArguments, + resolveJavaScriptIntrinsicInvocation, +} from "./javascriptIntrinsicInvocation.js"; import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js"; import { readExactJavaScriptLiteral, @@ -74,6 +77,9 @@ interface InteropHelper { /** Prove one inert namespace conversion without relaxing general assignment semantics. */ export const createSemanticModuleInterop = ( state: JavaScriptSemanticAnalysisState, + options?: { + readonly intrinsicPath: (node: t.Node) => readonly string[] | undefined; + }, ) => { const helpers = new Map(); const origins = new Map(); @@ -96,55 +102,57 @@ export const createSemanticModuleInterop = ( return ["intrinsic"]; }; - const intrinsicPath = (node: t.Node): readonly string[] | undefined => { - let current = node; - const visited = new Set(); - const pending: { id: string; suffixLength: number }[] = []; - let suffix: readonly string[] = []; - const finish = ( - path: readonly string[] | undefined, - ): readonly string[] | undefined => { - for (const binding of pending) - intrinsicBindings.set( - binding.id, - path === undefined - ? null - : path.slice(0, path.length - binding.suffixLength), - ); - return path; - }; - for (;;) { - const { root, members } = electronMemberPath(current); - if (members.some((member) => member === null)) return finish(undefined); - suffix = [...(members as readonly string[]), ...suffix]; - for (const global of ["Object", "Function", "Reflect"]) - if (isUnshadowedGlobal(root, state, global)) - return finish(["intrinsic", global, ...suffix]); - for (const global of ["globalThis", "global", "window", "self"]) - if (isUnshadowedGlobal(root, state, global)) - return finish(["intrinsic", ...suffix]); - if (!t.isIdentifier(root)) return finish(undefined); - const binding = resolveSemanticBindingState(state, root, root.name); - const cached = - binding === undefined - ? undefined - : intrinsicBindings.get(binding.bindingId); - if (cached !== undefined) - return finish(cached === null ? undefined : [...cached, ...suffix]); - const initializer = semanticStableInitializer(binding, state); - if ( - binding === undefined || - initializer === undefined || - initializer.projection.some((segment) => segment === null) || - visited.has(binding.bindingId) - ) - return finish(undefined); - visited.add(binding.bindingId); - pending.push({ id: binding.bindingId, suffixLength: suffix.length }); - suffix = [...initializer.projection.map(String), ...suffix]; - current = initializer.node; - } - }; + const intrinsicPath = + options?.intrinsicPath ?? + ((node: t.Node): readonly string[] | undefined => { + let current = node; + const visited = new Set(); + const pending: { id: string; suffixLength: number }[] = []; + let suffix: readonly string[] = []; + const finish = ( + path: readonly string[] | undefined, + ): readonly string[] | undefined => { + for (const binding of pending) + intrinsicBindings.set( + binding.id, + path === undefined + ? null + : path.slice(0, path.length - binding.suffixLength), + ); + return path; + }; + for (;;) { + const { root, members } = electronMemberPath(current); + if (members.some((member) => member === null)) return finish(undefined); + suffix = [...(members as readonly string[]), ...suffix]; + for (const global of ["Object", "Function", "Reflect"]) + if (isUnshadowedGlobal(root, state, global)) + return finish(["intrinsic", global, ...suffix]); + for (const global of ["globalThis", "global", "window", "self"]) + if (isUnshadowedGlobal(root, state, global)) + return finish(["intrinsic", ...suffix]); + if (!t.isIdentifier(root)) return finish(undefined); + const binding = resolveSemanticBindingState(state, root, root.name); + const cached = + binding === undefined + ? undefined + : intrinsicBindings.get(binding.bindingId); + if (cached !== undefined) + return finish(cached === null ? undefined : [...cached, ...suffix]); + const initializer = semanticStableInitializer(binding, state); + if ( + binding === undefined || + initializer === undefined || + initializer.projection.some((segment) => segment === null) || + visited.has(binding.bindingId) + ) + return finish(undefined); + visited.add(binding.bindingId); + pending.push({ id: binding.bindingId, suffixLength: suffix.length }); + suffix = [...initializer.projection.map(String), ...suffix]; + current = initializer.node; + } + }); // A nominal alias path is sufficient for conservative mutation lookup, but // helper proof also needs each read to follow initialization. An alias copy @@ -263,7 +271,7 @@ export const createSemanticModuleInterop = ( const { root, members } = electronMemberPath(current.callee); let bindTarget = root; let bindingArguments: JavaScriptIntrinsicInvocation["arguments"] = - current.arguments; + expandJavaScriptInvocationArguments(current.arguments); let captured = capturedIntrinsic(current.callee); let terminal = captured?.path.at(-1) === "bind"; let path: readonly string[] | undefined = captured?.path.slice(0, -1); diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts new file mode 100644 index 000000000..2eaec7a4b --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts @@ -0,0 +1,57 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +it.each([ + { + name: "reconverging projections with different alias-cycle histories", + setup: `var x; const a = { foo: x, bar: globalThis }; x = a.bar; +const y = flag ? x : a.foo; y.URL = replacement;`, + specifier: null, + }, + { + name: "growing self-reference projections with an ambient initializer", + setup: `let alias = globalThis; alias = alias.globalThis; +alias.URL = replacement;`, + specifier: null, + }, + { + name: "distinct properties selecting both ambient identities", + setup: `const box = { left: globalThis, right: import.meta }; +const selected = flag ? box.left : box.right; selected.URL = replacement;`, + specifier: null, + }, + { + name: "cyclic containers with a reachable ambient property", + setup: `var alias; const box = { next: alias, target: import.meta }; +alias = box; const selected = flag ? alias.target : alias.next.target; +selected.url = "file:///other/entry.mjs";`, + specifier: null, + }, + { + name: "cycles containing no ambient identity", + setup: `var box; box = { next: box }; +const selected = flag ? box : box.next; selected.URL = replacement;`, + specifier: "./worker.js", + }, + { + name: "unrelated writes through widened self-reference projections", + setup: `let alias = globalThis; alias = alias.globalThis; +alias.unrelated = replacement;`, + specifier: "./worker.js", + }, + { + name: "an exact empty projection into a shallow copy", + setup: `const copy = { ...globalThis }; +const selected = flag ? copy : copy; selected.URL = replacement;`, + specifier: "./worker.js", + }, +])("preserves $name", ({ setup, specifier }) => { + const source = `import { Worker } from "node:worker_threads"; +${setup} +new Worker(new URL("./worker.js", import.meta.url));`; + const worker = analyzeJavaScriptStaticSource(source).references.find( + (reference) => reference.kind === "worker", + ); + expect(worker?.specifier).toBe(specifier); +}); diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts new file mode 100644 index 000000000..dd24f3541 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -0,0 +1,301 @@ +import * as t from "@babel/types"; + +import { + semanticStaticPropertyKey, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; +import { + collectJavaScriptIdentityWritesSteps, + electronMemberPath, +} from "./javascriptElectronMemberWrites.js"; +import { createSemanticModuleInterop } from "./javascriptModuleInterop.js"; +import { + isUnshadowedGlobal, + resolveSemanticBindingState, + semanticStableInitializer, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; +import { isImportMetaUrl } from "./javascriptStaticAnalysisHelpers.js"; + +const GLOBAL_OBJECT_NAMES = ["globalThis", "global", "window", "self"]; + +const globalObjectName = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): string | undefined => + GLOBAL_OBJECT_NAMES.find((name) => isUnshadowedGlobal(node, state, name)); + +/** Global self references retain object identity, rather than adding a property. */ +const canonicalAmbientPath = ( + path: readonly (string | null)[], +): readonly (string | null)[] => { + if (path[0] !== "global") return path; + let firstProperty = 1; + while (GLOBAL_OBJECT_NAMES.includes(path[firstProperty] ?? "")) + firstProperty += 1; + return ["global", ...path.slice(firstProperty)]; +}; + +type AmbientPath = readonly (string | null)[]; + +/** Undefined means a cyclic or conflicting projection can select any depth. */ +type AmbientProjection = AmbientPath | undefined; + +/** + * Follow local object identities without equating shallow copies with their + * sources. Write lookup retains every visible initializer: reassigning an alias + * later cannot erase a possible earlier mutation. Intrinsic reads stay strict. + */ +const createAmbientPathResolver = ( + state: JavaScriptSemanticAnalysisState, + possibleWrites: boolean, +) => { + const cached = new WeakMap(); + return (node: t.Node): readonly AmbientPath[] => { + const prior = cached.get(node); + if (prior !== undefined) return prior; + const result = new Map(); + const pending: { node: t.Node; suffix: AmbientProjection }[] = [ + { node, suffix: [] }, + ]; + // 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. + const expanded = new WeakMap(); + const retain = (path: AmbientPath) => { + let canonical = canonicalAmbientPath(path); + for (;;) { + result.set(JSON.stringify(canonical), canonical); + // An unknown key may select a global self reference as well as a + // distinct property. Retain both possible object identities for writes. + if ( + !possibleWrites || + canonical[0] !== "global" || + canonical[1] !== null + ) + break; + canonical = canonicalAmbientPath(["global", ...canonical.slice(2)]); + } + }; + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined) break; + const { root, members } = electronMemberPath(current.node); + let suffix: AmbientProjection = + current.suffix === undefined + ? undefined + : [...members, ...current.suffix]; + if (expanded.has(root)) { + const previous = expanded.get(root); + if (previous === undefined) continue; + if (suffix !== undefined && previous.length === suffix.length) { + const joined: AmbientPath = previous.map((key, index) => + key === suffix?.[index] ? key : null, + ); + if (joined.every((key, index) => key === previous[index])) continue; + suffix = joined; + } else suffix = undefined; + if (!possibleWrites) { + cached.set(node, []); + return []; + } + } + expanded.set(root, suffix); + // 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({ node, suffix: path }); + if ( + t.isMetaProperty(root) && + root.meta.name === "import" && + root.property.name === "meta" + ) { + retain(["import-meta", ...(suffix ?? [])]); + continue; + } + if (t.isIdentifier(root)) { + if (globalObjectName(root, state) !== undefined) { + retain(["global", ...(suffix ?? [])]); + continue; + } + if (isUnshadowedGlobal(root, state, root.name)) { + retain(["global", root.name, ...(suffix ?? [])]); + continue; + } + const binding = resolveSemanticBindingState(state, root, root.name); + if (binding === undefined) continue; + const stable = semanticStableInitializer(binding, state); + const initializers = possibleWrites + ? binding.initializers + : stable === undefined + ? [] + : [stable]; + for (const initializer of initializers) + pending.push({ + node: initializer.node, + suffix: + suffix === undefined + ? undefined + : [ + ...initializer.projection.map((key) => + key === null ? null : String(key), + ), + ...suffix, + ], + }); + continue; + } + if (possibleWrites && t.isConditionalExpression(root)) { + continueWith(root.consequent); + continueWith(root.alternate); + continue; + } + if (possibleWrites && t.isLogicalExpression(root)) { + continueWith(root.left); + continueWith(root.right); + continue; + } + const key = suffix?.[0]; + if (suffix !== undefined && key === undefined) continue; + if (t.isObjectExpression(root)) { + if (possibleWrites) { + for (const property of root.properties) { + if (t.isSpreadElement(property)) continueWith(property.argument); + else if ( + t.isObjectProperty(property) && + t.isExpression(property.value) + ) { + const ownKey = semanticStaticPropertyKey( + property.key, + property.computed, + ); + if ( + suffix === undefined || + key === null || + ownKey === null || + ownKey === key + ) + continueWith(property.value, suffix?.slice(1)); + } + } + } else if ( + suffix !== undefined && + key !== null && + root.properties.every( + (property) => + t.isObjectProperty(property) && + semanticStaticPropertyKey(property.key, property.computed) !== + null, + ) + ) { + const property = root.properties.findLast( + (property) => + t.isObjectProperty(property) && + semanticStaticPropertyKey(property.key, property.computed) === + key, + ); + if (t.isObjectProperty(property) && t.isExpression(property.value)) + continueWith(property.value, suffix.slice(1)); + } + } else if (t.isArrayExpression(root)) { + if (suffix === undefined) { + for (const element of root.elements) + if (t.isSpreadElement(element)) continueWith(element.argument); + else if (t.isExpression(element)) continueWith(element); + } else if (!root.elements.some(t.isSpreadElement)) { + const index = Number(key); + if (key === null && possibleWrites) { + for (const element of root.elements) + if (t.isExpression(element)) + continueWith(element, suffix.slice(1)); + } else if ( + key !== null && + Number.isInteger(index) && + index >= 0 && + String(index) === key + ) { + const element = root.elements[index]; + if (t.isExpression(element)) continueWith(element, suffix.slice(1)); + } + } + } + } + const paths = [...result.values()]; + cached.set(node, paths); + return paths; + }; +}; + +/** + * Index source-local writes through ambient identities and stable aliases. + * The shared invocation owner resolves intrinsic aliases, bind/call/apply and + * literal argument spreads. Runtime reachability and arbitrary call effects + * remain unevaluated; a visible possible write is sufficient. + */ +export function* indexJavaScriptAmbientWritesSteps( + program: t.Program, + state: JavaScriptSemanticAnalysisState, +): Generator { + const ambientReads = createAmbientPathResolver(state, false); + const ambientWrites = createAmbientPathResolver(state, true); + const interop = createSemanticModuleInterop(state, { + intrinsicPath: (node) => { + const [path] = ambientReads(node); + return path?.[0] === "global" && path.every((key) => key !== null) + ? ["intrinsic", ...(path.slice(1) as readonly string[])] + : undefined; + }, + }); + yield* collectJavaScriptIdentityWritesSteps( + program, + (root, kind) => { + if (kind === "opaque-call") return []; + // Reassigning a local alias changes the binding, not the referenced object. + if (kind === "binding") + return t.isIdentifier(root) && + isUnshadowedGlobal(root, state, root.name) + ? [["global", root.name]] + : []; + return ambientWrites(root); + }, + interop.intrinsicInvocation, + interop.staticString, + interop.isFreshObject, + (_node, path) => { + state.ambientWrites.retain(path); + }, + ); +} + +/** + * Recognize an ambient name or direct global-object property without a visible + * source-local overwrite. This is a syntax-local identity fact, not proof that + * the runtime's globals have never been patched by other code. + */ +export const isUnmodifiedGlobalReference = ( + callee: t.Node, + state: JavaScriptSemanticAnalysisState, + name: string, +): boolean => { + const node = unwrapJavaScriptExpression(callee).node; + if (state.ambientWrites.covers(["global", name])) return false; + if (isUnshadowedGlobal(node, state, name)) return true; + if ( + !t.isMemberExpression(node) || + semanticStaticPropertyKey(node.property, node.computed) !== name + ) + return false; + const root = unwrapJavaScriptExpression(node.object).node; + const namespace = globalObjectName(root, state); + return ( + namespace !== undefined && + !state.ambientWrites.covers(["global", namespace]) + ); +}; + +/** A module URL base is known only while its property has no visible write. */ +export const isUnmodifiedImportMetaUrl = ( + node: t.Node | undefined, + state: JavaScriptSemanticAnalysisState, +): boolean => + isImportMetaUrl(node) && !state.ambientWrites.covers(["import-meta", "url"]); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 66776180a..b56e1a112 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -42,6 +42,7 @@ import { } from "./javascriptSemanticTraversal.js"; import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js"; import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js"; +import { indexJavaScriptAmbientWritesSteps } from "./javascriptSemanticAmbientWrites.js"; import { collectSemanticReturns, resolveSemanticModuleCallables, @@ -99,6 +100,7 @@ interface AddBindingInput { readonly definitionNode: t.Node; readonly initializer: t.Node | null; readonly directOrigin?: JavaScriptModuleOrigin; + readonly runtimeImport?: boolean; readonly projection?: readonly (string | number | null)[]; } @@ -128,6 +130,7 @@ export function* indexJavaScriptBindingsSteps( ): Generator { const state = createState(program); yield* collectDefinitionsSteps(program, state); + yield* indexJavaScriptAmbientWritesSteps(program, state); return state; } @@ -436,15 +439,18 @@ export function* indexJavaScriptElectronIdentitySteps( }; yield* collectJavaScriptIdentityWritesSteps( program, - (root, kind) => { - if (kind === "opaque-call") return interop.opaqueMutationPath(root); + (node, kind) => { + if (kind === "opaque-call") return [interop.opaqueMutationPath(node)]; + const { root, members } = electronMemberPath(node); const exported = kind === "binding" ? undefined : rootExport(root); - return exported === undefined - ? (interop.intrinsicPath(root) ?? + const path = + exported === undefined + ? (interop.intrinsicPath(root) ?? (kind === "member" ? interop.boundIntrinsic(root)?.path : undefined)) - : ["electron", ...exported]; + : ["electron", ...exported]; + return path === undefined ? [] : [[...path, ...members]]; }, interop.intrinsicInvocation, interop.staticString, @@ -550,6 +556,7 @@ const createState = ( linkedRequireCalls: new WeakSet(), moduleLinkBindings: new WeakMap(), conditionalInitializers: new WeakSet(), + ambientWrites: new SemanticPropertyPathCoverage(), }; }; @@ -1004,6 +1011,12 @@ const bindImports = ( definitionNode: specifier.local, initializer: null, directOrigin: { specifier: node.source.value, importedPath }, + runtimeImport: + node.importKind !== "type" && + node.importKind !== "typeof" && + (!t.isImportSpecifier(specifier) || + (specifier.importKind !== "type" && + specifier.importKind !== "typeof")), }); } }; @@ -1180,6 +1193,7 @@ const addBinding = (input: AddBindingInput): void => { definitionNode, initializer, directOrigin, + runtimeImport, projection = [], } = input; let binding = scope.bindings.get(name); @@ -1200,6 +1214,7 @@ const addBinding = (input: AddBindingInput): void => { ? { ...directOrigin, resolutionMode: "require" } : directOrigin, ); + binding.runtimeImport ||= runtimeImport === true; }; const createBinding = ( @@ -1219,6 +1234,7 @@ const createBinding = ( initializers: [], referenceInitializers: [], directOrigins: [], + runtimeImport: false, }); const addAssignment = ( diff --git a/src/domain/javascript/javascriptSemanticDependencies.ts b/src/domain/javascript/javascriptSemanticDependencies.ts new file mode 100644 index 000000000..3fca67577 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticDependencies.ts @@ -0,0 +1,81 @@ +/** An immutable persistent binary trie over one state's binding identities. */ +export type SemanticDependencyProof = + | true + | { + readonly zero: SemanticDependencyProof; + readonly one: SemanticDependencyProof; + } + | undefined; + +/** + * Identity layout belongs to the indexed state, not to a query or its effects. + * Every proof has the same height. Union shares unchanged branches, so aliases + * add a path rather than copying their dependency's transitive membership set. + */ +export class SemanticDependencyProofs { + private readonly indices = new Map(); + private readonly singletons = new Map(); + private readonly height: number; + + constructor(bindings: Iterable) { + for (const binding of bindings) + this.indices.set(binding, this.indices.size); + this.height = Math.ceil(Math.log2(Math.max(1, this.indices.size))); + } + + binding(binding: string): SemanticDependencyProof { + const prior = this.singletons.get(binding); + if (prior !== undefined) return prior; + const index = this.index(binding); + let proof: SemanticDependencyProof = true; + for (let bit = 0; bit < this.height; bit++) { + const one = Math.floor(index / 2 ** bit) % 2 === 1; + proof = Object.freeze({ + zero: one ? undefined : proof, + one: one ? proof : undefined, + }); + } + this.singletons.set(binding, proof); + return proof; + } + + union( + first: SemanticDependencyProof, + second: SemanticDependencyProof, + ): SemanticDependencyProof { + if (first === second || second === undefined) return first; + if (first === undefined) return second; + if (first === true || second === true) return true; + const zero = this.union(first.zero, second.zero); + const one = this.union(first.one, second.one); + if (zero === first.zero && one === first.one) return first; + if (zero === second.zero && one === second.one) return second; + return Object.freeze({ zero, one }); + } + + intersects( + proof: SemanticDependencyProof, + active: ReadonlyMap, + ): boolean { + for (const binding of active.keys()) + if (this.has(proof, binding)) return true; + return false; + } + + has(proof: SemanticDependencyProof, binding: string): boolean { + const index = this.index(binding); + for (let bit = this.height - 1; bit >= 0; bit--) { + if (proof === undefined) return false; + if (proof === true) return true; + proof = Math.floor(index / 2 ** bit) % 2 === 1 ? proof.one : proof.zero; + } + return proof === true; + } + + private index(binding: string): number { + const index = this.indices.get(binding); + if (index === undefined) + throw new TypeError(`Unindexed semantic dependency: ${binding}`); + return index; + } +} diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index e3f3a2942..c2f7efba3 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -6,7 +6,7 @@ import { type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; import { - clearSemanticPrimitiveBindingValues, + clearSemanticValueCaches, evaluateSemanticBinding, evaluateSemanticExpression, } from "./javascriptSemanticValues.js"; @@ -256,7 +256,7 @@ export function* collectSemanticMemberMutationsSteps( if (effect === "escape") binding.escapedPaths.push({ path, node: mutation }); else binding.mutatedPaths.push(path); - clearSemanticPrimitiveBindingValues(state); + clearSemanticValueCaches(state); for (const initializer of origins.initializers) pending.push({ node: initializer.node, @@ -548,7 +548,7 @@ export function* collectSemanticMemberMutationsSteps( } // Gathering effects can evaluate a primitive projection before its object // escapes. Final values must use the completed mutation state. - clearSemanticPrimitiveBindingValues(state); + clearSemanticValueCaches(state); } interface ReferencedValue { diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.test.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.test.ts index 8f4049409..8b5364578 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.test.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.test.ts @@ -54,6 +54,7 @@ describe("parameter references copied into function body bindings", () => { initializers: [entry, assigned], referenceInitializers: [], directOrigins: [], + runtimeImport: false, }; const origins = semanticMutationInitializers(binding, mutation, parents); expect(origins.initializers.includes(entry)).toBe(retained); diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index 29f798f36..b82abc62d 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -1,6 +1,9 @@ import * as t from "@babel/types"; -import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropertyPaths.js"; +import type { + JavaScriptSemanticPropertyPath, + SemanticPropertyPathCoverage, +} from "./javascriptSemanticPropertyPaths.js"; import type { JavaScriptModuleOrigin, @@ -43,6 +46,8 @@ export interface JavaScriptSemanticBindingState { readonly initializers: JavaScriptSemanticInitializer[]; readonly referenceInitializers: JavaScriptSemanticReferenceInitializer[]; readonly directOrigins: JavaScriptModuleOrigin[]; + /** An ECMAScript import that exists at runtime, excluding type-only and import-equals syntax. */ + runtimeImport: boolean; } /** Mutable lexical-scope state used only during AST recovery. */ @@ -75,6 +80,8 @@ export interface JavaScriptSemanticAnalysisState { JavaScriptSemanticBindingState >; readonly conditionalInitializers: WeakSet; + /** Source-local writes, tagged by global-object or import.meta identity. */ + readonly ambientWrites: SemanticPropertyPathCoverage; } /** diff --git a/src/domain/javascript/javascriptSemanticValues.memo.test.ts b/src/domain/javascript/javascriptSemanticValues.memo.test.ts new file mode 100644 index 000000000..8f32a723c --- /dev/null +++ b/src/domain/javascript/javascriptSemanticValues.memo.test.ts @@ -0,0 +1,227 @@ +import { expect, it, vi } from "vitest"; + +import { + analyzeJavaScriptSemantics, + indexJavaScriptBindingsSteps, +} from "./javascriptSemanticAnalysis.js"; +import { SemanticDependencyProofs } from "./javascriptSemanticDependencies.js"; +import type { JavaScriptSemanticValue } from "./javascriptSemanticValueTypes.js"; +import { + clearSemanticValueCaches, + evaluateSemanticBinding, + evaluateSemanticProvenance, +} from "./javascriptSemanticValues.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; + +const property = ( + value: JavaScriptSemanticValue, + name: string, +): JavaScriptSemanticValue => { + if (value.status !== "object") + throw new Error(`Expected object at ${name}, got ${value.status}`); + const result = value.properties.find((entry) => entry.name === name)?.value; + if (result === undefined) throw new Error(`Missing ${name}`); + return result; +}; + +it.each([false, true])( + "recomputes a shared cyclic value under a different active ancestor (prewarmed: %s)", + (prewarmed) => { + const parsed = parseJavaScriptSource( + ` +const a = { shared: common, own: "a" }; +const b = { shared: common, own: "b" }; +const common = { a, b }; +const result = { first: a, second: b }; +`, + "cycles.js", + ); + if (parsed === null) throw new Error("Expected parsed source"); + const steps = indexJavaScriptBindingsSteps(parsed.program); + let step = steps.next(); + while (step.done !== true) step = steps.next(); + const state = step.value; + const binding = [...state.bindingsById.values()].find( + (entry) => entry.name === "result", + ); + if (binding === undefined) throw new Error("Missing result binding"); + if (prewarmed) + for (const entry of state.bindingsById.values()) + if (entry.name === "a" || entry.name === "b") + evaluateSemanticBinding(entry, state); + const value = evaluateSemanticBinding(binding, state); + const firstCommon = property(property(value, "first"), "shared"); + const secondCommon = property(property(value, "second"), "shared"); + expect(property(firstCommon, "a").status).toBe("cycle"); + expect(property(property(firstCommon, "b"), "own")).toEqual({ + status: "literal", + value: "b", + }); + expect(property(secondCommon, "b").status).toBe("cycle"); + expect(property(property(secondCommon, "a"), "own")).toEqual({ + status: "literal", + value: "a", + }); + }, +); + +it("invalidates retained alias values at the producing mutation boundary", () => { + const parsed = parseJavaScriptSource( + "const target = { value: 1 }; const alias = target; const root = { alias };", + "mutation.js", + ); + if (parsed === null) throw new Error("Expected parsed source"); + const steps = indexJavaScriptBindingsSteps(parsed.program); + let step = steps.next(); + while (step.done !== true) step = steps.next(); + const state = step.value; + const target = [...state.bindingsById.values()].find( + ({ name }) => name === "target", + ); + const root = [...state.bindingsById.values()].find( + ({ name }) => name === "root", + ); + if (target === undefined || root === undefined) + throw new Error("Missing binding"); + expect( + property(property(evaluateSemanticBinding(root, state), "alias"), "value"), + ).toEqual({ status: "literal", value: 1 }); + target.mutatedPaths.push(["value"]); + clearSemanticValueCaches(state); + expect( + property(property(evaluateSemanticBinding(root, state), "alias"), "value") + .status, + ).toBe("unknown"); +}); + +const balancedOr = (names: string[]): string => { + if (names.length === 1) return names[0]!; + const middle = Math.floor(names.length / 2); + return `(${balancedOr(names.slice(0, middle))} || ${balancedOr(names.slice(middle))})`; +}; + +it("shares dependency proofs across a shallow wide reconvergent graph", () => { + const measure = (width: number) => { + const imports = Array.from({ length: width }, (_, index) => `l${index}`); + const aliases = Array.from({ length: width }, (_, index) => `a${index}`); + const source = `${imports.map((name) => `import * as ${name} from "fs";`).join("\n")} +const shared = ${balancedOr(imports)}; +function consumer() { +${aliases.map((name) => `const ${name} = shared;`).join("\n")} +const root = ${balancedOr(aliases)}; +}`; + const parsed = parseJavaScriptSource(source, "wide.js"); + if (parsed === null) throw new Error("Expected parsed source"); + const steps = indexJavaScriptBindingsSteps(parsed.program); + let step = steps.next(); + while (step.done !== true) step = steps.next(); + const state = step.value; + const rootBinding = [...state.bindingsById.values()].find( + ({ name }) => name === "root", + ); + if (rootBinding === undefined) throw new Error("Missing root binding"); + // One root query simultaneously retains every alias memo in this family. + // Separate per-binding provenance queries have baseline repeated traversal; + // they must not be confused with this cache's retained closure expansion. + // Count representation construction and union work, not elapsed time/heap. + // Binding-ID additions also discriminate the superseded flattened Sets. + let bindingMemberships = 0; + let proofBranches = 0; + let unionCalls = 0; + const add = Set.prototype.add; + const freeze = Object.freeze; + const union = SemanticDependencyProofs.prototype.union; + const addSpy = vi.spyOn(Set.prototype, "add").mockImplementation(function ( + this: Set, + value: unknown, + ) { + if (typeof value === "string" && value.includes("binding:")) + bindingMemberships++; + return add.call(this, value); + }); + const freezeSpy = vi.spyOn(Object, "freeze").mockImplementation((value) => { + if ( + typeof value === "object" && + value !== null && + Object.hasOwn(value, "zero") && + Object.hasOwn(value, "one") && + Object.keys(value).length === 2 + ) + proofBranches++; + return freeze(value); + }); + const unionSpy = vi + .spyOn(SemanticDependencyProofs.prototype, "union") + .mockImplementation(function ( + this: SemanticDependencyProofs, + first, + second, + ) { + unionCalls++; + return union.call(this, first, second); + }); + try { + expect(evaluateSemanticBinding(rootBinding, state).status).toBe( + "ambiguous", + ); + expect(evaluateSemanticProvenance(rootBinding, state).origins).toEqual([ + { specifier: "fs", importedPath: [] }, + ]); + } finally { + unionSpy.mockRestore(); + freezeSpy.mockRestore(); + addSpy.mockRestore(); + } + const result = analyzeJavaScriptSemantics(source); + expect(result.coverage).toEqual({ status: "complete", omittedCount: 0 }); + for (const name of [...imports, ...aliases, "shared", "root"]) + expect( + result.bindings.filter((entry) => entry.name === name), + ).toHaveLength(1); + const root = result.bindings.find((entry) => entry.name === "root"); + expect(root?.value.status).toBe("ambiguous"); + expect(root?.provenance.origins).toEqual([ + { specifier: "fs", importedPath: [] }, + ]); + return { + storage: bindingMemberships + proofBranches, + work: bindingMemberships + unionCalls, + }; + }; + // Balanced depths remain small; doubling both independent widths must not + // quadruple proof construction/work while all meaningful aliases survive. + const small = measure(32); + const large = measure(64); + expect(small.storage).toBeGreaterThan(0); + expect(small.work).toBeGreaterThan(0); + expect(large.storage).toBeLessThan(small.storage * 3); + expect(large.work).toBeLessThan(small.work * 3); +}); + +it("uses immutable exact identity proofs and shares unchanged union branches", () => { + const proofs = new SemanticDependencyProofs(["a", "b", "c", "d", "e"]); + const a = proofs.binding("a"); + const b = proofs.binding("b"); + const c = proofs.binding("c"); + const ab = proofs.union(a, b); + const abc = proofs.union(ab, c); + expect(proofs.union(abc, ab)).toBe(abc); + expect(proofs.binding("a")).toBe(a); + expect(Object.isFrozen(abc)).toBe(true); + expect(["a", "b", "c", "d", "e"].map((id) => proofs.has(abc, id))).toEqual([ + true, + true, + true, + false, + false, + ]); + expect(proofs.has(undefined, "a")).toBe(false); + expect(proofs.intersects(abc, new Map([["d", 0]]))).toBe(false); + expect(proofs.intersects(abc, new Map([["c", 0]]))).toBe(true); + const single = new SemanticDependencyProofs(["only"]); + expect(single.has(single.binding("only"), "only")).toBe(true); + expect(single.has(undefined, "only")).toBe(false); + expect(() => proofs.binding("unindexed")).toThrow( + "Unindexed semantic dependency", + ); +}); diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index 9cd0a10c0..cf31e0575 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -1,5 +1,10 @@ import * as t from "@babel/types"; +import { + SemanticDependencyProofs, + type SemanticDependencyProof, +} from "./javascriptSemanticDependencies.js"; + import { semanticCapturePosition, semanticEscapeFollowsCapture, @@ -51,6 +56,39 @@ import { semanticResourceLimitReason, } from "./javascriptSemanticResourceLimits.js"; +interface MemoizedEvaluation { + readonly value: T; + readonly dependencies: SemanticDependencyProof; +} + +/** Provenance lives for one query; live values belong to the effects-aware state. */ +interface EvaluationMemo { + readonly provenance: Map< + string, + Map> + >; + readonly dependencyFrames: { proof: SemanticDependencyProof }[]; + readonly dependencies: SemanticDependencyProofs; +} + +// Indexing owns stable binding identities; effects invalidate values, not this +// identity-only layout. No query result or dependency closure is retained here. +const dependencyProofsByState = new WeakMap< + JavaScriptSemanticAnalysisState, + SemanticDependencyProofs +>(); + +const createEvaluationMemo = ( + state: JavaScriptSemanticAnalysisState, +): EvaluationMemo => { + let dependencies = dependencyProofsByState.get(state); + if (dependencies === undefined) { + dependencies = new SemanticDependencyProofs(state.bindingsById.keys()); + dependencyProofsByState.set(state, dependencies); + } + return { provenance: new Map(), dependencyFrames: [], dependencies }; +}; + interface EvaluationContext { readonly state: JavaScriptSemanticAnalysisState; /** Bindings under evaluation, by their position on the active stack. */ @@ -59,6 +97,7 @@ interface EvaluationContext { readonly capturePoint?: t.Node; readonly primitiveBindingValues: Map; readonly dependence: EvaluationDependence; + readonly memo: EvaluationMemo; } /** @@ -76,8 +115,7 @@ const CALLER_INDEPENDENT = Number.POSITIVE_INFINITY; const EVERY_CALLER = -1; /** A value no caller changes, and the deepest evaluation known to yield it. */ -interface CallerIndependentBindingValue { - readonly value: JavaScriptSemanticValue; +interface CallerIndependentBindingValue extends MemoizedEvaluation { readonly expressionDepth: number; } @@ -101,8 +139,8 @@ const capturedBindingValuesByState = new WeakMap< WeakMap> >(); -/** Discard primitive projections evaluated before mutation collection finished. */ -export const clearSemanticPrimitiveBindingValues = ( +/** Discard every state-owned value derived before the effects boundary changed. */ +export const clearSemanticValueCaches = ( state: JavaScriptSemanticAnalysisState, ): void => { primitiveBindingValuesByState.delete(state); @@ -151,6 +189,7 @@ export const evaluateSemanticBinding = ( expressionDepth: 0, primitiveBindingValues: primitiveBindingValuesFor(state), dependence: { lowest: CALLER_INDEPENDENT }, + memo: createEvaluationMemo(state), }); /** Evaluate an inert expression, optionally at an earlier reference capture. */ @@ -165,6 +204,7 @@ export const evaluateSemanticExpression = ( expressionDepth: 0, primitiveBindingValues: primitiveBindingValuesFor(state), dependence: { lowest: CALLER_INDEPENDENT }, + memo: createEvaluationMemo(state), }; return evaluateExpression( node, @@ -185,12 +225,60 @@ export const evaluateSemanticProvenance = ( expressionDepth: 0, primitiveBindingValues: primitiveBindingValuesFor(state), dependence: { lowest: CALLER_INDEPENDENT }, + memo: createEvaluationMemo(state), }); +const readMemoizedEvaluation = ( + entry: MemoizedEvaluation | undefined, + context: EvaluationContext, +): T | undefined => { + if ( + entry === undefined || + context.memo.dependencies.intersects(entry.dependencies, context.bindings) + ) + return undefined; + retainDependencyProof(entry.dependencies, context); + return entry.value; +}; + +const retainDependencyProof = ( + proof: SemanticDependencyProof, + context: EvaluationContext, +): void => { + const parent = context.memo.dependencyFrames.at(-1); + if (parent !== undefined) + parent.proof = context.memo.dependencies.union(parent.proof, proof); +}; + +const recordBindingDependency = ( + binding: JavaScriptSemanticBindingState, + context: EvaluationContext, +): void => + retainDependencyProof( + context.memo.dependencies.binding(binding.bindingId), + context, + ); + +const collectEvaluationDependencies = ( + binding: JavaScriptSemanticBindingState, + context: EvaluationContext, + evaluate: () => T, +): MemoizedEvaluation => { + const frame = { proof: context.memo.dependencies.binding(binding.bindingId) }; + context.memo.dependencyFrames.push(frame); + try { + return { value: evaluate(), dependencies: frame.proof }; + } finally { + context.memo.dependencyFrames.pop(); + retainDependencyProof(frame.proof, context); + } +}; + const evaluateBinding = ( binding: JavaScriptSemanticBindingState, context: EvaluationContext, ): JavaScriptSemanticValue => { + recordBindingDependency(binding, context); const cycleHead = context.bindings.get(binding.bindingId); if (cycleHead !== undefined) { context.dependence.lowest = Math.min(context.dependence.lowest, cycleHead); @@ -224,8 +312,10 @@ const evaluateBinding = ( if ( retained !== undefined && context.expressionDepth <= retained.expressionDepth - ) - return retained.value; + ) { + const reusable = readMemoizedEvaluation(retained, context); + if (reusable !== undefined) return reusable; + } if (binding.initializers.length === 0) return { status: "unknown", @@ -260,22 +350,30 @@ const evaluateBinding = ( context.bindings.set(binding.bindingId, position); let value: JavaScriptSemanticValue; let callerIndependent = false; + let evaluated: MemoizedEvaluation; try { - const nested = nestedContext(context); - value = projectValue( - evaluateExpression(initializer.node, nested), - initializer.projection, - ); - if (!isPrimitive(value)) { - const captureContext = primitiveCaptureContext(initializer.node, nested); - if (captureContext !== undefined) { - const captured = projectValue( - evaluateExpression(initializer.node, captureContext), - initializer.projection, + evaluated = collectEvaluationDependencies(binding, context, () => { + const nested = nestedContext(context); + let value = projectValue( + evaluateExpression(initializer.node, nested), + initializer.projection, + ); + if (!isPrimitive(value)) { + const captureContext = primitiveCaptureContext( + initializer.node, + nested, ); - if (isPrimitive(captured)) value = captured; + if (captureContext !== undefined) { + const captured = projectValue( + evaluateExpression(initializer.node, captureContext), + initializer.projection, + ); + if (isPrimitive(captured)) value = captured; + } } - } + return value; + }); + value = evaluated.value; // A cycle among bindings this one reads is part of its value. A cycle // through this binding is cut elsewhere when a caller holds another member. callerIndependent = context.dependence.lowest > position; @@ -309,6 +407,7 @@ const evaluateBinding = ( ) independent.set(binding.bindingId, { value: projected, + dependencies: evaluated.dependencies, expressionDepth: context.expressionDepth, }); // Point-local memoization also bounds failed speculative captures. Only a @@ -649,6 +748,50 @@ const projectValue = ( const provenanceForBinding = ( binding: JavaScriptSemanticBindingState, context: EvaluationContext, +): JavaScriptBindingProvenance => { + recordBindingDependency(binding, context); + const cycleHead = context.bindings.get(binding.bindingId); + if (cycleHead !== undefined) { + context.dependence.lowest = Math.min(context.dependence.lowest, cycleHead); + return semanticUnresolvedProvenance( + "cycle", + `Alias cycle at ${binding.name}.`, + ); + } + let depths = context.memo.provenance.get(binding.bindingId); + const prior = readMemoizedEvaluation( + depths?.get(context.expressionDepth), + context, + ); + if (prior !== undefined) return prior; + const position = context.bindings.size; + const callerDependence = context.dependence.lowest; + context.dependence.lowest = CALLER_INDEPENDENT; + let result: MemoizedEvaluation; + let callerIndependent = false; + try { + result = collectEvaluationDependencies(binding, context, () => + provenanceForBindingUncached(binding, context), + ); + callerIndependent = context.dependence.lowest > position; + } finally { + context.dependence.lowest = callerIndependent + ? callerDependence + : Math.min(callerDependence, context.dependence.lowest); + } + if (callerIndependent) { + if (depths === undefined) { + depths = new Map(); + context.memo.provenance.set(binding.bindingId, depths); + } + depths.set(context.expressionDepth, result); + } + return result.value; +}; + +const provenanceForBindingUncached = ( + binding: JavaScriptSemanticBindingState, + context: EvaluationContext, ): JavaScriptBindingProvenance => { const provenance = provenanceForBindingValue(binding, context); const assigned = binding.definitions.some( @@ -674,11 +817,6 @@ const provenanceForBindingValue = ( binding: JavaScriptSemanticBindingState, context: EvaluationContext, ): JavaScriptBindingProvenance => { - if (context.bindings.has(binding.bindingId)) - return semanticUnresolvedProvenance( - "cycle", - `Alias cycle at ${binding.name}.`, - ); if (binding.directOrigins.length > 0) return semanticOriginsProvenance(binding.directOrigins); if (binding.initializers.length === 0) return semanticLocalProvenance(); @@ -725,21 +863,25 @@ const provenanceForExpression = ( node: t.Node, context: EvaluationContext, ): JavaScriptBindingProvenance => { - if (context.expressionDepth > SEMANTIC_EXPRESSION_DEPTH_LIMIT) + if (context.expressionDepth > SEMANTIC_EXPRESSION_DEPTH_LIMIT) { + context.dependence.lowest = EVERY_CALLER; return semanticUnresolvedProvenance( "unknown", semanticResourceLimitReason("expression-depth"), ); + } const unwrapped = unwrapJavaScriptExpression(node); if (unwrapped.depth > 0) { if ( context.expressionDepth + unwrapped.depth > SEMANTIC_EXPRESSION_DEPTH_LIMIT - ) + ) { + context.dependence.lowest = EVERY_CALLER; return semanticUnresolvedProvenance( "unknown", semanticResourceLimitReason("expression-depth"), ); + } return provenanceForExpression(unwrapped.node, { ...context, expressionDepth: context.expressionDepth + unwrapped.depth, @@ -846,6 +988,7 @@ const nestedContext = (context: EvaluationContext): EvaluationContext => ({ expressionDepth: context.expressionDepth + 1, bindings: context.bindings, dependence: context.dependence, + memo: context.memo, }); const isPrimitive = (value: JavaScriptSemanticValue): boolean => diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index cbbd57b77..4d10638bc 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -235,10 +235,11 @@ const inspectNode = ( inspectBundlerBootstrap(source, node, accumulator, bindings); inspectBundlerRegistration(source, node, accumulator, bindings); inspectEsbuildWrapper(source, node, accumulator, bindings); - inspectCall(source, node, findings); + inspectCall(source, node, findings, bindings); } else if (t.isOptionalCallExpression(node)) { - inspectCall(source, node, findings); - } else if (t.isNewExpression(node)) inspectCall(source, node, findings); + inspectCall(source, node, findings, bindings); + } else if (t.isNewExpression(node)) + inspectCall(source, node, findings, bindings); if (t.isTSImportEqualsDeclaration(node)) { const specifier = semanticImportEqualsSpecifier(node); if (specifier !== null) diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index cf951f2f4..148f7fb7e 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -1,5 +1,6 @@ import * as t from "@babel/types"; +import { htmlUrlText, stripQueryAndFragment } from "../artifactPathSyntax.js"; import { sanitizeEndpointCandidate } from "../browserObservation.js"; import { addFindingOnce, @@ -7,6 +8,7 @@ import { } from "./javascriptStaticAnalysisFindings.js"; import { STATIC_ROUTE_CALL_NAMES, + argumentNode, argumentValue, calleeName, endpointArgument as endpointArgumentHelper, @@ -19,13 +21,24 @@ import { storageKind, stringValue, } from "./javascriptStaticAnalysisHelpers.js"; -import { semanticStaticPropertyName } from "./javascriptAstValues.js"; +import { + semanticStaticPropertyName, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; import type { JavaScriptAnalysisAccumulator as AnalysisAccumulator, JavaScriptEndpointInput as EndpointInput, JavaScriptFindingContext as FindingContext, JavaScriptReferenceInput as ReferenceInput, } from "./javascriptStaticAnalysisState.js"; +import { + resolveSemanticBindingState, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; +import { + isUnmodifiedGlobalReference, + isUnmodifiedImportMetaUrl, +} from "./javascriptSemanticAmbientWrites.js"; type JavaScriptCallLike = | t.CallExpression @@ -37,6 +50,7 @@ export const inspectCall = ( source: string, node: JavaScriptCallLike, context: FindingContext, + bindings: JavaScriptSemanticAnalysisState, ): void => { const { accumulator } = context; const name = calleeName(node.callee); @@ -59,8 +73,27 @@ export const inspectCall = ( }); for (const specifier of vitePreloadDependencySpecifiers(source, node, name)) addReference(context, { node, kind: "dynamic-import", specifier }); - if (name === "Worker" || name.endsWith(".Worker")) - addReference(context, { node, kind: "worker", specifier: first }); + const worker = t.isNewExpression(node) ? node : undefined; + const workerConstructor = + worker === undefined + ? undefined + : workerConstructorKind(worker.callee, bindings); + if ( + workerConstructor !== undefined || + name === "Worker" || + name.endsWith(".Worker") + ) + addReference(context, { + node, + kind: "worker", + // Node string paths use the process cwd or filesystem root, neither of + // which is established by the analyzed source's artifact location. + specifier: + (workerConstructor === "node" ? undefined : first) ?? + (workerConstructor === undefined || worker === undefined + ? undefined + : workerUrlSpecifier(worker, bindings)), + }); if (name.endsWith("serviceWorker.register")) addReference(context, { node, kind: "service-worker", specifier: first }); inspectEndpointCall(node, name, first, context); @@ -97,6 +130,69 @@ export const inspectCall = ( }); }; +const workerUrlSpecifier = ( + worker: t.NewExpression, + bindings: JavaScriptSemanticAnalysisState, +): string | undefined => { + const argumentExpression = argumentNode(worker.arguments[0]); + if (argumentExpression === undefined) return undefined; + const node = unwrapJavaScriptExpression(argumentExpression).node; + if (!t.isNewExpression(node)) return undefined; + const callee = unwrapJavaScriptExpression(node.callee).node; + if ( + !isUnmodifiedGlobalReference(callee, bindings, "URL") || + !isUnmodifiedImportMetaUrl(argumentNode(node.arguments[1]), bindings) + ) + return undefined; + const specifier = stringValue(node.arguments[0]); + if (specifier === undefined) return undefined; + const pathname = stripQueryAndFragment(specifier); + // The module URL's scheme and filesystem/origin root are unavailable. Only + // relative pathnames can establish a location within the artifact; retain + // rooted paths and paths requiring URL text normalization as expressions. + // Malformed percent escapes survive HTTP URL parsing but cannot be converted + // from a file URL to a Node Worker path. Without the base scheme, keep that + // ambiguity unknown; query and fragment bytes do not affect pathname lookup. + return pathname.length > 0 && + !pathname.startsWith("/") && + !/%(?![0-9a-f]{2})/iu.test(pathname) && + stripQueryAndFragment(htmlUrlText(specifier)) === pathname + ? specifier + : undefined; +}; + +const workerConstructorKind = ( + callee: t.Node, + bindings: JavaScriptSemanticAnalysisState, +): "browser" | "node" | undefined => { + const node = unwrapJavaScriptExpression(callee).node; + const member = + t.isMemberExpression(node) && + semanticStaticPropertyName(node.property, node.computed) === "Worker"; + const root = unwrapJavaScriptExpression(member ? node.object : node).node; + if (!t.isIdentifier(root)) return undefined; + const browserGlobal = member + ? ["globalThis", "window", "self"].includes(root.name) + : root.name === "Worker"; + if (browserGlobal && isUnmodifiedGlobalReference(node, bindings, "Worker")) + return "browser"; + const binding = resolveSemanticBindingState(bindings, root, root.name); + const [origin] = binding?.directOrigins ?? []; + // Named ESM exports and module namespace exports are read-only. Default + // imports and CommonJS objects can replace Worker, so their identity is unknown. + return binding?.runtimeImport === true && + binding.definitions.length === 1 && + binding.directOrigins.length === 1 && + origin !== undefined && + (origin.specifier === "worker_threads" || + origin.specifier === "node:worker_threads") && + (member + ? origin.importedPath.length === 0 + : origin.importedPath.length === 1 && origin.importedPath[0] === "Worker") + ? "node" + : undefined; +}; + // Element factories such as jsx("audio", props) and createElement("video", // props) carry the HTML media `preload` hint ("none", "metadata", "auto"), // which never names an Electron preload script. diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index 40ffe6e1f..a6a6ebc44 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -342,16 +342,23 @@ const isDirectoryIdentity = (node: t.Node | undefined): boolean => { ); }; -const isFileIdentity = (node: t.Node | undefined): boolean => { - if (t.isIdentifier(node, { name: "__filename" })) return true; +/** Whether the expression denotes the current module's URL. */ +export const isImportMetaUrl = (node: t.Node | undefined): boolean => { if (node === undefined) return false; - if ( - t.isMemberExpression(node) && - t.isMetaProperty(node.object) && - node.object.meta.name === "import" && - node.object.property.name === "meta" && - semanticStaticPropertyName(node.property, node.computed) === "url" - ) + const expression = unwrapJavaScriptExpression(node).node; + if (!t.isMemberExpression(expression)) return false; + const object = unwrapJavaScriptExpression(expression.object).node; + return ( + t.isMetaProperty(object) && + object.meta.name === "import" && + object.property.name === "meta" && + semanticStaticPropertyName(expression.property, expression.computed) === + "url" + ); +}; + +const isFileIdentity = (node: t.Node | undefined): boolean => { + if (t.isIdentifier(node, { name: "__filename" }) || isImportMetaUrl(node)) return true; if (!t.isCallExpression(node)) return false; const name = calleeName(node.callee); diff --git a/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts b/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts new file mode 100644 index 000000000..6d9b62a63 --- /dev/null +++ b/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts @@ -0,0 +1,626 @@ +import { expect } from "vitest"; + +import type { JavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +const workerReferences = (graph: JavaScriptApplicationGraph) => + graph.nodes + .filter(({ kind }) => kind === "worker") + .flatMap((node) => + node.observations.map(({ properties, evidence }) => ({ + source: + evidence.location.available && + evidence.location.value.kind === "source-range" + ? evidence.location.value.source + : null, + declared_specifier: properties.declared_specifier, + expression: properties.expression, + resolved_path: properties.resolved_path, + mapped_paths: graph.edges + .filter( + ({ relation, source_node_id }) => + relation === "maps_to" && source_node_id === node.node_id, + ) + .map(({ properties: edge }) => edge.resolved_path), + })), + ); + +cliTest( + "promotes Worker URLs only for known constructor bindings through the CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-worker-identity-"); + const identityCases = [ + ...["globalThis", "window", "self"].map((namespace) => ({ + prefix: "", + constructor: `new ${namespace}.Worker`, + suffix: "", + resolves: true, + })), + ...["worker_threads", "node:worker_threads"].flatMap((specifier) => [ + { + prefix: `import { Worker } from "${specifier}";`, + constructor: "new Worker", + suffix: "", + resolves: true, + }, + { + prefix: `import * as threads from "${specifier}";`, + constructor: "new threads.Worker", + suffix: "", + resolves: true, + }, + ]), + { + prefix: 'import { Worker as ThreadWorker } from "node:worker_threads";', + constructor: "new ThreadWorker", + suffix: "", + resolves: true, + }, + { + prefix: 'import { Worker } from "node:worker_threads";', + constructor: "new (Worker as typeof Worker)", + suffix: "", + resolves: true, + }, + { + prefix: 'import * as threads from "node:worker_threads";', + constructor: "new ((threads as typeof threads).Worker!)", + suffix: "", + resolves: true, + }, + { + prefix: + 'import { Worker } from "node:worker_threads"; function start(globalThis) { globalThis.URL = Fake;', + constructor: "new Worker", + suffix: "}", + resolves: true, + }, + { + prefix: 'globalThis.unrelated = Fake; URL.extra = "metadata";', + constructor: "new Worker", + suffix: "", + resolves: true, + }, + ...[ + ["class Worker {}", "new Worker", ""], + ["globalThis.Worker = Fake;", "new Worker", ""], + ["globalThis.Worker = Fake;", "new window.Worker", ""], + ["globalThis = replacement;", "new globalThis.Worker", ""], + ["function start(Worker) {", "new Worker", "}"], + ['import { Worker } from "./custom.js";', "new Worker", ""], + ["const custom = { Worker: class {} };", "new custom.Worker", ""], + ["function start(window) {", "new window.Worker", "}"], + ["function start(globalThis) {", "new globalThis.Worker", "}"], + [ + 'import type { Worker } from "node:worker_threads";', + "new Worker", + "", + ], + [ + 'import { type Worker } from "node:worker_threads";', + "new Worker", + "", + ], + [ + 'import type * as threads from "node:worker_threads";', + "new threads.Worker", + "", + ], + [ + 'import threads from "node:worker_threads"; threads.Worker = Fake;', + "new threads.Worker", + "", + ], + [ + 'const threads = require("node:worker_threads"); threads.Worker = Fake;', + "new threads.Worker", + "", + ], + [ + 'import threads = require("node:worker_threads"); threads.Worker = Fake;', + "new threads.Worker", + "", + ], + [ + 'import { Worker } from "node:worker_threads"; Worker = Fake;', + "new Worker", + "", + ], + ["", "Worker", ""], + ].map(([prefix, constructor, suffix]) => ({ + prefix, + constructor, + suffix, + resolves: false, + })), + ].map(({ prefix, constructor, suffix, resolves }, index) => ({ + source: `identity-${index}.ts`, + prefix, + expression: `${constructor}(new URL("./worker.js", import.meta.url))`, + suffix, + resolves, + })); + await writeFixtureFiles(root, { + ...Object.fromEntries( + identityCases.map(({ source, prefix, expression, suffix }) => [ + source, + `${prefix}\n${expression};${suffix}`, + ]), + ), + "worker.js": "self.postMessage('existing worker file');", + "custom.js": "export class Worker {}", + "custom-alias.mjs": + 'import { Worker as ThreadWorker } from "./custom.js"; new ThreadWorker(new URL("./worker.js", import.meta.url));', + }); + const result = await analyzeJavaScriptDirectory("CLI", cli, root); + expect(result.statistics.parse_failures).toBe(0); + const references = workerReferences(result.graph); + expect(references).toHaveLength(identityCases.length); + expect(references.some(({ source }) => source === "custom-alias.mjs")).toBe( + false, + ); + for (const { source, expression, resolves } of identityCases) + expect(references).toContainEqual({ + source, + declared_specifier: resolves ? "./worker.js" : null, + expression: resolves ? null : expression, + resolved_path: resolves ? "worker.js" : null, + mapped_paths: resolves ? ["worker.js"] : [], + }); + }, +); + +cliTest( + "resolves module-relative Worker URL constructors through the compiled CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-worker-module-url-"); + const cases = [ + { + source: "nested/relative.mjs", + expression: 'new Worker(new URL("./worker.js", import.meta.url));', + specifier: "./worker.js", + resolved: "nested/worker.js", + }, + ...[ + 'import.meta.unrelated = "metadata";', + 'globalThis["import.meta"] = { url: "file:///other/entry.mjs" };', + 'const meta = import.meta; meta.unrelated = "metadata";', + 'const ambient = globalThis; ambient.unrelated = "metadata";', + 'const copy = { ...import.meta }; Object.defineProperty(copy, "url", { value: "file:///other/entry.mjs" });', + "const copy = { ...globalThis }; copy.URL = replacement;", + "function update(globalThis) { const ambient = globalThis; ambient.URL = replacement; } update({});", + "const define = Object.defineProperty.bind(Object);", + 'Object.defineProperty(...[import.meta, "unrelated", { value: "metadata" }]);', + 'let meta = import.meta; meta = {}; let base = import.meta.url; base = "file:///other/entry.mjs";', + ].map((mutation, index) => ({ + source: `nested/unrelated-meta-${index}.mjs`, + expression: `import { Worker } from "node:worker_threads"; ${mutation} new Worker(new URL("./worker.js", import.meta.url));`, + specifier: "./worker.js", + resolved: "nested/worker.js", + })), + { + source: "nested/parent.mjs", + expression: + 'new Worker(new URL("../parent-worker.js", import.meta.url));', + specifier: "../parent-worker.js", + resolved: "parent-worker.js", + }, + { + source: "nested/dot-segments.mjs", + expression: + 'new Worker(new URL("./child/.././worker.js", import.meta.url));', + specifier: "./child/.././worker.js", + resolved: "nested/worker.js", + }, + { + source: "nested/template.mjs", + expression: + "new Worker(new URL(`./template-worker.js`, import.meta.url));", + specifier: "./template-worker.js", + resolved: "nested/template-worker.js", + }, + ...[ + 'new URL("./worker.js", import.meta.url) as URL', + 'new URL("./worker.js", import.meta.url)', + 'new URL("./worker.js", import.meta.url)!', + 'new URL("./worker.js", import.meta.url) satisfies URL', + '((new URL("./worker.js", import.meta.url) as URL)!) satisfies URL', + 'new (URL as typeof URL)("./worker.js", import.meta.url)', + 'new URL("./worker.js" as string, import.meta.url as string)', + 'new URL("./worker.js", (import.meta as ImportMeta).url)', + 'new globalThis.URL("./worker.js", import.meta.url)', + 'new window.URL("./worker.js", import.meta.url)', + 'new self.URL("./worker.js", import.meta.url)', + 'new global.URL("./worker.js", import.meta.url)', + 'new ((globalThis as typeof globalThis)["URL"]!)("./worker.js", import.meta.url)', + ].map((urlExpression, index) => ({ + source: `nested/typed-${index}.ts`, + expression: `new Worker(${urlExpression});`, + specifier: "./worker.js", + resolved: "nested/worker.js", + })), + { + source: "nested/space.mjs", + expression: + 'new Worker(new URL("./space%20worker.js?worker=1#entry", import.meta.url));', + specifier: "./space%20worker.js?worker=1#entry", + resolved: "nested/space worker.js", + }, + { + source: "nested/percent.mjs", + expression: + 'new Worker(new URL("./percent%2520worker.js", import.meta.url));', + specifier: "./percent%2520worker.js", + resolved: "nested/percent%20worker.js", + }, + { + source: "nested/literal-percent.mjs", + expression: + 'new Worker(new URL("./percent%25worker.js", import.meta.url));', + specifier: "./percent%25worker.js", + resolved: "nested/percent%worker.js", + }, + ...["?value=", "#value="].map((suffix, index) => { + const specifier = `./worker.js${suffix}%2F%2E%5C%00%FF%GG%2%\\\0`; + return { + source: `nested/suffix-${index}.mjs`, + expression: `new Worker(new URL(${JSON.stringify(specifier)}, import.meta.url));`, + specifier, + resolved: "nested/worker.js", + }; + }), + { + source: "bundled/entry.mjs", + expression: + 'self.webpackChunkdemo.push([[1], {1: function(module, exports, require) { new Worker(new URL("./worker.js", import.meta.url)); }, "./worker.js": function(module) { module.exports = 42; }}]);', + specifier: "./worker.js", + resolved: "bundled/worker.js", + }, + { + source: "nested/literal.mjs", + expression: 'new Worker("./literal-worker.js");', + specifier: "./literal-worker.js", + resolved: "nested/literal-worker.js", + }, + ]; + await writeFixtureFiles(root, { + ...Object.fromEntries( + cases.flatMap(({ source, expression, resolved }) => [ + [source, expression], + [resolved, `self.postMessage(${JSON.stringify(resolved)});`], + ]), + ), + "nested/percent worker.js": "self.postMessage('double-decoding decoy');", + }); + + const result = await analyzeJavaScriptDirectory("CLI", cli, root); + expect(result.statistics.parse_failures).toBe(0); + expect(workerReferences(result.graph)).toEqual( + expect.arrayContaining( + cases.map(({ source, specifier, resolved }) => ({ + source, + declared_specifier: specifier, + expression: null, + resolved_path: resolved, + mapped_paths: [resolved], + })), + ), + ); + expect(workerReferences(result.graph)).toHaveLength(cases.length); + expect(result.graph.nodes).toContainEqual( + expect.objectContaining({ + kind: "javascript-module", + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ module_key: "./worker.js" }), + }), + ]), + }), + ); + }, +); + +cliTest( + "keeps unsupported Worker URL expressions and missing exact files unresolved through the CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-worker-url-unknown-"); + const malformedPercentPaths = [ + ["./worker%.js", "worker%.js"], + ["./worker%2.js", "worker%2.js"], + ["./worker%GG.js", "worker%GG.js"], + ["./space%20worker%GG.js", "space worker%GG.js"], + ]; + const expressions = { + "dynamic-path.mjs": "new Worker(new URL(path, import.meta.url));", + "absolute.mjs": + 'import { Worker } from "node:worker_threads"; new Worker(new URL("/worker.js", import.meta.url));', + "typed-apply-meta.ts": + 'import { Worker } from "node:worker_threads"; Object.defineProperty.apply(Object, ([import.meta, "url", { value: "file:///other.mjs" }] as const)); new Worker(new URL("./worker.js", import.meta.url));', + "overwritten-qualified-url.mjs": + 'globalThis.URL = replacement; new Worker(new globalThis.URL("./worker.js", import.meta.url));', + "shadowed-qualified-url.mjs": + 'function start(window) { new Worker(new window.URL("./worker.js", import.meta.url)); }', + ...Object.fromEntries( + ["globalThis.URL", "URL", 'globalThis["URL"]'].map((target, index) => [ + `overwritten-url-${index}.mjs`, + `import { Worker } from "node:worker_threads"; const RealURL = URL; ${target} = class { constructor() { return new RealURL("./other.js", import.meta.url); } }; new Worker(new URL("./worker.js", import.meta.url));`, + ]), + ), + ...Object.fromEntries( + [ + "delete globalThis.URL;", + "({ URL: globalThis.URL } = replacements);", + "globalThis[key] = replacement;", + 'Object.defineProperty(globalThis, "URL", { value: replacement });', + 'Reflect.set(globalThis, "URL", replacement);', + "globalThis.globalThis.URL = replacement;", + "globalThis.global.URL = replacement;", + "const ambient = globalThis; ambient.URL = replacement;", + "const { globalThis: ambient } = globalThis; ambient.URL = replacement;", + ].map((mutation, index) => [ + `mutated-url-${index}.mjs`, + `import { Worker } from "node:worker_threads"; ${mutation} new Worker(new URL("./worker.js", import.meta.url));`, + ]), + ), + ...Object.fromEntries( + [ + 'import.meta.url = "file:///other/entry.mjs";', + "delete import.meta.url;", + 'Object.defineProperty(import.meta, "url", { value: "file:///other/entry.mjs" });', + "import.meta[key] = replacement;", + 'const meta = import.meta; const alias = meta; alias.url = "file:///other/entry.mjs";', + 'let meta = import.meta; meta.url = "file:///other/entry.mjs"; meta = {};', + 'const define = Object.defineProperty; define(import.meta, "url", { value: "file:///other/entry.mjs" });', + 'const set = Reflect.set.bind(Reflect); set(import.meta, "url", "file:///other/entry.mjs");', + 'Object.defineProperty.bind(Object, import.meta, "url")({ value: "file:///other/entry.mjs" });', + 'Object.assign(...[import.meta, { url: "file:///other/entry.mjs" }]);', + 'Object.defineProperty(...[import.meta, "url", { value: "file:///other/entry.mjs" }]);', + ].map((mutation, index) => [ + `mutated-import-meta-${index}.mjs`, + `import { Worker } from "node:worker_threads"; ${mutation} new Worker(new URL("./worker.js", import.meta.url));`, + ]), + ), + ...Object.fromEntries( + malformedPercentPaths.map(([specifier], index) => [ + `malformed-percent-${index}.mjs`, + `import { Worker } from "node:worker_threads"; new Worker(new URL(${JSON.stringify(specifier)}, import.meta.url));`, + ]), + ), + "dynamic-base.mjs": 'new Worker(new URL("./worker.js", base));', + "shadowed.mjs": + 'function start(URL) { return new Worker(new URL("./worker.js", import.meta.url)); }', + "typed-shadowed.ts": + 'function start(URL: typeof globalThis.URL) { return new Worker((new (URL as typeof globalThis.URL)("./worker.js", import.meta.url) as URL)!); }', + "typed-dynamic-path.ts": + "new Worker((new URL(path as string, import.meta.url) as URL)!);", + "typed-dynamic-base.ts": + 'new Worker((new URL("./worker.js", base as string) as URL)!);', + "call.mjs": 'new Worker(URL("./worker.js", import.meta.url));', + "custom.mjs": + 'new Worker(new custom.URL("./worker.js", import.meta.url));', + "extension.mjs": + 'new Worker(new URL("./extensionless", import.meta.url));', + "directory.mjs": 'new Worker(new URL("./directory", import.meta.url));', + "directory-dot.mjs": + 'new Worker(new URL("./worker.js/.", import.meta.url));', + "directory-parent.mjs": + 'new Worker(new URL("./worker.js/child/..", import.meta.url));', + "empty-segment.mjs": + 'new Worker(new URL("./child//../worker.js", import.meta.url));', + "package.mjs": + 'new Worker(new URL("./package-worker", import.meta.url));', + "external.mjs": + 'new Worker(new URL("https://example.test/worker.js", import.meta.url));', + "empty.mjs": 'new Worker(new URL("", import.meta.url));', + "query.mjs": 'new Worker(new URL("?worker=1", import.meta.url));', + "whitespace.mjs": + 'new Worker(new URL(" ./worker.js ", import.meta.url));', + "control.mjs": 'new Worker(new URL("./worker\\t.js", import.meta.url));', + "leading-control.mjs": + 'new Worker(new URL("\\u0001./worker.js", import.meta.url));', + }; + const knownSpecifiers: Readonly> = { + "extension.mjs": "./extensionless", + "directory.mjs": "./directory", + "directory-dot.mjs": "./worker.js/.", + "directory-parent.mjs": "./worker.js/child/..", + "empty-segment.mjs": "./child//../worker.js", + "package.mjs": "./package-worker", + "external.mjs": "https://example.test/worker.js", + }; + await writeFixtureFiles(root, { + ...expressions, + ...Object.fromEntries( + malformedPercentPaths.map(([, decoy]) => [ + decoy, + "self.postMessage('malformed percent pathname decoy');", + ]), + ), + "worker.js": "self.postMessage('existing decoy');", + "other.js": "self.postMessage('custom URL result decoy');", + "extensionless.js": "self.postMessage('extension decoy');", + "directory/index.js": "self.postMessage('index decoy');", + "package-worker/package.json": JSON.stringify({ main: "selected.js" }), + "package-worker/selected.js": "self.postMessage('package main decoy');", + }); + + const result = await analyzeJavaScriptDirectory("CLI", cli, root); + expect(result.statistics.parse_failures).toBe(0); + const references = workerReferences(result.graph); + expect(references).toHaveLength(Object.keys(expressions).length); + for (const [source, expression] of Object.entries(expressions)) { + const specifier = knownSpecifiers[source] ?? null; + expect(references).toContainEqual( + expect.objectContaining({ + source, + declared_specifier: specifier, + expression: + specifier === null + ? expression.slice( + expression.indexOf("new Worker("), + expression.lastIndexOf(";"), + ) + : null, + resolved_path: null, + mapped_paths: [], + }), + ); + } + }, +); + +cliTest( + "bounds repeated ambient alias branches through the compiled CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-worker-alias-branches-"); + const aliases = [ + "const alias0 = globalThis;", + ...Array.from( + { length: 40 }, + (_, index) => + `const alias${index + 1} = flag${index} ? alias${index} : alias${index};`, + ), + "alias40.URL = replacement;", + ].join("\n"); + const splitAliases = [ + "const alias0 = globalThis;", + ...Array.from( + { length: 40 }, + (_, index) => + `const left${index} = alias${index}; const right${index} = alias${index}; const alias${index + 1} = flag${index} ? left${index} : right${index};`, + ), + "alias40.URL = replacement;", + ].join("\n"); + const memberAliases = [ + "const alias0 = globalThis;", + ...Array.from( + { length: 40 }, + (_, index) => + `const alias${index + 1} = flag${index} ? alias${index}.globalThis : alias${index}.window;`, + ), + "alias40.URL = replacement;", + ].join("\n"); + const expression = 'new Worker(new URL("./worker.js", import.meta.url))'; + await writeFixtureFiles(root, { + "entry.mjs": `import { Worker } from "node:worker_threads";\n${aliases}\n${expression};`, + "split.mjs": `import { Worker } from "node:worker_threads";\n${splitAliases}\n${expression};`, + "member.mjs": `import { Worker } from "node:worker_threads";\n${memberAliases}\n${expression};`, + "worker.js": "self.postMessage('existing alias-mutation decoy');", + }); + + const result = await analyzeJavaScriptDirectory( + "CLI", + { run: (invocation) => cli.run({ ...invocation, timeoutMs: 20_000 }) }, + root, + ); + expect(result.statistics.parse_failures).toBe(0); + const references = workerReferences(result.graph); + expect(references).toHaveLength(3); + expect(references).toEqual( + expect.arrayContaining( + ["entry.mjs", "split.mjs", "member.mjs"].map((source) => ({ + source, + declared_specifier: null, + expression, + resolved_path: null, + mapped_paths: [], + })), + ), + ); + }, +); + +cliTest( + "keeps Node Worker filesystem strings unresolved through the compiled CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-node-worker-filesystem-"); + const cases = [ + { + prefix: 'import { Worker } from "node:worker_threads";', + expression: 'new Worker("./worker.js")', + }, + { + prefix: 'import { Worker as ThreadWorker } from "worker_threads";', + expression: 'new ThreadWorker("/worker.js")', + }, + { + prefix: 'import * as threads from "node:worker_threads";', + expression: 'new threads.Worker("./worker.js")', + }, + { + prefix: 'import { Worker } from "node:worker_threads";', + expression: 'new (Worker as typeof Worker)("/worker.js")', + }, + { + prefix: 'import * as threads from "node:worker_threads";', + expression: 'new ((threads as typeof threads).Worker!)("./worker.js")', + }, + ].map((fixture, index) => ({ + ...fixture, + source: `nested/node-literal-${index}.ts`, + })); + await writeFixtureFiles(root, { + ...Object.fromEntries( + cases.map(({ prefix, expression, source }) => [ + source, + `${prefix}\n${expression};`, + ]), + ), + "worker.js": "self.postMessage('filesystem-root decoy');", + "nested/worker.js": "self.postMessage('source-sibling decoy');", + }); + + const result = await analyzeJavaScriptDirectory("CLI", cli, root); + expect(result.statistics.parse_failures).toBe(0); + const references = workerReferences(result.graph); + expect(references).toHaveLength(cases.length); + for (const { source, expression } of cases) + expect(references).toContainEqual({ + source, + declared_specifier: null, + expression, + resolved_path: null, + mapped_paths: [], + }); + }, +); + +// Windows cannot materialize the pipe-named directory needed for this decoy. +cliTest.skipIf(process.platform === "win32")( + "keeps file-URL drive letters distinct from encoded POSIX paths through the compiled CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-worker-url-drive-"); + await writeFixtureFiles(root, { + "dist/drive.mjs": 'new Worker(new URL("c|/worker.js", import.meta.url));', + "dist/encoded.mjs": + 'new Worker(new URL("c%7C/worker.js", import.meta.url));', + "dist/c|/worker.js": "self.postMessage('existing relative-path decoy');", + }); + + const result = await analyzeJavaScriptDirectory("CLI", cli, root); + expect(result.statistics.parse_failures).toBe(0); + const references = workerReferences(result.graph); + expect(references).toHaveLength(2); + expect(references).toEqual( + expect.arrayContaining([ + { + source: "dist/drive.mjs", + declared_specifier: "c|/worker.js", + expression: null, + resolved_path: null, + mapped_paths: [], + }, + { + source: "dist/encoded.mjs", + declared_specifier: "c%7C/worker.js", + expression: null, + resolved_path: "dist/c|/worker.js", + mapped_paths: ["dist/c|/worker.js"], + }, + ]), + ); + }, +);