fix(javascript): report empty Electron coverage instead of silent zeroes (#8332)

* fix(javascript): report empty Electron coverage instead of silent zeroes

`analyze_javascript_application` reported `browser_windows: 0` and
`ipc.operations: 0` with only generic partial/unknown flags, so a bundle that
imports `electron` but whose Electron facts were not recovered read exactly like
an application with no IPC at all. Issue #8201 is that false negative on a real
Rolldown bundle.

Two additions, both scoped to "Electron is referenced but nothing was recovered":

**A machine-readable indicator.** `electron_summary` gains
`electron_semantics: "recovered" | "empty"`. It is absent when no analyzed source
links an Electron module, so its presence distinguishes "not an Electron
application" from "Electron coverage is empty". It follows the existing
`protocol_registrations.exactOptional()` precedent, so previously captured
evidence stays valid.

**A graph limitation.** The limitation list already carried a note when at least
one Electron fact was recovered but said nothing when zero were. It now states
that zero IPC operations and zero BrowserWindows mean empty coverage rather than
absence.

`electronModuleReferenced` is the single owner of the module test. It reuses
`ELECTRON_MODULE`, which matches exactly the supported entry points (`electron`,
`electron/common`, `/main`, `/renderer`, `/utility`), so `electron-helper` does
not count, and it ignores type-only links because importing Electron types
promises no boundary fact.

Five tests cover recovered, empty, not-referenced, type-only, and supported
subpath versus unrelated package. The empty, recovered and type-only cases fail
on unmodified `main`.

* chore: retrigger title and readiness checks after the title was corrected

* fix(javascript): report Electron coverage from retained facts

Four review findings on the coverage indicator.

**A rejected application projection hid the reference.** A file whose expanded
relationships exceed the result budget is replaced by a placeholder with no
module links before the summary runs, while `graphLimitations` reads the raw
analysis. The same response could then warn in prose and omit the field. The
summary now takes the retained analysis for coverage only, and both outputs
read one `electronSemanticsState`, so they cannot disagree.

**A skipped semantic analysis hid the reference too.** `electronModuleReferenced`
read only `semantic.ir.moduleLinks`, which is absent when semantic analysis is
skipped for a budget and drops `import("electron")` or `module.require("electron")`
forms. It now falls back to the retained `javascript.references`, which the static
producer records for those inputs, while still excluding `type-import` links when
the richer owner is available.

`electronBoundaryFactCount` is now the single owner of the fact total, replacing a
duplicate inline reduce in the graph builder.

**Omission no longer claims absence.** The schema comment said a missing
`electron_semantics` means the application does not use Electron. The predicate
only establishes that no retained fact names one, and unread sources, failed
analysis, and dynamic module names can hide its use, so the contract now says
unknown.

Two regressions cover a rejected projection and a skipped semantic analysis; both
fail on the previous commit.

* fix(javascript): count a dynamic Electron import as coverage

Review finding: `electronModuleReferenced` returned as soon as module links
existed, so a literal dynamic import was ignored. Verified against the two fact
owners:

    import("electron")   -> references: [dynamic-import electron]
                            moduleLinks: []
    require("electron")  -> references: [require electron]
                            moduleLinks: [require electron]

An application that only loads Electron through `import("electron")` therefore
got neither `electron_semantics: "empty"` nor the empty-coverage warning, and the
zero counts stayed unexplained.

The two owners now answer their own half instead of one short-circuiting the
other. Module links answer a runtime static import or require and keep excluding
`type-import`; the retained reference is read for every kind except
`static-import`, which is exactly the type-only risk. A dynamic specifier is
still never counted by either owner, so this remains an observed-reference claim
rather than an absence claim.

The regression uses `import("electron")` and fails on the previous commit.

* fix(javascript): read only module-loading Electron references

Two review findings, both verified against the static reference producer.

**A retained runtime static import was skipped.** The excluded kind was meant to
protect the type-only distinction, but the static analyzer records a whole
`import type { IpcMain } from "electron"` declaration as no reference at all:

    import type { IpcMain } from "electron"   -> references: []
    import { app } from "electron"            -> references: [static-import electron]
    require("electron")                       -> references: [require electron]
    import("electron")                        -> references: [dynamic-import electron]
    new Worker(new URL("electron", m))        -> references: [worker electron]

When semantic analysis is skipped for a budget the module links are absent, so
that exclusion dropped a real runtime ESM import and left the zero counts
unexplained.

**A worker URL path is not an Electron module.** `new Worker(new
URL("electron", import.meta.url))` records a `worker` reference whose specifier is
a path relative to the importing source, so counting it reported an ordinary
worker file as `electron_semantics: "empty"`.

`ELECTRON_REFERENCE_KINDS` is now an explicit allowlist of the kinds that load a
module by package specifier — `static-import`, `dynamic-import` and `require` —
so `worker` and `service-worker` URL paths are excluded. Module links are still
read first for the type-only distinction.

Three regressions cover a skipped-semantics ESM import, a skipped-semantics whole
type-only import, and a worker URL path; two of them fail on the previous commit.

* fix(javascript): own Electron applicability in the static producer

Review finding. A `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. The boundary owner already read only module-loading
reference kinds, but `finalizeStaticAnalysis` still treated every matching static
reference as `analysis_coverage.applicability.electron: "observed"`, which
`javascriptStaticSourceCoverageSummary` publishes. One response could therefore
say Electron was observed in its coverage view while the Electron summary omitted
`electron_semantics`.

`ELECTRON_REFERENCE_KINDS` now lives next to `ELECTRON_MODULE` in the domain owner
and is consumed by both the static producer and the Electron boundary owner, so the
two outputs cannot disagree. A worker URL path is `unknown` for both, while a
`require("electron")` is `observed` for both.

The regression asserts the domain applicability fact and the boundary summary agree
for a worker URL path; it fails when the producer stops filtering by kind.

* fix(javascript): require a loaded Electron module for applicability

Review finding. A CommonJS `require.resolve("electron")` records a `require`
reference with `operation: "resolve"`: it locates the module without loading it,
so no Electron boundary fact is expected. Both predicates dropped the operation,
so such a source reported `electron_semantics: "empty"` and the missing-coverage
warning even though Electron was never loaded.

Measured on the static producer:

    require.resolve("electron")  -> references: [require resolve electron]
                                    applicability.electron: unknown
    require("electron")          -> references: [require load electron]
                                    applicability.electron: observed
    import("electron")           -> references: [dynamic-import load electron]
                                    applicability.electron: observed

`electronModuleReferenced` and `finalizeStaticAnalysis` now both require
`operation === "load"`, so the boundary summary and the coverage view stay aligned
and reachability-only references no longer claim missing coverage.

The regression asserts a resolve-only source produces neither the field nor the
warning, and it fails when the operation check is removed.

* fix(javascript): classify Electron loads in the static fact owner

Review finding, and the correct home for the previous two rounds of the same
complaint. A Webpack/Rspack factory may name a local module `"electron"`, and the
static producer then records a loaded `require` reference whose specifier is that
bundle module key. Treating the key as a package specifier reported
`electron_semantics: "empty"` plus the missing-coverage warning for a bundle that
never loads Electron.

Three changes, all in the domain owner, so no consumer re-derives the question:

- `JavaScriptModuleRangeIndex.hasKey` answers whether a specifier is a recovered
  bundle module key. A `require` whose specifier is such a key loads a module
  inside the artifact.
- `electronObserved` in `finalizeStaticAnalysis` now requires a loaded module-load
  reference whose specifier is not a bundle key. The published
  `analysis_coverage.applicability.electron` therefore owns every exclusion:
  `type-import`, `require.resolve`, `worker`/`service-worker` URL paths, and
  bundle-local keys.
- `electronModuleReferenced` consumes that owned fact instead of re-deriving it,
  keeping the boundary summary and the coverage view from disagreeing. Historical
  evidence without the field still falls back to the retained references.

Measured:

    bundle-local module ID "electron"  -> applicability unknown
    require("electron")                -> applicability observed
    webpack external n(45840)          -> applicability observed

The regression covers both the local key and the real external in the same
factory shape; it fails without the bundle-key exclusion.

* fix(javascript): identify a bundle module by its loader, not its key

Both review findings were right, and they show why the previous key-set approach
was wrong: bundle-key membership is a property of the file, but whether a
specifier names a module inside the artifact is a property of the reference.

`JavaScriptStaticReference` now records the loader that produced it, and
`electronObserved` excludes `factory-loader` references instead of every specifier
that happens to match a recovered module key. Measured on Node 24.22.1:

    local module keyed "electron"                 -> unknown  (factory-loader)
    external 45840 = require("electron")          -> observed (commonjs-loader)
    factory keyed "electron" that loads the pkg   -> observed (commonjs-loader)

The third case is the regression: a bundle whose factory is *named* `"electron"`
but whose body does `e.exports = require("electron")` is a real Electron load, and
the old key-set check suppressed it. The real load keeps its `commonjs-loader`
identity even when the same specifier also names a factory, so one response can
rely on the loader while the other relies on the key without either lying.

`JavaScriptModuleRangeIndex.hasKey` is removed; it answered the wrong question.

One regression covers all three shapes and fails when the `factory-loader`
exclusion is dropped.

* fix(javascript): carry the reference loader across the worker boundary

The worker schema is strict, so the new `loader` field broke the worker handshake:
`unrecognized_keys` on every `references` entry. That surfaced in CI as both a
worker-schema test failure and a package-baseline failure reporting
`worker_failure_reason: "io"` for a one-file Electron app.

`referenceShape` now accepts the same three loader identities as the domain type, so
the fact survives the worker boundary instead of collapsing every analysis to an
execution failure.

Verified: `JavaScriptAnalysisWorkerSchemas.test.ts` 10 passed, and the full
domain/application/javascript suite is 5484 passed | 10 skipped. The remaining
`SourceMapCommand` and `DarwinHelperCache` findings are present on unmodified
`main` and are not from this change.

* fix(javascript): remove the superseded key set and forward a deferred loader

Two review findings.

**The `keys` set is dead.** `JavaScriptModuleRangeIndex.keys` is left over from the
abandoned key-membership approach: the loader-identity refactor removed `hasKey` and
the write in `add`, so nothing reads it. Removed.

**The deferred require path dropped the loader identity.** `requireCallSpecifier`
returns a `loader` for `commonjs-loader`, `factory-loader` and `create-require`, and the
direct `addReference` call forwards it, but `finishModuleRequireCalls` did not. A
`createRequire` loader saved under a member literally named `require` therefore lost
its identity:

    import { createRequire } from "node:module";
    const holder = { require: createRequire(import.meta.url) };
    holder.require("x");

    before: require "x" with loader undefined
    after:  require "x" with loader "create-require"

Three regressions cover it; one fails without the change.

I did **not** change the inline-type-only import case. `isTypeOnlyImportDeclaration`
deliberately keeps a declaration whose bindings are all inline type-only, because
`hasUncertainTypeOnlyModuleSideEffect` reports that decision as
`type-only-module-side-effect` uncertainty: whether TypeScript emits the import depends
on the consumer's compiler options. Narrowing it to erased broke
`javascriptStaticCoverage.test.ts` and `javascriptSemanticAnalysis.require_links.test.ts`,
which assert exactly that contract. If Electron applicability should ignore that
reference, the decision belongs where applicability is derived, not in the shared
declaration predicate — say the word and I will do it there instead.

* fix(javascript): keep a commonjs-loader reference out of the bundle module index

Review finding. `finalizeLocatedFindings` attaches `module_runtime` to every finding
that sits inside a factory module range, whoever produced it. `resolveReference` then
sent every reference carrying `module_runtime` through the bundle-module lookup, so a
real CommonJS load inside a factory body resolved to the local module key that shared
its name:

    "electron": (e, t, n) => { e.exports = require("electron") }

    fact:  specifier "electron", loader "commonjs-loader", module_key "electron"
    before: resolved through the bundle module index to `#module:electron`
    after:  resolves as an external package specifier

Only a `factory-loader` specifier names a recovered bundle module key, which is what
`loader` already records after the loader-identity change. The module lookup is now
gated on it, and an absent `loader` keeps the previous behaviour for older findings.
This commit is contained in:
吴佳浩Alben authored and GitHub committed 2026-11-06 15:15:43 +08:00
1 parent 85775b635b
commit 99bcd3e4e0
12 files changed
+551 -25

No files matched your search

@@ -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<string>,
/**
* 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,
};
};
@@ -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.",
@@ -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<AnalyzedJavaScriptArtifactFile> => {
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");
});
@@ -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),
@@ -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({
@@ -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<string> = new Set([
"static-import",
"dynamic-import",
"require",
]);
/** Match Electron's supported module entry points. */
export const ELECTRON_MODULE =
/^electron(?:\/(?:common|main|renderer|utility))?$/u;
@@ -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 = {
@@ -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),
},
@@ -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 },
]);
});
@@ -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. */
@@ -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;
}
@@ -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",