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", + }), + }), + }), }), ]), );