diff --git a/src/application/javascript/ElectronBoundaryAnalysis.ts b/src/application/javascript/ElectronBoundaryAnalysis.ts index 8b1b3c404f..5d1bc43ca1 100644 --- a/src/application/javascript/ElectronBoundaryAnalysis.ts +++ b/src/application/javascript/ElectronBoundaryAnalysis.ts @@ -1,6 +1,10 @@ import type { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; import type { ElectronIpcFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js"; +import { + ELECTRON_MODULE, + ELECTRON_REFERENCE_KINDS, +} from "../../domain/javascript/javascriptElectronMemberWrites.js"; import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js"; import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; @@ -117,11 +121,89 @@ export const createElectronIpcContext = ( return { records, owners, states, pairings }; }; +/** + * Whether any analyzed source loads an Electron module. + * + * `analysis_coverage.applicability.electron` is the domain owner of this + * question: it already excludes a `type-import`, a `require.resolve` that only + * locates the module, a `worker` URL path, and a specifier that is a recovered + * bundle module key. Reading the owned fact here keeps the boundary summary and + * the published coverage view from disagreeing. + * + * Historical evidence may predate that field, so the retained references answer + * the same question for those files. + */ +export const electronModuleReferenced = ( + analysis: JavaScriptArtifactAnalysis, +): boolean => + analysis.files.some(({ javascript, semantic }) => { + const applicability = javascript?.analysis_coverage?.applicability.electron; + if (applicability !== undefined) return applicability === "observed"; + const links = semantic?.ir.moduleLinks; + if ( + links !== undefined && + links.some( + ({ specifier, kind }) => + kind !== "type-import" && + specifier !== null && + ELECTRON_MODULE.test(specifier), + ) + ) + return true; + return (javascript?.references ?? []).some( + ({ specifier, kind, operation }) => + operation === "load" && + ELECTRON_REFERENCE_KINDS.has(kind) && + specifier !== null && + ELECTRON_MODULE.test(specifier), + ); + }); + +/** Every Electron boundary fact family the graph reports. */ +export const electronBoundaryFactCount = ( + analysis: JavaScriptArtifactAnalysis, +): number => + analysis.files.reduce( + (count, { javascript }) => + count + + (javascript === null + ? 0 + : javascript.electron.browser_windows.length + + javascript.electron.context_bridge_apis.length + + javascript.electron.ipc.length + + javascript.electron.sender_validations.length + + javascript.electron.utility_processes.length + + javascript.electron.native_addon_bindings.length + + javascript.electron.protocols.length), + 0, + ); + +/** + * Whether the Electron boundary summary came back empty. `undefined` means no + * analyzed source names Electron, which is unknown rather than absent: unread + * sources and dynamic module names can hide its use. + */ +export const electronSemanticsState = ( + analysis: JavaScriptArtifactAnalysis, +): "recovered" | "empty" | undefined => + electronModuleReferenced(analysis) === false + ? undefined + : electronBoundaryFactCount(analysis) === 0 + ? "empty" + : "recovered"; + /** Summarize static Electron findings without treating gaps as absence. */ export const summarizeElectronBoundaries = ( analysis: JavaScriptArtifactAnalysis, ipcContext: ElectronIpcContext, directoryPaths?: ReadonlySet, + /** + * Analysis view used only to describe coverage. A rejected application + * projection replaces a file with a placeholder in `analysis`, which would + * hide a retained Electron reference, so the caller passes the raw analysis + * and both outputs then share one coverage result. + */ + coverageAnalysis: JavaScriptArtifactAnalysis = analysis, ): ElectronBoundarySummary => { const files = new Map(analysis.files.map(({ file }) => [file.path, file])); const javascript = analysis.files.flatMap(({ javascript: value }) => @@ -155,7 +237,15 @@ export const summarizeElectronBoundaries = ( finding.channel === null ? [] : [finding.channel], ), ); + const protocolCount = javascript.reduce( + (count, { electron }) => count + electron.protocols.length, + 0, + ); + // The coverage state is read from the retained analysis so that a rejected + // projection cannot hide an Electron reference the graph still reports. + const semantics = electronSemanticsState(coverageAnalysis); return { + ...(semantics === undefined ? {} : { electron_semantics: semantics }), browser_windows: windows.length, explicit_web_preferences: windows.reduce( (count, window) => count + window.web_preferences.length, @@ -220,10 +310,7 @@ export const summarizeElectronBoundaries = ( moduleKind: finding.module_kind, }), ).length, - protocol_registrations: javascript.reduce( - (count, { electron }) => count + electron.protocols.length, - 0, - ), + protocol_registrations: protocolCount, }; }; diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index e59e673224..f5d6a42466 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -1,4 +1,5 @@ import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js"; +import { electronSemanticsState } from "./ElectronBoundaryAnalysis.js"; import { ELECTRON_IDENTITY_LIMITATION } from "../../domain/javascript/javascriptElectronMemberWrites.js"; import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; import { @@ -240,6 +241,7 @@ function* buildJavaScriptArtifactGraphInputSteps( projectionContext.analysis, ipc, context.directoryPaths, + context.analysis, ), graph: { schema: "JavaScriptApplicationGraph", @@ -356,20 +358,9 @@ const graphLimitations = ( ): string[] => { const ipc = ipcContext.records; const pairings = ipcContext.states; - const electronFindings = context.analysis.files.reduce( - (count, { javascript }) => - count + - (javascript === null - ? 0 - : javascript.electron.browser_windows.length + - javascript.electron.context_bridge_apis.length + - javascript.electron.ipc.length + - javascript.electron.sender_validations.length + - javascript.electron.utility_processes.length + - javascript.electron.native_addon_bindings.length + - javascript.electron.protocols.length), - 0, - ); + // Shared with the Electron summary so both outputs describe one coverage + // result, including when a rejected projection drops a file from the graph. + const electronSemantics = electronSemanticsState(context.analysis); return [ ...context.analysis.limitations, ...((context.fileSet.source_selection?.unselected_files ?? 0) === 0 @@ -395,11 +386,15 @@ const graphLimitations = ( ) ? [ELECTRON_IDENTITY_LIMITATION] : []), - ...(electronFindings === 0 - ? [] - : [ - "Electron relationships are derived from inert syntax; runtime registration, reachability, defaults, and enforcement remain unproven.", - ]), + ...(electronSemantics === "empty" + ? [ + "Electron modules are referenced but no Electron boundary fact was recovered from inert syntax. Zero IPC operations and zero BrowserWindows here mean empty coverage, not evidence that the application has none.", + ] + : electronSemantics === "recovered" + ? [ + "Electron relationships are derived from inert syntax; runtime registration, reachability, defaults, and enforcement remain unproven.", + ] + : []), ...(ipc.some(({ finding }) => finding.channel === null) ? [ "Dynamic IPC channel expressions remain unresolved and are never paired by textual similarity.", diff --git a/src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts b/src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts new file mode 100644 index 0000000000..3aaac46202 --- /dev/null +++ b/src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it } from "vitest"; + +import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; +import { projectJavaScriptModuleSemantics } from "../../domain/javascript/javascriptModuleSemanticIr.js"; +import { moduleResolutionModeForJavaScriptReference } from "../../domain/javascript/javascriptModuleResolutionMode.js"; +import { analyzeParsedJavaScriptSemanticsSteps } from "../../domain/javascript/javascriptSemanticAnalysis.js"; +import { parseJavaScriptSource } from "../../domain/javascript/javascriptSourceParser.js"; +import { analyzeParsedJavaScriptStaticSourceSteps } from "../../domain/javascript/javascriptStaticAnalysis.js"; +import type { + AnalyzedJavaScriptArtifactFile, + JavaScriptArtifactAnalysis, +} from "./JavaScriptArtifactAnalysisTypes.js"; +import { completeJavaScriptAnalysisSteps } from "./JavaScriptAnalysisControl.js"; +import { buildImmutableJavaScriptArtifactGraphSteps as buildGraph } from "./JavaScriptArtifactGraphBuilder.js"; +import { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; +import { electronModuleReferenced } from "./ElectronBoundaryAnalysis.js"; + +const SHA256 = "a".repeat(64); +const PATH = "main.js"; + +const EMPTY_COVERAGE = + "Electron modules are referenced but no Electron boundary fact was recovered from inert syntax. Zero IPC operations and zero BrowserWindows here mean empty coverage, not evidence that the application has none."; + +const fileFor = ( + source: string, + path: string = PATH, +): JavaScriptArtifactFile => ({ + path, + container_sha256: SHA256, + sha256: SHA256, + bytes: source.length, + inventory_artifact_id: `art_${SHA256}`, + kind: "javascript", + unpacked: false, + text: { included: true, value: source }, +}); + +const analyzedEntry = async ( + source: string, + path: string = PATH, +): Promise => { + const file = fileFor(source, path); + const parsed = parseJavaScriptSource(source, path); + if (parsed === null) throw new Error("expected a parsed source"); + const facts = await completeJavaScriptAnalysisSteps( + analyzeParsedJavaScriptStaticSourceSteps(source, parsed), + ); + const ir = await completeJavaScriptAnalysisSteps( + analyzeParsedJavaScriptSemanticsSteps( + parsed, + moduleResolutionModeForJavaScriptReference(path, "static-import"), + 100_000, + ), + ); + return { + file, + javascript: facts, + semantic: { ir: projectJavaScriptModuleSemantics(ir) }, + }; +}; + +const snapshot = { + manifest: { + root_artifact_id: `art_${SHA256}`, + root_sha256: SHA256, + root_format: "directory", + manifest_id: `agm_${SHA256}`, + graph_sha256: SHA256, + }, + nodes: [{ artifact_id: `art_${SHA256}`, sha256: SHA256, size: 1 }], + occurrences: [], + limitations: [], + integrity_contradictions: [], + coverage: { scope: "root", status: "complete" }, +} as unknown as ArtifactInventorySnapshot; + +const graphFor = async ( + source: string, + mutate?: ( + entry: AnalyzedJavaScriptArtifactFile, + ) => AnalyzedJavaScriptArtifactFile, + path: string = PATH, +): Promise<{ + limitations: readonly string[]; + electronSemantics: string | undefined; +}> => { + const base = await analyzedEntry(source, path); + const entry = mutate === undefined ? base : mutate(base); + const analysis: JavaScriptArtifactAnalysis = { + jsonDocuments: new JavaScriptJsonDocuments(), + files: [entry], + packages: [], + json_modules: [], + html_scripts: [], + html_inline_scripts: [], + source_maps: [], + visited_ast_nodes: 0, + findings: 0, + modules: 0, + parse_failures: 0, + limitations: [], + }; + const fileSet = { + files: [entry.file], + unavailable_relevant_files: [], + containers: [], + text_bytes_read: entry.file.bytes, + invalid_utf8_files: 0, + }; + const steps = buildGraph(snapshot, fileSet, analysis); + let next = steps.next(); + while (next.done !== true) next = steps.next(); + return { + limitations: next.value.graph.limitations, + electronSemantics: next.value.electronSummary.electron_semantics, + }; +}; + +describe("Electron semantic coverage", () => { + it("reports empty Electron coverage when the module is referenced but nothing is recovered", async () => { + const { limitations, electronSemantics } = await graphFor( + `const { app } = require("electron");\napp.whenReady();\n`, + ); + expect(limitations).toContain(EMPTY_COVERAGE); + expect(electronSemantics).toBe("empty"); + }); + + it("reports idle-coverage limits, not empty coverage, once a boundary fact is recovered", async () => { + const { limitations, electronSemantics } = await graphFor( + `const { ipcMain } = require("electron");\nipcMain.handle("ping", () => 1);\n`, + ); + expect(limitations).not.toContain(EMPTY_COVERAGE); + expect(limitations).toContain( + "Electron relationships are derived from inert syntax; runtime registration, reachability, defaults, and enforcement remain unproven.", + ); + expect(electronSemantics).toBe("recovered"); + }); + + it("omits the coverage field when the source never references Electron", async () => { + const { limitations, electronSemantics } = await graphFor( + `export const value = 1;\n`, + ); + expect(limitations).not.toContain(EMPTY_COVERAGE); + expect(electronSemantics).toBeUndefined(); + }); + + it("does not count a type-only Electron import as boundary coverage", () => { + // A `.js` fixture cannot carry `import type`, so the module predicate is + // exercised directly here. + const analysisWith = ( + kind: "require" | "type-import", + ): JavaScriptArtifactAnalysis => + ({ + files: [ + { + file: fileFor(""), + javascript: null, + semantic: { + ir: { moduleLinks: [{ kind, specifier: "electron" }] }, + }, + }, + ], + }) as unknown as JavaScriptArtifactAnalysis; + expect(electronModuleReferenced(analysisWith("require"))).toBe(true); + expect(electronModuleReferenced(analysisWith("type-import"))).toBe(false); + }); + + it("matches a supported Electron subpath and ignores an unrelated package", async () => { + const subpath = await graphFor( + `const { app } = require("electron/main");\napp.whenReady();\n`, + ); + expect(subpath.electronSemantics).toBe("empty"); + const unrelated = await graphFor( + `const helper = require("electron-helper");\nhelper.run();\n`, + ); + expect(unrelated.electronSemantics).toBeUndefined(); + }); + + it("keeps the coverage state when a rejected projection drops the file", async () => { + // A file whose expanded relationships exceed the result budget is replaced + // by a placeholder before the Electron summary runs, so the coverage state + // must come from the retained analysis rather than the projected view. + const { limitations, electronSemantics } = await graphFor( + `const { app } = require("electron");\napp.whenReady();\n`, + (entry) => ({ + ...entry, + application_projection_failure: { + reason: "result budget exhausted", + error: { code: "result_budget_exhausted" }, + limits: null, + }, + }), + ); + expect(electronSemantics).toBe("empty"); + expect(limitations).toContain(EMPTY_COVERAGE); + }); + + it("counts a retained static reference when semantic analysis was skipped", async () => { + // Semantic analysis can be skipped for a budget, which leaves the module + // links absent while the static reference is still retained. + const { electronSemantics } = await graphFor( + `const { app } = require("electron");\napp.whenReady();\n`, + (entry) => ({ + ...entry, + semantic: null, + }), + ); + expect(electronSemantics).toBe("empty"); + }); +}); + +it("counts a dynamic Electron import that module links do not retain", async () => { + // The semantic collector keeps no module link for `import("electron")`, so + // only the retained static reference names Electron here. + const { limitations, electronSemantics } = await graphFor( + `const electron = import("electron");\nelectron.then(() => {});\n`, + ); + expect(electronSemantics).toBe("empty"); + expect(limitations).toContain(EMPTY_COVERAGE); +}); + +it("counts a retained runtime static import when semantics are skipped", async () => { + // The static analyzer records a whole `import type` declaration as no + // reference, so a retained `static-import` is a runtime load. + const { electronSemantics } = await graphFor( + `import { app } from "electron";\napp.whenReady();\n`, + (entry) => ({ ...entry, semantic: null }), + ); + expect(electronSemantics).toBe("empty"); +}); + +it("does not count a whole type-only import when semantics are skipped", async () => { + // `import type` needs a TypeScript source, and the static analyzer records no + // reference for a whole type-only declaration. + const { electronSemantics } = await graphFor( + `import type { IpcMain } from "electron";\nexport const value = 1;\n`, + (entry) => ({ ...entry, semantic: null }), + "main.mts", + ); + expect(electronSemantics).toBeUndefined(); +}); + +it("does not treat a worker URL path as an Electron module", async () => { + // `new Worker(new URL("electron", import.meta.url))` records a `worker` + // reference whose specifier is a path relative to the importing source. + const { electronSemantics } = await graphFor( + `const worker = new Worker(new URL("electron", import.meta.url));\n`, + (entry) => ({ ...entry, semantic: null }), + ); + expect(electronSemantics).toBeUndefined(); +}); + +it("reports the same Electron applicability as the retained static fact", async () => { + // `analysis_coverage.applicability.electron` is the domain owner of the same + // question, so a worker URL path must be unknown for both outputs. + + const entry = await analyzedEntry( + `const worker = new Worker(new URL("electron", import.meta.url));\n`, + ); + expect(entry.javascript?.analysis_coverage?.applicability.electron).toBe( + "unknown", + ); + const graph = await graphFor( + `const worker = new Worker(new URL("electron", import.meta.url));\n`, + ); + expect(graph.electronSemantics).toBeUndefined(); +}); + +it("does not count a resolve-only Electron reference as coverage", async () => { + // `require.resolve("electron")` locates the module without loading it, so no + // boundary fact is expected and neither output may claim empty coverage. + const { limitations, electronSemantics } = await graphFor( + `const entry = require.resolve("electron");\n`, + ); + expect(electronSemantics).toBeUndefined(); + expect(limitations).not.toContain(EMPTY_COVERAGE); +}); + +it("classifies a bundle module by its loader, not by its name", async () => { + // A Webpack/Rspack factory can name a local module `"electron"`. A + // `factory-loader` reference names that module key, while a real external load + // keeps its `commonjs-loader` identity even in the same file. + const shell = (modules: string, consumer: string) => + `(()=>{var m={${modules}},c={};` + + `function n(t){var r=c[t];if(void 0!==r)return r.exports;var o=c[t]={exports:{}};` + + `return m[t](o,o.exports,n),o.exports}${consumer}})();`; + + const local = await graphFor( + shell( + `"electron":(e,t,n)=>{"use strict";e.exports={local:1}},` + + `100:(e,t,n)=>{"use strict";const s=n("electron");s.local}`, + `n(100);`, + ), + ); + expect(local.electronSemantics).toBeUndefined(); + expect(local.limitations).not.toContain(EMPTY_COVERAGE); + + const external = await graphFor( + shell( + `45840:e=>{"use strict";e.exports=require("electron")},` + + `100:(e,t,n)=>{"use strict";const s=n(45840);s.ipcMain.handle("x",()=>1)}`, + `n(100);`, + ), + ); + expect(external.electronSemantics).toBe("recovered"); + + // A factory keyed `"electron"` whose body loads the real package is still a + // real load, so the name alone must not suppress it. + const externalUnderKey = await graphFor( + shell( + `"electron":(e,t,n)=>{"use strict";e.exports=require("electron")},` + + `100:(e,t,n)=>{"use strict";const s=n("electron");s.ipcMain.handle("x",()=>1)}`, + `n(100);`, + ), + ); + expect(externalUnderKey.electronSemantics).toBe("recovered"); +}); diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index 9e0b3c7036..49968898b4 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -678,8 +678,14 @@ const resolveReference = ( ? null : resolveBundlerChunkReference(context, sourcePath, reference); if (chunk !== null) return chunk; + // Only a recovered factory loader names a bundle module key. A real CommonJS + // `require("electron")` inside a factory body records `commonjs-loader`, so its + // specifier is an external package name even though the finding sits inside a + // module range and carries that range's runtime. const module = - isWorker || reference.module_runtime === undefined + isWorker || + reference.module_runtime === undefined || + reference.loader === "commonjs-loader" ? undefined : context.moduleNodes.get( moduleLookupKey(sourcePath, reference.module_runtime, specifier), diff --git a/src/domain/javascript/javascriptApplicationAnalysis.ts b/src/domain/javascript/javascriptApplicationAnalysis.ts index d99ff3f5d4..294d1d31e6 100644 --- a/src/domain/javascript/javascriptApplicationAnalysis.ts +++ b/src/domain/javascript/javascriptApplicationAnalysis.ts @@ -58,6 +58,17 @@ const electronBoundarySummarySchema = z.strictObject({ resolved_native_addon_bindings: countSchema, /** Absent in evidence produced before protocol registrations were reported. */ protocol_registrations: countSchema.exactOptional(), + /** + * Whether the Electron boundary surface was actually recovered. `empty` means + * an analyzed source names Electron but no boundary fact was recovered, so the + * zeroes above are missing coverage and not evidence of absence. + * + * Absent when no retained fact names an Electron module, or in evidence + * produced before this field existed. Absence is unknown rather than proof + * that the application has no Electron surface: unread sources, failed + * analysis, and dynamic module names can hide one. + */ + electron_semantics: z.enum(["recovered", "empty"]).exactOptional(), }); const reconstructionStatisticsSchema = z.strictObject({ diff --git a/src/domain/javascript/javascriptElectronMemberWrites.ts b/src/domain/javascript/javascriptElectronMemberWrites.ts index 63f8374464..99510650c4 100644 --- a/src/domain/javascript/javascriptElectronMemberWrites.ts +++ b/src/domain/javascript/javascriptElectronMemberWrites.ts @@ -8,6 +8,18 @@ import { } from "./javascriptAstValues.js"; import { traverseJavaScriptValueAstSteps } from "./javascriptSemanticTraversal.js"; +/** + * The reference kinds that load a module by package specifier. A `worker` or + * `service-worker` reference records a URL relative to the importing source, so + * `new Worker(new URL("electron", import.meta.url))` names a local file and is + * not an Electron module. + */ +export const ELECTRON_REFERENCE_KINDS: ReadonlySet = new Set([ + "static-import", + "dynamic-import", + "require", +]); + /** Match Electron's supported module entry points. */ export const ELECTRON_MODULE = /^electron(?:\/(?:common|main|renderer|utility))?$/u; diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 770d3f4905..f8c25f0022 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -8,6 +8,7 @@ import { collectElectronWrappedCallPathsSteps } from "./electronWrappedCallPaths import { ELECTRON_IDENTITY_LIMITATION, ELECTRON_MODULE, + ELECTRON_REFERENCE_KINDS, } from "./javascriptElectronMemberWrites.js"; import { @@ -199,9 +200,22 @@ const finalizeStaticAnalysis = ( ): JavaScriptStaticAnalysis => { const parserErrors = file.errors.length; const typeOnlySideEffect = hasUncertainTypeOnlyModuleSideEffect(file.program); + // A `worker` or `service-worker` reference records a URL relative to the + // importing source, so `new Worker(new URL("electron", import.meta.url))` is + // not an Electron module. Read the same kinds the Electron boundary owner uses. + // Only a loaded module establishes Electron applicability: `require.resolve` + // locates the module without loading it, so no boundary fact is expected. const electronObserved = accumulator.references.some( ({ value }) => - value.specifier !== null && ELECTRON_MODULE.test(value.specifier), + value.specifier !== null && + value.operation === "load" && + ELECTRON_REFERENCE_KINDS.has(value.kind) && + ELECTRON_MODULE.test(value.specifier) && + // A `factory-loader` reference names a recovered bundle module, so its + // specifier is a module key inside the artifact rather than a package. A + // real external load in the same file keeps its `commonjs-loader` + // identity, so it is still counted. + value.loader !== "factory-loader", ); const resourceLimits = [...bindings.valueResourceLimits].sort(); const coverage: JavaScriptStaticCoverage = { diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index e71986ad51..c9cf1c628c 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -103,6 +103,9 @@ export const inspectCall = ( ...(requireCall.resolutionBasePath === undefined ? {} : { resolutionBasePath: requireCall.resolutionBasePath }), + ...(requireCall.loader === undefined + ? {} + : { loader: requireCall.loader }), }); const chunkLoad = chunkLoadSpecifier(node, bindings, context); if (chunkLoad !== null) @@ -281,6 +284,7 @@ const requireCallSpecifier = ( readonly specifier: string | undefined; readonly operation: "load" | "resolve"; readonly resolutionBasePath?: string; + readonly loader?: "commonjs-loader" | "factory-loader" | "create-require"; } | null => { const call = semanticRequireCallAt( node, @@ -305,6 +309,13 @@ const requireCallSpecifier = ( ...(call.sourceRelativePath === undefined ? {} : { resolutionBasePath: call.sourceRelativePath }), + // `factory-loader` names a bundle factory require, so its specifier is a + // recovered module key rather than a package specifier. + ...(call.loader === "commonjs-loader" || + call.loader === "factory-loader" || + call.loader === "create-require" + ? { loader: call.loader } + : {}), }; }; @@ -340,6 +351,10 @@ export const finishModuleRequireCalls = ( ...(call.resolutionBasePath === undefined ? {} : { resolutionBasePath: call.resolutionBasePath }), + // A factory or `createRequire` loader saved under a member named `require` + // still names a bundle module key, not a package. Dropping the loader here + // would read that key as an external specifier. + ...(call.loader === undefined ? {} : { loader: call.loader }), }); } context.accumulator.pendingModuleRequireCalls.length = 0; @@ -981,6 +996,7 @@ export const addReference = ( ...(input.resolutionBasePath === undefined ? {} : { resolution_base_path: input.resolutionBasePath }), + ...(input.loader === undefined ? {} : { loader: input.loader }), module_key: null, location: range(input.node), }, diff --git a/src/domain/javascript/javascriptStaticAnalysisDeferredLoader.test.ts b/src/domain/javascript/javascriptStaticAnalysisDeferredLoader.test.ts new file mode 100644 index 0000000000..89f3ef69e3 --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisDeferredLoader.test.ts @@ -0,0 +1,55 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +/** + * A `createRequire` loader saved under a member literally named `require` is still + * the same loader. The deferred path used to drop the loader identity, so the + * specifier of a factory or `create-require` module key read as an external package. + * + * Measured shape: + * + * import { createRequire } from "node:module"; + * const holder = { require: createRequire(import.meta.url) }; + * holder.require("x"); -> a `create-require` load of "x" + * + * A plain `module.require` member is the CommonJS loader, so it stays unqualified. + */ +const referencesFor = (source: string) => + (analyzeJavaScriptStaticSource(source).references ?? []).map( + ({ kind, specifier, loader }) => ({ kind, specifier, loader }), + ); + +it("forwards createRequire through a member named require", () => { + expect( + referencesFor( + `import { createRequire } from "node:module"; +const holder = { require: createRequire(import.meta.url) }; +holder.require("x");`, + ), + ).toEqual([ + { kind: "static-import", specifier: "node:module", loader: undefined }, + { kind: "require", specifier: "x", loader: "create-require" }, + ]); +}); + +it("forwards a destructured require member alias", () => { + expect( + referencesFor( + `import { createRequire } from "node:module"; +const { require: load } = { require: createRequire(import.meta.url) }; +load("y");`, + ), + ).toEqual([ + { kind: "static-import", specifier: "node:module", loader: undefined }, + { kind: "require", specifier: "y", loader: "create-require" }, + ]); +}); + +it("keeps a module.require member unqualified", () => { + // A bare snippet has no `module` binding, so the specifier stays unresolved; what + // this pins is that the deferred path does not invent a loader identity for it. + expect(referencesFor(`const load = module.require; load("z");`)).toEqual([ + { kind: "require", specifier: null, loader: undefined }, + ]); +}); diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index cd6b6e4425..8628fcb931 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -138,6 +138,7 @@ export interface JavaScriptReferenceInput { readonly kind: JavaScriptStaticReference["kind"]; readonly specifier: string | undefined; readonly resolutionBasePath?: string; + readonly loader?: JavaScriptStaticReference["loader"]; } /** Candidate route or network endpoint literal. */ diff --git a/src/domain/javascript/javascriptStaticAnalysisTypes.ts b/src/domain/javascript/javascriptStaticAnalysisTypes.ts index 0ad2c5eba5..489d08dfdc 100644 --- a/src/domain/javascript/javascriptStaticAnalysisTypes.ts +++ b/src/domain/javascript/javascriptStaticAnalysisTypes.ts @@ -44,6 +44,12 @@ interface JavaScriptStaticReferenceState { readonly module_runtime?: string; /** Source-relative filename selected by a proven createRequire factory. */ readonly resolution_base_path?: string; + /** + * Which loader produced this reference. A `factory-loader` specifier is a + * recovered bundle module key, so it names a module inside the artifact even + * when it reads like a package name. + */ + readonly loader?: "commonjs-loader" | "factory-loader" | "create-require"; readonly location: JavaScriptSourceRange; } diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index 0c17677dde..a7a6e7d0e3 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -327,6 +327,11 @@ const referenceShape = { ...findingContext, operation: z.enum(["load", "resolve"]), resolution_base_path: z.string().exactOptional(), + // A `factory-loader` specifier is a recovered bundle module key rather than a + // package specifier, so the loader identity travels with the reference. + loader: z + .enum(["commonjs-loader", "factory-loader", "create-require"]) + .exactOptional(), kind: z.enum([ "static-import", "dynamic-import",