From f91b7f8009c9af27a5e0148e3b07d20bfe3b7a2a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:04:57 +0800 Subject: [PATCH] fix(javascript): distinguish resolver discovery from module loading Preserve load and resolve operations through semantic facts, worker transfer and graph Evidence. Only loads propagate executable ownership. Project ambient member writes and escapes through the shared path- and site-aware mutation owner; retain source literals, bases and uncertainty. Cover ownership and mutation boundaries through CLI and MCP. --- docs/javascript-artifact-reconstruction.md | 10 + .../JavaScriptArtifactGraphFindings.ts | 3 + .../javascriptSemanticAmbientWrites.test.ts | 27 ++- .../javascriptSemanticAmbientWrites.ts | 37 +++- .../javascript/javascriptSemanticAnalysis.ts | 15 ++ .../javascriptSemanticMemberMutations.ts | 31 ++- .../javascriptSemanticRequireOrigin.ts | 26 ++- .../javascriptStaticAnalysisCalls.ts | 12 +- ...iptStaticAnalysisLoaderInvocations.test.ts | 8 +- .../javascriptStaticAnalysisState.ts | 1 + .../javascriptStaticAnalysisTypes.ts | 2 + .../JavaScriptAnalysisWorkerSchemas.ts | 1 + .../javascriptIndirectLoaderOwners.test.ts | 180 ++++++++++++++++++ .../javascriptLiteralLoaderOwnership.ts | 15 +- 14 files changed, 351 insertions(+), 17 deletions(-) diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 719e0c93b7..6f0114632e 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -777,6 +777,16 @@ does not change the source file or call-site Evidence identity. If the filename cannot be proven, REA keeps the require target unresolved rather than silently resolving it from the caller's directory. +Module-reference graph edges, including native-addon `loads` edges, retain +`properties.operation`: `load` identifies +an import or loader invocation that may execute the target; `resolve` identifies +a proven `require.resolve()` lookup that discovers a target without executing it. +Both retain the exact specifier, resolution mode, selected resolution base, and +call-site Evidence. Resolve-only edges do not propagate application ownership or +create executable-import gaps when their targets are missing. A modified +`.resolve` member remains an uncertain `load`, because its replacement may execute +code. These static facts do not establish that a call runs at runtime. + ESM relative module paths, legacy package subpaths without exports, and selected package exports targets use URL suffix handling and one percent-decoding pass. Bare specifiers retain their complete query/fragment text for exports key matching. diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index 69adac5a1b..98f9644600 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -205,6 +205,7 @@ const addReference = ( ? null : resolveReference(context, input.file.path, input.value, input.coverage); if ( + input.value.operation === "load" && (input.value.kind === "static-import" || input.value.kind === "dynamic-import" || input.value.kind === "require") && @@ -222,6 +223,7 @@ const addReference = ( resolved !== null && resolved.path !== null && resolved.file !== null && + input.value.operation === "load" && (input.value.kind === "static-import" || input.value.kind === "dynamic-import" || input.value.kind === "require") @@ -305,6 +307,7 @@ const addReference = ( relation: resolved?.file?.kind === "native-addon" ? "loads" : "imports", properties: { kind: input.value.kind, + operation: input.value.operation, specifier: input.value.specifier, expression: input.value.expression, resolution_base_path: input.value.resolution_base_path ?? null, diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts index 0d785dbef1..f2deb2ba51 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts @@ -1,7 +1,10 @@ import { expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; -import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js"; +import { + indexJavaScriptBindingsSteps, + indexJavaScriptBindingsWithReceiversSteps, +} from "./javascriptSemanticAnalysis.js"; import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; import { parseJavaScriptSource } from "./javascriptSourceParser.js"; @@ -103,3 +106,25 @@ new Worker(new URL("./worker.js", import.meta.url));`; ); expect(worker?.specifier).toBe(specifier); }); + +it("retains getter-derived ambient escapes once per invoked read site", () => { + const source = `const box={loader:require,get trigger(){this.loader.resolve=this.loader;return 0}}; +box.trigger; +box.trigger;`; + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected getter fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + const effects = index.bindings.ambientEscapeEffects.filter( + ({ path }) => + JSON.stringify(path) === JSON.stringify(["global", "require", null]), + ); + expect( + effects.map(({ node }) => + node === undefined ? null : source.slice(node.start ?? 0, node.end ?? 0), + ), + ).toEqual(["box.trigger", "box.trigger"]); + expect(effects[0]?.node).not.toBe(effects[1]?.node); +}); diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index a3e7c6d8ff..5035c6944b 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -49,6 +49,36 @@ const canonicalAmbientPath = ( type AmbientPath = readonly (string | null)[]; +const retainedAmbientEffects = new WeakMap< + JavaScriptSemanticAnalysisState, + Map> +>(); + +/** Retain canonical ambient effects once, whether discovered by syntax or receiver expansion. */ +export const retainSemanticAmbientEffect = ( + state: JavaScriptSemanticAnalysisState, + kind: "write" | "escape", + path: AmbientPath, + node: t.Node | undefined, +): boolean => { + const selected = kind === "escape" ? [...path, null] : path; + const byNode = retainedAmbientEffects.get(state) ?? new Map(); + const keys = byNode.get(node) ?? new Set(); + const key = JSON.stringify([kind, selected]); + if (keys.has(key)) return false; + keys.add(key); + byNode.set(node, keys); + retainedAmbientEffects.set(state, byNode); + if (kind === "write") { + state.ambientWrites.retain(selected); + state.ambientWriteEffects.push({ path: selected, node }); + } else { + state.ambientEscapes.retain(selected); + state.ambientEscapeEffects.push({ path: selected, node }); + } + return true; +}; + /** Undefined means a cyclic or conflicting projection can select any depth. */ type AmbientProjection = AmbientPath | undefined; @@ -641,17 +671,14 @@ export function* indexJavaScriptAmbientWritesSteps( ...path.slice(2), ]); for (const resolved of paths) { - state.ambientWrites.retain(resolved); - state.ambientWriteEffects.push({ path: resolved, node }); + retainSemanticAmbientEffect(state, "write", resolved, node); } } for (const { node, target, browserReceiver, projection } of escapes) for (const path of ambientWrites(target, node, true, projection)) // Only proven browser APIs keep their global receiver from user code. if (!(browserReceiver && path.length === 1 && path[0] === "global")) { - const escaped = [...path, null]; - state.ambientEscapes.retain(escaped); - state.ambientEscapeEffects.push({ path: escaped, node }); + retainSemanticAmbientEffect(state, "escape", path, node); } // A function may call a browser method replaced later in source order. if ( diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index b22047c67f..adfe1b6429 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -68,6 +68,7 @@ import { semanticBundledRequireOrigin } from "./javascriptBundledRequireOrigin.j import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js"; import { semanticEffectTiming } from "./javascriptSemanticMutationInitializers.js"; import { + createAmbientPathResolver, indexJavaScriptAmbientWritesSteps, isUnmodifiedGlobalReference, } from "./javascriptSemanticAmbientWrites.js"; @@ -279,6 +280,20 @@ const visitSemanticReferenceEffectsAtSite = ( } else if (lookup.kind === "ambient" || lookup.kind === "uncertain-ambient") { for (const node of state.ambientBindingWrites.get(root.name) ?? []) if (!retain("ambient-write", node)) return metadata; + const ambientRoots = createAmbientPathResolver(state, false)(root); + for (const ambientRoot of ambientRoots) { + const selectedPath = [...ambientRoot, ...path]; + for (const [kind, effects] of [ + ["member-write", state.ambientWriteEffects], + ["escape", state.ambientEscapeEffects], + ] as const) + for (const effect of effects) + if ( + semanticPathsOverlap(selectedPath, effect.path) && + !retain(kind, effect.node, effect.path.slice(ambientRoot.length)) + ) + return metadata; + } } return metadata; }; diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 066fcd64c4..c3b1fe0616 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -1,4 +1,8 @@ import * as t from "@babel/types"; +import { + createAmbientPathResolver, + retainSemanticAmbientEffect, +} from "./javascriptSemanticAmbientWrites.js"; import { isIntrinsicMapLookup } from "./javascriptSemanticMapOperations.js"; import { isIntrinsicTypedArrayConstruction, @@ -522,6 +526,7 @@ export function* collectSemanticMemberMutationsSteps( iterableReferenceKeys.set(reference.node, keys); iterableReferences.push(reference); }; + const ambientPaths = createAmbientPathResolver(state, true); const markValue = ( node: t.Node, path: PropertyPath, @@ -577,6 +582,18 @@ export function* collectSemanticMemberMutationsSteps( if (t.isIdentifier(expression)) { const binding = resolveBinding(expression, expression.name); if (binding === undefined) { + for (const path of ambientPaths( + expression, + current.originAt, + effect === "escape", + current.path.map((key) => + key === null || typeof key === "object" ? null : String(key), + ), + )) + if (retainSemanticAmbientEffect(state, effect, path, mutation)) { + recordedEffectCount += 1; + clearSemanticValueCaches(state); + } const processOrigin = semanticGlobalProcessOrigin(expression, state); if (processOrigin !== undefined) recordRequiredEffect( @@ -1478,6 +1495,7 @@ const observableMutationPaths = ( prefix: PropertyPath, ) => boolean; } => { + const ambientPaths = createAmbientPathResolver(state, false); const bindingNodes = new Map(); const expressions = new WeakMap(); const pendingExpressions: { @@ -1617,6 +1635,8 @@ const observableMutationPaths = ( ["globalThis", "global", "window", "self"].includes(expression.name) ) (observer.origins ??= new Set()).add("ambient"); + else if (ambientPaths(expression).length > 0) + (observer.origins ??= new Set()).add("opaque"); } else if ( t.isMemberExpression(expression) || t.isOptionalMemberExpression(expression) @@ -1686,11 +1706,12 @@ const observableMutationPaths = ( add("opaque"); } else if (origin === "array-prototype" || origin === "opaque") add(origin); - else if ( - origin === "ambient" && - semanticPropertyPathKeyMatches(key, "Array") - ) - add("array"); + else if (origin === "ambient") + add( + semanticPropertyPathKeyMatches(key, "Array") + ? "array" + : "opaque", + ); else if ( origin === "array" && semanticPropertyPathKeyMatches(key, "prototype") diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index 6b6d93824b..afc8bfb41f 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -654,6 +654,7 @@ const loaderInvocationInterop = (state: JavaScriptSemanticAnalysisState) => { }; export interface JavaScriptRequireCallFact { + readonly operation: "load" | "resolve"; readonly loader: JavaScriptRequireLoaderFact; readonly arguments: | readonly t.CallExpression["arguments"][number][] @@ -670,7 +671,12 @@ export const semanticRequireCallAt = ( factoryBindingIds: ReadonlySet, ): JavaScriptRequireCallFact | undefined => { if (semanticModuleRequireCallAt(node, state)) - return { loader: "unknown", arguments: undefined, sourceRelative: false }; + return { + operation: "load", + loader: "unknown", + arguments: undefined, + sourceRelative: false, + }; const callee = unwrapJavaScriptExpression(node.callee).node; if ( (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && @@ -680,7 +686,12 @@ export const semanticRequireCallAt = ( selectedRequireSourcesAt(callee, node, state, factoryBindingIds)?.loader === "local" ) - return { loader: "local", arguments: undefined, sourceRelative: false }; + return { + operation: "load", + loader: "local", + arguments: undefined, + sourceRelative: false, + }; if ( (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && semanticStaticPropertyKey(callee.property, callee.computed) === "resolve" @@ -691,6 +702,13 @@ export const semanticRequireCallAt = ( state, factoryBindingIds, ); + if (loader === "unknown") + return { + operation: "load", + loader, + arguments: undefined, + sourceRelative: false, + }; // Bundler factory loaders can share the `require` shape, but their // `.resolve` member is not Node's module resolver. if (loader === "commonjs-loader" || loader === "create-require") { @@ -701,6 +719,7 @@ export const semanticRequireCallAt = ( factoryBindingIds, ); return { + operation: "resolve", loader: selected?.loader ?? loader, arguments: node.arguments, sourceRelative: @@ -726,6 +745,7 @@ export const semanticRequireCallAt = ( ); if (selected !== undefined || loader !== "local") return { + operation: "load", loader: selected?.loader ?? loader, arguments: node.arguments, sourceRelative: selected?.sourceRelative ?? loader !== "create-require", @@ -767,6 +787,7 @@ export const semanticRequireCallAt = ( factoryBindingIds, ); return { + operation: "load", loader: invocation.path[1] === "local" ? "local" @@ -790,6 +811,7 @@ export const semanticRequireCallAt = ( return factory === undefined ? undefined : { + operation: "load", loader: factory, arguments: node.arguments.slice(t.isIdentifier(node.callee) ? 0 : 1), sourceRelative: true, diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 5bfa12e247..1fc7b56304 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -91,6 +91,7 @@ export const inspectCall = ( node, kind: "require", specifier: requireCall.specifier, + operation: requireCall.operation, ...(requireCall.resolutionBasePath === undefined ? {} : { resolutionBasePath: requireCall.resolutionBasePath }), @@ -270,6 +271,7 @@ const requireCallSpecifier = ( resolveMemberChanged = false, ): { readonly specifier: string | undefined; + readonly operation: "load" | "resolve"; readonly resolutionBasePath?: string; } | null => { const call = semanticRequireCallAt( @@ -284,9 +286,13 @@ const requireCallSpecifier = ( !call.sourceRelative ) { context.accumulator.unknownFindings += 1; - return { specifier: undefined }; + return { + specifier: undefined, + operation: resolveMemberChanged ? "load" : call.operation, + }; } return { + operation: call.operation, specifier: argumentValue(call.arguments?.[0]), ...(call.sourceRelativePath === undefined ? {} @@ -322,6 +328,7 @@ export const finishModuleRequireCalls = ( node, kind: "require", specifier: call.specifier, + operation: call.operation, ...(call.resolutionBasePath === undefined ? {} : { resolutionBasePath: call.resolutionBasePath }), @@ -947,10 +954,11 @@ export const addReference = ( : { specifier, expression: null }; addLocatedFinding(context, { collection: context.accumulator.references, - key: `reference\0${input.kind}\0${reference.specifier ?? reference.expression}\0${input.resolutionBasePath ?? ""}`, + key: `reference\0${input.kind}\0${input.operation ?? "load"}\0${reference.specifier ?? reference.expression}\0${input.resolutionBasePath ?? ""}`, node: input.node, value: { kind: input.kind, + operation: input.operation ?? "load", ...reference, ...(input.resolutionBasePath === undefined ? {} diff --git a/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts b/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts index e65a02a6a7..a5d1aafebc 100644 --- a/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts @@ -157,7 +157,13 @@ it("retains a mutated require.resolve request as an unknown fact", () => { analyzeJavaScriptStaticSource(source).references.filter( ({ kind }) => kind === "require", ), - ).toEqual([expect.objectContaining({ kind: "require", specifier: null })]); + ).toEqual([ + expect.objectContaining({ + kind: "require", + operation: "load", + specifier: null, + }), + ]); }); it("does not treat bundler factory resolve members as Node resolvers", () => { diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index dc42744ef0..9a8b0c7c81 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -110,6 +110,7 @@ export interface JavaScriptFindingContext { /** Candidate import, require, worker, or service-worker reference. */ export interface JavaScriptReferenceInput { + readonly operation?: JavaScriptStaticReference["operation"]; readonly node: t.Node; readonly kind: JavaScriptStaticReference["kind"]; readonly specifier: string | undefined; diff --git a/src/domain/javascript/javascriptStaticAnalysisTypes.ts b/src/domain/javascript/javascriptStaticAnalysisTypes.ts index a2adfb811a..843670a138 100644 --- a/src/domain/javascript/javascriptStaticAnalysisTypes.ts +++ b/src/domain/javascript/javascriptStaticAnalysisTypes.ts @@ -22,6 +22,8 @@ export interface JavaScriptSourceRange { } interface JavaScriptStaticReferenceState { + /** Resolution discovers a target without loading or executing it. */ + readonly operation: "load" | "resolve"; readonly kind: | "static-import" | "dynamic-import" diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index 2532c3c71d..6d60d651a4 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -296,6 +296,7 @@ const electron = z })); const referenceShape = { ...findingContext, + operation: z.enum(["load", "resolve"]), resolution_base_path: z.string().exactOptional(), kind: z.enum([ "static-import", diff --git a/tests/acceptance/applications/javascriptIndirectLoaderOwners.test.ts b/tests/acceptance/applications/javascriptIndirectLoaderOwners.test.ts index 7725003d7f..886c79d0c7 100644 --- a/tests/acceptance/applications/javascriptIndirectLoaderOwners.test.ts +++ b/tests/acceptance/applications/javascriptIndirectLoaderOwners.test.ts @@ -40,9 +40,112 @@ const cases = [ }, { name: "require.resolve does not execute its resolved module", + resolverSpecifier: "../shared/window.cjs", + resolverTarget: "shared/window.cjs", load: 'require.resolve("../shared/window.cjs");', possible: false, }, + { + name: "resolution discovery does not suppress a subsequent executable load", + resolverSpecifier: "../shared/window.cjs", + resolverTarget: "shared/window.cjs", + load: 'require.resolve("../shared/window.cjs");require("../shared/window.cjs");', + possible: true, + }, + { + name: "sibling wrapper property writes preserve resolver identity", + resolverSpecifier: "../shared/window.cjs", + resolverTarget: "shared/window.cjs", + load: 'require.unrelated=custom;require.resolve("../shared/window.cjs");', + possible: false, + }, + { + name: "later resolver writes do not change an earlier lookup", + resolverSpecifier: "../shared/window.cjs", + resolverTarget: "shared/window.cjs", + load: 'require.resolve("../shared/window.cjs");require.resolve=custom;', + possible: false, + }, + { + name: "conditional wrapper resolver writes retain possible execution", + uncertainResolver: true, + load: 'if(flag)require.resolve=custom;require.resolve("../shared/window.cjs");', + possible: true, + }, + { + name: "escaped wrapper resolver retains possible execution", + uncertainResolver: true, + load: 'consume(require);require.resolve("../shared/window.cjs");', + possible: true, + }, + { + name: "invoked getter retains ambient resolver mutation", + uncertainResolver: true, + load: 'const box={loader:require,get trigger(){this.loader.resolve=this.loader;return 0}};box.trigger;require.resolve("../shared/window.cjs");', + possible: true, + }, + { + name: "invoked getter retains qualified ambient resolver mutation", + uncertainResolver: true, + load: 'const box={loader:globalThis.require,get trigger(){this.loader.resolve=this.loader;return 0}};box.trigger;require.resolve("../shared/window.cjs");', + possible: true, + }, + { + name: "unused getter preserves ambient resolver identity", + resolverSpecifier: "../shared/window.cjs", + resolverTarget: "shared/window.cjs", + load: 'const box={loader:require,get trigger(){this.loader.resolve=this.loader;return 0}};require.resolve("../shared/window.cjs");', + possible: false, + }, + { + name: "later getter read preserves earlier ambient resolution", + resolverSpecifier: "../shared/window.cjs", + resolverTarget: "shared/window.cjs", + load: 'const box={loader:require,get trigger(){this.loader.resolve=this.loader;return 0}};require.resolve("../shared/window.cjs");box.trigger;', + possible: false, + }, + { + name: "invoked getter retains lexical created resolver mutation", + uncertainResolver: true, + mainFile: "main.mjs", + load: 'import {createRequire} from "node:module";const loader=createRequire(import.meta.url);const box={loader,get trigger(){this.loader.resolve=this.loader;return 0}};box.trigger;loader.resolve("../shared/window.cjs");', + possible: true, + }, + { + name: "replaced resolver retains possible executable ownership", + uncertainResolver: true, + load: 'require.resolve=require;require.resolve("../shared/window.cjs");', + possible: true, + }, + { + name: "created resolver retains discovery without module execution", + resolverSpecifier: "../shared/window.cjs", + resolverTarget: "shared/window.cjs", + mainFile: "main.mjs", + load: 'import {createRequire} from "node:module";const load=createRequire(import.meta.url);load.resolve("../shared/window.cjs");', + possible: false, + }, + { + name: "missing resolver target does not create executable ownership gaps", + missingTargetOperation: "resolve", + resolverSpecifier: "../missing/window.cjs", + resolverTarget: null, + load: 'require.resolve("../missing/window.cjs");', + possible: false, + }, + { + name: "dynamic executable target retains application ownership uncertainty", + unresolvedExecutable: true, + load: "require(runtimeTarget);", + possible: true, + }, + { + name: "modified created resolver retains possible executable ownership", + uncertainResolver: true, + mainFile: "main.mjs", + load: 'import {createRequire} from "node:module";const load=createRequire(import.meta.url);load.resolve=load;load.resolve("../shared/window.cjs");', + possible: true, + }, { name: "require.main is not an implicit module loader", load: 'require.main("../shared/window.cjs");', @@ -256,10 +359,87 @@ cliTest.for( root, ); expect(statistics.parse_failures).toBe(0); + if ("resolverSpecifier" in testCase) { + expect(graph.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relation: "imports", + properties: expect.objectContaining({ + kind: "require", + operation: "resolve", + specifier: testCase.resolverSpecifier, + resolution_mode: "require", + resolved_path: testCase.resolverTarget, + }), + }), + ]), + ); + } + + if ("uncertainResolver" in testCase) + expect(graph.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relation: "imports", + properties: expect.objectContaining({ + kind: "require", + operation: "load", + specifier: null, + }), + evidence: expect.objectContaining({ + coverage: expect.objectContaining({ status: "partial" }), + }), + }), + ]), + ); const roles = graph.nodes.filter( ({ kind }) => kind === "electron-renderer", ); expect(roles).toHaveLength(1); + if ( + "missingTargetOperation" in testCase || + "unresolvedExecutable" in testCase + ) { + const baseLimitation = + "Inventoried package metadata does not establish one application root for this source; the loadFile base is unknown."; + const executable = "unresolvedExecutable" in testCase; + expect(graph.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relation: "imports", + properties: expect.objectContaining({ + operation: executable ? "load" : "resolve", + specifier: executable ? null : "../missing/window.cjs", + expression: executable ? "require(runtimeTarget)" : null, + resolved_path: null, + resolution_status: executable ? null : "not-found", + }), + evidence: expect.objectContaining({ + coverage: expect.objectContaining({ status: "complete" }), + limitations: expect.arrayContaining([ + "Static syntax does not prove that this relationship executes at runtime.", + ]), + }), + }), + ]), + ); + expect(roles[0]?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + limitations: executable ? [baseLimitation] : [], + }), + evidence: expect.objectContaining({ + coverage: expect.objectContaining({ status: "complete" }), + limitations: executable + ? expect.arrayContaining([baseLimitation]) + : expect.not.arrayContaining([baseLimitation]), + }), + }), + ]), + ); + } + expect(roles[0]?.observations).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/tests/acceptance/applications/javascriptLiteralLoaderOwnership.ts b/tests/acceptance/applications/javascriptLiteralLoaderOwnership.ts index a672fe7092..5908002b12 100644 --- a/tests/acceptance/applications/javascriptLiteralLoaderOwnership.ts +++ b/tests/acceptance/applications/javascriptLiteralLoaderOwnership.ts @@ -319,6 +319,7 @@ const expectCompetingLoaderGraph = ( "local module", "projected module", "resolve-only", + "createRequire base", "missing wildcard alias", "earlier reassigned module alias", ].includes(loader); @@ -359,9 +360,21 @@ const expectCompetingLoaderGraph = ( relation: "imports", properties: expect.objectContaining({ kind: "require", - specifier: null, + operation: "load", + specifier: "../shared/window.cjs", + resolution_base_path: "../other/nested/main.cjs", + resolution_status: "not-found", resolved_path: null, }), + evidence: expect.objectContaining({ + location: expect.objectContaining({ + available: true, + value: expect.objectContaining({ + kind: "source-range", + source: "b/main.mjs", + }), + }), + }), }), ]), );