fix(javascript): preserve loader authority for exact suffixes (#9477)

* fix(javascript): require exact loader suffix case

* fix(javascript): share exact loader mode across import resolution

* test(process): align symlink fixture with filesystem checkpoints

* test(javascript): normalize the module resolution oracle path separators

* test(frida): assert the CLI spawn Evidence contract

* test(mcp): use an unsupported analysis-view parent

* test(browser): separate source-location line bounds from parity

* fix(javascript): preserve uncertain loader provenance

* test(javascript): cover uncertain require resolution

* fix(javascript): retain conditional Electron evidence

* fix(javascript): preserve timed semantic provenance

* fix(javascript): retain conditional builtin effects

* test(javascript): distinguish bundled host authority

* fix(javascript): preserve nested loader invocation identity

* test(javascript): qualify Electron fixture authority

* fix(javascript): preserve loader invocation authority

* fix(javascript): respect source order for resolver escapes

* fix(javascript): check nested loader callable mutations

* fix(javascript): retain conditional constructor requests

* fix(reference): retain constructor loader relationships

* fix(reference): follow nested loader callable paths

* refactor(javascript): share callable mutation interpretation

* fix(javascript): require exact package loader suffixes

* refactor(javascript): unify conditional relation evidence

* refactor(javascript): unify conditional node evidence

* fix(javascript): retain unknown authority for mixed case sources

* fix(javascript): preserve loader uncertainty for dotfiles

* fix(reference): classify javascript suffix dotfiles

* test(reference): cover suffix dotfiles through cli

* fix(javascript): retain equivalent conditional built-in origins

* fix(javascript): preserve wrapped resolver operation

* fix(javascript): retain nested conditional built-in receivers

* test(javascript): declare CommonJS in native addon fixtures

* fix(javascript): retain wrapped resolver candidates

* style: format merged native command schema

* fix(javascript): preserve uncertain loader authority

* test(javascript): require candidate preload evidence

* test(javascript): cover reference loader member writes

---------

Co-authored-by: ShuaixinHuang <1836656514@qq.com>
This commit is contained in:
morlutoandShuaixinHuang authored and GitHub committed 2026-11-09 13:21:24 +08:00
1 parent 78a84d75c0
commit b46f4fcebd
112 files changed
+5156 -797

No files matched your search

@@ -90,14 +90,20 @@ export const createElectronIpcContext = (
const uncertain =
!coverageComplete ||
owner == null ||
transmission.finding.loader_authority === "candidate" ||
matching.some(
({ finding }) => finding.loader_authority === "candidate",
) ||
matching.some((handler) => owners.get(handler.key) == null);
return {
transmission,
candidates,
coverageLimitedCandidate:
!coverageComplete &&
transmission.finding.loader_authority !== "candidate" &&
owner != null &&
candidates.length === 1 &&
candidates[0]?.finding.loader_authority !== "candidate" &&
isExactIpcHandler(candidates[0]) &&
!matching.some((handler) => owners.get(handler.key) == null)
? (candidates[0] ?? null)
@@ -143,16 +149,18 @@ export const electronModuleReferenced = (
if (
links !== undefined &&
links.some(
({ specifier, kind }) =>
({ specifier, kind, loaderAuthority }) =>
kind !== "type-import" &&
loaderAuthority !== "candidate" &&
specifier !== null &&
ELECTRON_MODULE.test(specifier),
)
)
return true;
return (javascript?.references ?? []).some(
({ specifier, kind, operation }) =>
({ specifier, kind, operation, loader_authority }) =>
operation === "load" &&
loader_authority !== "candidate" &&
ELECTRON_REFERENCE_KINDS.has(kind) &&
specifier !== null &&
ELECTRON_MODULE.test(specifier),
@@ -168,16 +176,48 @@ export const electronBoundaryFactCount = (
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),
: javascript.electron.browser_windows.filter(provenElectronFinding)
.length +
javascript.electron.context_bridge_apis.filter(provenElectronFinding)
.length +
javascript.electron.ipc.filter(provenElectronFinding).length +
javascript.electron.sender_validations.filter(provenElectronFinding)
.length +
javascript.electron.utility_processes.filter(provenElectronFinding)
.length +
javascript.electron.native_addon_bindings.filter(
provenElectronFinding,
).length +
javascript.electron.protocols.filter(provenElectronFinding).length),
0,
);
const provenElectronFinding = (finding: {
readonly loader_authority?: "candidate" | undefined;
}): boolean => finding.loader_authority !== "candidate";
/** Candidate findings remain in the graph, but are not proven summary counts. */
export const hasCandidateElectronFindings = (
analysis: JavaScriptArtifactAnalysis,
): boolean =>
analysis.files.some(
({ javascript }) =>
javascript !== null &&
[
javascript.electron.browser_windows,
javascript.electron.context_bridge_apis,
javascript.electron.ipc,
javascript.electron.sender_validations,
javascript.electron.utility_processes,
javascript.electron.native_addon_bindings,
javascript.electron.protocols,
javascript.electron.app_path_changes ?? [],
javascript.electron.call_paths ?? [],
].some((findings) =>
findings.some((finding) => !provenElectronFinding(finding)),
),
);
/**
* Whether the Electron boundary summary came back empty. `undefined` means no
* analyzed source names Electron, which is unknown rather than absent: unread
@@ -209,36 +249,45 @@ export const summarizeElectronBoundaries = (
const javascript = analysis.files.flatMap(({ javascript: value }) =>
value === null ? [] : [value],
);
const windows = javascript.flatMap(
({ electron }) => electron.browser_windows,
const windows = javascript.flatMap(({ electron }) =>
electron.browser_windows.filter(provenElectronFinding),
);
const bridges = javascript.flatMap(
({ electron }) => electron.context_bridge_apis,
const bridges = javascript.flatMap(({ electron }) =>
electron.context_bridge_apis.filter(provenElectronFinding),
);
const validations = javascript.flatMap(
({ electron }) => electron.sender_validations,
const validations = javascript.flatMap(({ electron }) =>
electron.sender_validations.filter(provenElectronFinding),
);
const utilities = analysis.files.flatMap(({ file, javascript: value }) =>
(value?.electron.utility_processes ?? []).map((finding) => ({
file,
finding,
})),
(value?.electron.utility_processes ?? [])
.filter(provenElectronFinding)
.map((finding) => ({
file,
finding,
})),
);
const nativeBindings = analysis.files.flatMap(({ file, javascript: value }) =>
(value?.electron.native_addon_bindings ?? []).map((finding) => ({
file,
finding,
})),
(value?.electron.native_addon_bindings ?? [])
.filter(provenElectronFinding)
.map((finding) => ({
file,
finding,
})),
);
const ipc = ipcContext.records.filter(({ finding }) =>
provenElectronFinding(finding),
);
const pairing = ipcContext.states.filter(({ transmission }) =>
provenElectronFinding(transmission.finding),
);
const ipc = ipcContext.records;
const pairing = ipcContext.states;
const literalChannels = new Set(
ipc.flatMap(({ finding }) =>
finding.channel === null ? [] : [finding.channel],
),
);
const protocolCount = javascript.reduce(
(count, { electron }) => count + electron.protocols.length,
(count, { electron }) =>
count + electron.protocols.filter(provenElectronFinding).length,
0,
);
// The coverage state is read from the retained analysis so that a rejected
@@ -10,6 +10,7 @@ import type {
} from "./ElectronBoundaryAnalysis.js";
import {
addElectronInferenceEdge,
ELECTRON_CANDIDATE_LIMITATION,
electronFindingSourceNode,
} from "./ElectronBoundaryGraphContext.js";
import {
@@ -64,6 +65,9 @@ export const addElectronCallPaths = (
module_runtime: step.module_runtime,
conditions: path.conditions,
resolution: "candidate",
...(path.loader_authority === "candidate"
? { loader_authority: "candidate" }
: {}),
},
evidence: staticInferenceEvidence({
sha256: file.sha256,
@@ -71,8 +75,14 @@ export const addElectronCallPaths = (
range: step.location,
operation: "substitute-electron-wrapper",
coverage,
...(path.loader_authority === "candidate"
? { confidence: "low" as const }
: {}),
limitations: [
"Lexical wrapper substitution is a static candidate; guards, runtime overrides and execution remain unverified.",
...(path.loader_authority === "candidate"
? [ELECTRON_CANDIDATE_LIMITATION]
: []),
],
}),
},
@@ -98,6 +108,7 @@ export const addElectronCallPaths = (
coverage,
relation: "contains",
operation: "locate-electron-wrapper-path",
loaderAuthority: path.loader_authority,
properties: {
path_id: pathId,
entry: path.entry,
@@ -123,6 +134,7 @@ export const addElectronCallPaths = (
? "maps_to"
: "calls",
operation: "trace-electron-wrapper-path",
loaderAuthority: path.loader_authority,
properties: {
path_id: pathId,
entry: path.entry,
@@ -161,6 +173,11 @@ export const addElectronCallPaths = (
);
for (const from of sources)
for (const to of targets) {
if (
from.path.loader_authority === "candidate" ||
to.path.loader_authority === "candidate"
)
continue;
const source = from.nodes.at(-1),
target = to.nodes[0];
if (!source || !target) continue;
@@ -33,8 +33,29 @@ export interface ElectronGraphEdgeInput {
readonly properties: Readonly<Record<string, unknown>>;
readonly confidence?: "high" | "medium" | "low";
readonly limitations?: readonly string[];
readonly loaderAuthority?: "candidate" | undefined;
}
/** Candidate syntax does not establish that the receiver is Electron. */
export const ELECTRON_CANDIDATE_LIMITATION =
"The Electron module loader is unknown, so this relationship is only a candidate.";
/** Preserve syntax with uncertainty when its Electron receiver is unproven. */
export const electronFindingEvidence = (
input: Parameters<typeof astObservationEvidence>[0],
loaderAuthority: "candidate" | undefined,
) =>
loaderAuthority === "candidate"
? staticInferenceEvidence({
...input,
confidence: "low",
limitations: [
...(input.limitations ?? []),
ELECTRON_CANDIDATE_LIMITATION,
],
})
: astObservationEvidence(input);
/** Resolve the file or recovered bundle module that owns one finding. */
export const electronFindingSourceNode = (
context: JavaScriptArtifactGraphContext,
@@ -69,17 +90,25 @@ export const addElectronAstEdge = (
source_node_id: input.source.node_id,
target_node_id: input.target.node_id,
relation: input.relation,
properties: input.properties,
evidence: astObservationEvidence({
sha256: input.file.sha256,
path: input.file.path,
range: input.range,
operation: input.operation,
coverage: input.coverage,
...(input.limitations === undefined
? {}
: { limitations: input.limitations }),
}),
properties: {
...input.properties,
...(input.loaderAuthority === "candidate"
? { loader_authority: "candidate" }
: {}),
},
evidence: electronFindingEvidence(
{
sha256: input.file.sha256,
path: input.file.path,
range: input.range,
operation: input.operation,
coverage: input.coverage,
...(input.limitations === undefined
? {}
: { limitations: input.limitations }),
},
input.loaderAuthority,
),
});
};
@@ -92,19 +121,33 @@ export const addElectronInferenceEdge = (
source_node_id: input.source.node_id,
target_node_id: input.target.node_id,
relation: input.relation,
properties: input.properties,
properties: {
...input.properties,
...(input.loaderAuthority === "candidate"
? { loader_authority: "candidate" }
: {}),
},
evidence: staticInferenceEvidence({
sha256: input.file.sha256,
path: input.file.path,
range: input.range,
operation: input.operation,
coverage: input.coverage,
...(input.confidence === undefined
? {}
: { confidence: input.confidence }),
...(input.limitations === undefined
? {}
: { limitations: input.limitations }),
...(input.loaderAuthority === "candidate"
? { confidence: "low" as const }
: input.confidence === undefined
? {}
: { confidence: input.confidence }),
...(input.loaderAuthority === "candidate"
? {
limitations: [
...(input.limitations ?? []),
ELECTRON_CANDIDATE_LIMITATION,
],
}
: input.limitations === undefined
? {}
: { limitations: input.limitations }),
}),
});
};
@@ -12,10 +12,7 @@ import {
type JavaScriptArtifactGraphContext,
type JavaScriptArtifactGraphCoverage,
} from "./JavaScriptArtifactGraphContext.js";
import {
astObservationEvidence,
staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
import { staticInferenceEvidence } from "./JavaScriptArtifactGraphEvidence.js";
import type {
ElectronIpcContext,
ElectronIpcRecord,
@@ -23,6 +20,8 @@ import type {
import {
addElectronAstEdge,
addElectronInferenceEdge,
ELECTRON_CANDIDATE_LIMITATION,
electronFindingEvidence,
electronFindingSourceNode,
electronObservationIdentity,
} from "./ElectronBoundaryGraphContext.js";
@@ -111,6 +110,7 @@ const addIpcRecord = (
coverage,
relation: record.finding.mode === "invoke" ? "invokes" : "sends",
operation: "map-ipc-transmission",
loaderAuthority: record.finding.loader_authority,
properties: {
side: record.finding.side,
operation: record.finding.operation,
@@ -150,10 +150,13 @@ const addChannelNode = (
return context.accumulator.addNode({
kind: "ipc-channel",
identity:
finding.resolution_basis === "wrapper-substitution"
finding.resolution_basis === "wrapper-substitution" ||
finding.loader_authority === "candidate"
? artifactLocalIdentity(
file.sha256,
"electron-inferred-ipc-channel",
finding.loader_authority === "candidate"
? "electron-candidate-ipc-channel"
: "electron-inferred-ipc-channel",
identityKey,
)
: electronObservationIdentity(
@@ -174,36 +177,67 @@ const addChannelNode = (
channel: finding.channel,
channel_expression: finding.channel_expression,
resolution: finding.channel === null ? "dynamic" : "literal",
...(finding.loader_authority === "candidate"
? { loader_authority: "candidate" }
: {}),
...(finding.invocation_location === undefined
? {}
: { invocation_location: finding.invocation_location }),
},
evidence: (finding.resolution_basis === "wrapper-substitution"
? staticInferenceEvidence
: astObservationEvidence)({
sha256: file.sha256,
path: file.path,
range: finding.location,
operation: "observe-ipc-channel-expression",
coverage,
limitations: [
...(finding.receiver === undefined
? []
: [
"The receiver is a source-level allocation, not a runtime frame identity; registration and dispatch remain unverified.",
]),
...(finding.resolution_basis !== "wrapper-substitution"
? []
: [
"The channel is inferred through lexical wrapper substitution; runtime registration and dispatch remain unverified.",
]),
...(finding.channel !== null
? []
: [
"The channel expression is dynamic; its runtime value and pairings remain unknown.",
]),
],
}),
evidence:
finding.resolution_basis !== "wrapper-substitution"
? electronFindingEvidence(
{
sha256: file.sha256,
path: file.path,
range: finding.location,
operation: "observe-ipc-channel-expression",
coverage,
limitations: [
...(finding.receiver === undefined
? []
: [
"The receiver is a source-level allocation, not a runtime frame identity; registration and dispatch remain unverified.",
]),
...(finding.channel !== null
? []
: [
"The channel expression is dynamic; its runtime value and pairings remain unknown.",
]),
],
},
finding.loader_authority,
)
: staticInferenceEvidence({
sha256: file.sha256,
path: file.path,
range: finding.location,
operation: "observe-ipc-channel-expression",
coverage,
...(finding.loader_authority === "candidate"
? { confidence: "low" as const }
: {}),
limitations: [
...(finding.loader_authority === "candidate"
? [ELECTRON_CANDIDATE_LIMITATION]
: []),
...(finding.receiver === undefined
? []
: [
"The receiver is a source-level allocation, not a runtime frame identity; registration and dispatch remain unverified.",
]),
...(finding.resolution_basis !== "wrapper-substitution"
? []
: [
"The channel is inferred through lexical wrapper substitution; runtime registration and dispatch remain unverified.",
]),
...(finding.channel !== null
? []
: [
"The channel expression is dynamic; its runtime value and pairings remain unknown.",
]),
],
}),
},
],
});
@@ -245,19 +279,38 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => {
channel_expression: finding.channel_expression,
handler_kind: finding.handler_kind,
handler_location: finding.handler_location,
...(finding.loader_authority === "candidate"
? { loader_authority: "candidate" }
: {}),
...(finding.invocation_location === undefined
? {}
: { invocation_location: finding.invocation_location }),
},
evidence: (finding.resolution_basis === "wrapper-substitution"
? staticInferenceEvidence
: astObservationEvidence)({
sha256: file.sha256,
path: file.path,
range: handlerRange,
operation: "observe-ipc-handler",
coverage,
}),
evidence:
finding.resolution_basis !== "wrapper-substitution"
? electronFindingEvidence(
{
sha256: file.sha256,
path: file.path,
range: handlerRange,
operation: "observe-ipc-handler",
coverage,
},
finding.loader_authority,
)
: staticInferenceEvidence({
sha256: file.sha256,
path: file.path,
range: handlerRange,
operation: "observe-ipc-handler",
coverage,
...(finding.loader_authority === "candidate"
? {
confidence: "low" as const,
limitations: [ELECTRON_CANDIDATE_LIMITATION],
}
: {}),
}),
},
],
});
@@ -270,6 +323,7 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => {
relation: "contains",
operation: "locate-ipc-handler",
properties: { side: finding.side, operation: finding.operation },
loaderAuthority: finding.loader_authority,
});
addElectronInferenceEdge(context, {
source: handler,
@@ -279,6 +333,7 @@ const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => {
coverage,
relation: "handles",
operation: "map-ipc-handler-registration",
loaderAuthority: finding.loader_authority,
properties: {
channel: finding.channel,
channel_expression: finding.channel_expression,
@@ -362,17 +417,23 @@ const addValidationObservation = (input: ValidationObservationInput): void => {
mechanism: validation.mechanism,
expected: validation.expected,
enforcement: validation.enforcement,
...(validation.loader_authority === "candidate"
? { loader_authority: "candidate" }
: {}),
},
evidence: astObservationEvidence({
sha256: file.sha256,
path: file.path,
range: validation.location,
operation: "observe-ipc-sender-validation-candidate",
coverage,
limitations: [
"A comparison or string check is visible, but static syntax alone does not prove rejection, ordering, or complete sender validation.",
],
}),
evidence: electronFindingEvidence(
{
sha256: file.sha256,
path: file.path,
range: validation.location,
operation: "observe-ipc-sender-validation-candidate",
coverage,
limitations: [
"A comparison or string check is visible, but static syntax alone does not prove rejection, ordering, or complete sender validation.",
],
},
validation.loader_authority,
),
},
],
});
@@ -3,15 +3,16 @@ import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSour
import type { ElectronNativeAddonBindingFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import {
artifactLocalIdentity,
javascriptAnalysisCoverage,
type JavaScriptArtifactGraphContext,
type JavaScriptArtifactGraphCoverage,
} from "./JavaScriptArtifactGraphContext.js";
import { astObservationEvidence } from "./JavaScriptArtifactGraphEvidence.js";
import { compositeKey } from "../../domain/unicodeCodePointOrder.js";
import { javascriptDisplayText } from "../../domain/javascript/javascriptAstValues.js";
import {
addElectronInferenceEdge,
electronFindingEvidence,
electronFindingSourceNode,
electronObservationIdentity,
} from "./ElectronBoundaryGraphContext.js";
@@ -45,15 +46,18 @@ const addNativeBinding = (input: NativeBindingInput): void => {
value.module_runtime,
);
if (source === undefined) return;
const resolved = resolveArtifactPathByContext({
declaredPath: value.specifier,
sourcePath: file.path,
context: "module-specifier",
files: context.filesByPath,
jsonDocuments: context.analysis.jsonDocuments,
directoryPaths: context.directoryPaths,
moduleKind: value.module_kind,
}).resolved_path;
const resolved =
value.loader_authority === "candidate"
? null
: resolveArtifactPathByContext({
declaredPath: value.specifier,
sourcePath: file.path,
context: "module-specifier",
files: context.filesByPath,
jsonDocuments: context.analysis.jsonDocuments,
directoryPaths: context.directoryPaths,
moduleKind: value.module_kind,
}).resolved_path;
// Extension probing can resolve `./addon.node` to `addon.node.js`; only a
// native-addon artifact can expose the requested binding.
const addon =
@@ -61,21 +65,29 @@ const addNativeBinding = (input: NativeBindingInput): void => {
context.filesByPath.get(resolved)?.kind !== "native-addon"
? undefined
: context.fileNodes.get(resolved);
const bindingKey = compositeKey([
file.path,
value.specifier,
value.binding_kind,
value.members,
value.namespace_access ?? null,
value.dynamic_member_access ?? null,
javascriptSourceRangeKey(value.location),
]);
const binding = context.accumulator.addNode({
kind: "native-export",
identity: electronObservationIdentity(
context,
"javascript-requested-native-export",
compositeKey([
file.path,
value.specifier,
value.binding_kind,
value.members,
value.namespace_access ?? null,
value.dynamic_member_access ?? null,
javascriptSourceRangeKey(value.location),
]),
),
identity:
value.loader_authority === "candidate"
? artifactLocalIdentity(
file.sha256,
"javascript-requested-native-export",
bindingKey,
)
: electronObservationIdentity(
context,
"javascript-requested-native-export",
bindingKey,
),
observations: [
{
label:
@@ -89,17 +101,23 @@ const addNativeBinding = (input: NativeBindingInput): void => {
dynamic_member_access: value.dynamic_member_access ?? null,
resolved_path: resolved,
native_export_verification: "not-performed",
...(value.loader_authority === "candidate"
? { loader_authority: "candidate" }
: {}),
},
evidence: astObservationEvidence({
sha256: file.sha256,
path: file.path,
range: value.location,
operation: "observe-native-addon-binding",
coverage,
limitations: [
"JavaScript syntax identifies requested member names; it does not prove that the native binary exports or implements them.",
],
}),
evidence: electronFindingEvidence(
{
sha256: file.sha256,
path: file.path,
range: value.location,
operation: "observe-native-addon-binding",
coverage,
limitations: [
"JavaScript syntax identifies requested member names; it does not prove that the native binary exports or implements them.",
],
},
value.loader_authority,
),
},
],
});
@@ -111,6 +129,7 @@ const addNativeBinding = (input: NativeBindingInput): void => {
coverage,
relation: value.binding_kind === "re-export" ? "exposes" : "imports",
operation: "map-native-addon-binding",
loaderAuthority: value.loader_authority,
properties: {
specifier: value.specifier,
binding_kind: value.binding_kind,
@@ -7,9 +7,9 @@ import {
type JavaScriptArtifactGraphContext,
type JavaScriptArtifactGraphCoverage,
} from "./JavaScriptArtifactGraphContext.js";
import { astObservationEvidence } from "./JavaScriptArtifactGraphEvidence.js";
import {
addElectronInferenceEdge,
electronFindingEvidence,
electronFindingSourceNode,
electronObservationIdentity,
} from "./ElectronBoundaryGraphContext.js";
@@ -67,6 +67,9 @@ const addProtocolRegistration = (input: ProtocolInput): void => {
role: value.role,
session: value.session,
enabled_privileges: value.enabled_privileges,
...(value.loader_authority === "candidate"
? { loader_authority: "candidate" }
: {}),
};
const limitations =
scheme !== null
@@ -86,10 +89,12 @@ const addProtocolRegistration = (input: ProtocolInput): void => {
const node = context.accumulator.addNode({
kind: "url-scheme",
identity:
scheme === null
scheme === null || value.loader_authority === "candidate"
? artifactLocalIdentity(
file.sha256,
"electron-dynamic-url-scheme",
value.loader_authority === "candidate"
? "electron-candidate-url-scheme"
: "electron-dynamic-url-scheme",
// Identical bytes at two paths are separate occurrences whose
// expressions can resolve differently.
`${file.path}:${value.api}:${javascriptSourceRangeKey(value.location)}`,
@@ -103,14 +108,17 @@ const addProtocolRegistration = (input: ProtocolInput): void => {
{
label: scheme ?? value.scheme.expression ?? String(value.scheme.value),
properties: facts,
evidence: astObservationEvidence({
sha256: file.sha256,
path: file.path,
range: value.location,
operation: "observe-protocol-registration",
coverage,
limitations,
}),
evidence: electronFindingEvidence(
{
sha256: file.sha256,
path: file.path,
range: value.location,
operation: "observe-protocol-registration",
coverage,
limitations,
},
value.loader_authority,
),
},
],
});
@@ -123,6 +131,7 @@ const addProtocolRegistration = (input: ProtocolInput): void => {
// Granting privileges exposes the scheme; the other roles claim its links.
relation: value.role === "privileged-scheme" ? "exposes" : "handles",
operation: "map-protocol-registration",
loaderAuthority: value.loader_authority,
properties: facts,
limitations,
});
@@ -14,13 +14,12 @@ import {
type JavaScriptArtifactGraphContext,
type JavaScriptArtifactGraphCoverage,
} from "./JavaScriptArtifactGraphContext.js";
import {
astObservationEvidence,
staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
import { staticInferenceEvidence } from "./JavaScriptArtifactGraphEvidence.js";
import {
addElectronAstEdge,
addElectronInferenceEdge,
ELECTRON_CANDIDATE_LIMITATION,
electronFindingEvidence,
electronFindingSourceNode,
} from "./ElectronBoundaryGraphContext.js";
import {
@@ -98,6 +97,9 @@ const addBrowserWindow = (
web_preferences: value.web_preferences,
preload_path: value.preload_path,
preload_resolution_context: value.preload_resolution_context,
...(value.loader_authority === "candidate"
? { loader_authority: "candidate" }
: {}),
...(value.resolution_sources === undefined
? {}
: {
@@ -110,21 +112,40 @@ const addBrowserWindow = (
}),
absence_means_default: false,
},
evidence: (value.resolution_sources === undefined
? astObservationEvidence
: staticInferenceEvidence)({
sha256: file.sha256,
path: file.path,
range: value.location,
operation:
value.resolution_sources === undefined
? "observe-browser-window-construction"
: "resolve-browser-window-constant-options",
coverage,
limitations: [
"Only explicitly present webPreferences are reported; omitted properties are not interpreted as Electron defaults.",
],
}),
evidence:
value.resolution_sources === undefined
? electronFindingEvidence(
{
sha256: file.sha256,
path: file.path,
range: value.location,
operation: "observe-browser-window-construction",
coverage,
limitations: [
"Only explicitly present webPreferences are reported; omitted properties are not interpreted as Electron defaults.",
],
},
value.loader_authority,
)
: staticInferenceEvidence({
sha256: file.sha256,
path: file.path,
range: value.location,
operation:
value.resolution_sources === undefined
? "observe-browser-window-construction"
: "resolve-browser-window-constant-options",
coverage,
limitations: [
"Only explicitly present webPreferences are reported; omitted properties are not interpreted as Electron defaults.",
...(value.loader_authority === "candidate"
? [ELECTRON_CANDIDATE_LIMITATION]
: []),
],
...(value.loader_authority === "candidate"
? { confidence: "low" as const }
: {}),
}),
},
],
});
@@ -137,6 +158,7 @@ const addBrowserWindow = (
relation: "contains",
operation: "locate-browser-window-construction",
properties: { source_path: file.path },
loaderAuthority: value.loader_authority,
});
addWindowPreload(input, window);
};
@@ -145,7 +167,11 @@ const addWindowPreload = (
input: FindingInput<ElectronBrowserWindowFinding>,
window: ApplicationNode,
): void => {
if (input.value.preload_path === null) return;
if (
input.value.preload_path === null ||
input.value.loader_authority === "candidate"
)
return;
const {
preload_path: declared,
preload_resolution_context: resolutionContext,
@@ -217,16 +243,35 @@ const addContextBridge = (
members: value.members,
unknown_members: value.unknown_members,
resolution_basis: value.resolution_basis ?? "direct-syntax",
...(value.loader_authority === "candidate"
? { loader_authority: "candidate" }
: {}),
},
evidence: (value.resolution_basis === "wrapper-substitution"
? staticInferenceEvidence
: astObservationEvidence)({
sha256: file.sha256,
path: file.path,
range: value.location,
operation: "observe-context-bridge-api",
coverage,
}),
evidence:
value.resolution_basis !== "wrapper-substitution"
? electronFindingEvidence(
{
sha256: file.sha256,
path: file.path,
range: value.location,
operation: "observe-context-bridge-api",
coverage,
},
value.loader_authority,
)
: staticInferenceEvidence({
sha256: file.sha256,
path: file.path,
range: value.location,
operation: "observe-context-bridge-api",
coverage,
...(value.loader_authority === "candidate"
? {
confidence: "low" as const,
limitations: [ELECTRON_CANDIDATE_LIMITATION],
}
: {}),
}),
},
],
});
@@ -238,6 +283,7 @@ const addContextBridge = (
coverage,
relation: "exposes",
operation: "map-context-bridge-exposure",
loaderAuthority: value.loader_authority,
properties: {
world: value.world,
api_key: value.api_key,
@@ -251,7 +297,7 @@ const addUtilityProcess = (
): void => {
const { context, file, value, coverage } = input;
const resolution =
value.module_path === null
value.module_path === null || value.loader_authority === "candidate"
? undefined
: resolveArtifactPathByContext({
declaredPath: value.module_path,
@@ -283,14 +329,20 @@ const addUtilityProcess = (
module_resolution_context: value.module_resolution_context,
module_expression: value.module_expression,
service_name: value.service_name,
...(value.loader_authority === "candidate"
? { loader_authority: "candidate" }
: {}),
},
evidence: astObservationEvidence({
sha256: file.sha256,
path: file.path,
range: value.location,
operation: "observe-utility-process-fork",
coverage,
}),
evidence: electronFindingEvidence(
{
sha256: file.sha256,
path: file.path,
range: value.location,
operation: "observe-utility-process-fork",
coverage,
},
value.loader_authority,
),
},
...(resolution === undefined
? []
@@ -318,6 +370,7 @@ const addUtilityProcess = (
coverage,
relation: "loads",
operation: "map-utility-process-launch",
loaderAuthority: value.loader_authority,
limitations: resolution?.limitations ?? [],
properties: {
declared_path: value.module_path,
@@ -129,7 +129,7 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async (
signal,
onPhase === undefined ? undefined : (phase) => onPhase(file, phase),
preloadPaths.has(`${file.container_sha256}:${file.path}`) &&
/\.js$/iu.test(file.path)
posix.extname(file.path) === ".js"
? "commonjs"
: packageTypeFor(file),
);
@@ -2,6 +2,7 @@ import type { ElectronBoundarySummary } from "../../domain/javascript/javascript
import {
electronSemanticsState,
createElectronIpcContext,
hasCandidateElectronFindings,
summarizeElectronBoundaries,
type ElectronIpcContext,
} from "./ElectronBoundaryAnalysis.js";
@@ -381,6 +382,11 @@ const graphLimitations = (
"CommonJS and ESM binding relationships were recovered from inert syntax and resolved only within the inventoried artifact container.",
"Webpack/Rspack factories were recovered from AST literals; REA did not invoke push handlers or bundle bootstrap code.",
"Static imports, entrypoints, workers, endpoints, and storage relationships do not prove runtime execution.",
...(hasCandidateElectronFindings(context.analysis)
? [
"Conditional Electron findings are retained in the graph but excluded from proven boundary summary counts; their loader identity remains unknown.",
]
: []),
...(context.analysis.files.some(({ javascript }) =>
javascript?.limitations.includes(ELECTRON_IDENTITY_LIMITATION),
)
@@ -118,9 +118,25 @@ const graphFor = async (
};
describe("Electron semantic coverage", () => {
it.each(["main.js", "main.MJS"])(
"does not present candidate Electron boundaries as proven coverage for %s",
async (path) => {
const { limitations, electronSemantics } = await graphFor(
'const { BrowserWindow } = require("electron"); new BrowserWindow();',
undefined,
path,
);
expect(electronSemantics).toBeUndefined();
expect(limitations.join(" ")).toContain("Conditional Electron findings");
expect(limitations).not.toContain(EMPTY_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`,
undefined,
"main.cjs",
);
expect(limitations).toContain(EMPTY_COVERAGE);
expect(electronSemantics).toBe("empty");
@@ -129,6 +145,8 @@ describe("Electron semantic coverage", () => {
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`,
undefined,
"main.cjs",
);
expect(limitations).not.toContain(EMPTY_COVERAGE);
expect(limitations).toContainEqual(
@@ -171,6 +189,8 @@ describe("Electron semantic coverage", () => {
it("matches a supported Electron subpath and ignores an unrelated package", async () => {
const subpath = await graphFor(
`const { app } = require("electron/main");\napp.whenReady();\n`,
undefined,
"main.cjs",
);
expect(subpath.electronSemantics).toBe("empty");
const unrelated = await graphFor(
@@ -193,6 +213,7 @@ describe("Electron semantic coverage", () => {
limits: null,
},
}),
"main.cjs",
);
expect(electronSemantics).toBe("empty");
expect(limitations).toContain(EMPTY_COVERAGE);
@@ -207,6 +228,7 @@ describe("Electron semantic coverage", () => {
...entry,
semantic: null,
}),
"main.cjs",
);
expect(electronSemantics).toBe("empty");
});
@@ -304,6 +326,8 @@ it("classifies a bundle module by its loader, not by its name", async () => {
`100:(e,t,n)=>{"use strict";const s=n(45840);s.ipcMain.handle("x",()=>1)}`,
`n(100);`,
),
undefined,
"main.cjs",
);
expect(external.electronSemantics).toBe("recovered");
@@ -315,6 +339,8 @@ it("classifies a bundle module by its loader, not by its name", async () => {
`100:(e,t,n)=>{"use strict";const s=n("electron");s.ipcMain.handle("x",()=>1)}`,
`n(100);`,
),
undefined,
"main.cjs",
);
expect(externalUnderKey.electronSemantics).toBe("recovered");
});
@@ -205,7 +205,8 @@ const addReference = (
input.value.module_runtime,
) ?? input.asset;
const resolved =
input.value.specifier === null
input.value.specifier === null ||
input.value.loader_authority === "candidate"
? null
: resolveReference(context, input.file.path, input.value, input.coverage);
if (
@@ -319,11 +320,15 @@ const addReference = (
operation: input.value.operation,
specifier: input.value.specifier,
expression: input.value.expression,
...(input.value.loader_authority === "candidate"
? { loader_authority: "candidate" }
: {}),
resolution_base_path: input.value.resolution_base_path ?? null,
resolution_mode:
input.value.kind === "static-import" ||
input.value.kind === "dynamic-import" ||
input.value.kind === "require"
(input.value.kind === "require" &&
input.value.loader_authority !== "candidate")
? moduleResolutionModeForJavaScriptReference(
input.file.path,
input.value.kind,
@@ -333,7 +338,18 @@ const addReference = (
resolution_status: resolved?.resolution?.resolution_status ?? null,
...artifactPathResolutionProperties(resolved?.resolution),
},
...(resolved === null ? {} : { limitations: resolved.limitations }),
...(input.value.loader_authority === "candidate"
? { confidence: "low" as const }
: {}),
...(resolved === null
? input.value.loader_authority === "candidate"
? {
limitations: [
"The module loader is unknown, so this dependency cannot be resolved against the source file.",
],
}
: {}
: { limitations: resolved.limitations }),
});
};
@@ -354,7 +370,7 @@ const addEndpoint = (
const candidateLimitation =
input.value.kind === "route"
? "Only the property name suggests a route; the enclosing object has no router fields."
: "The unbound method-like argument is only a candidate; its receiver does not establish a network call.";
: "The receiver or loader identity is unproven; this network endpoint is only a candidate.";
const partialLimitation =
"The request target is not an exact literal; the endpoint value retains the unresolved source expression.";
const label = `${input.value.kind} ${candidate ? "endpoint candidate" : "endpoint"}`;
@@ -388,7 +404,10 @@ const addEndpoint = (
operation: "classify-static-endpoint-candidate",
coverage: input.coverage,
confidence: "low",
limitations: [candidateLimitation],
limitations: [
candidateLimitation,
...(partial ? [partialLimitation] : []),
],
})
: astObservationEvidence({
sha256: input.file.sha256,
@@ -416,7 +435,10 @@ const addEndpoint = (
...(candidate
? {
confidence: "low" as const,
limitations: [candidateLimitation],
limitations: [
candidateLimitation,
...(partial ? [partialLimitation] : []),
],
}
: partial
? { limitations: [partialLimitation] }
@@ -496,6 +518,10 @@ const addDiscoveredRole = (
input: FindingInput<StaticRolePath>,
changedRoots: ReadonlySet<string> | null,
): void => {
if (input.value.loader_authority === "candidate") {
addCandidateRole(context, input);
return;
}
const root = applicationRootPath(context, input.file);
const resolved = resolveArtifactPathByContext({
declaredPath: input.value.path,
@@ -575,6 +601,67 @@ const addDiscoveredRole = (
});
};
/** Retain the requested path without asserting that an unknown loader creates an Electron role. */
const addCandidateRole = (
context: JavaScriptArtifactGraphContext,
input: FindingInput<StaticRolePath>,
): void => {
const limitation =
"The Electron loader is unknown, so the renderer role and target ownership remain unproven.";
const source =
sourceNodeFor(
context,
input.file.path,
input.value.module_key,
input.value.module_runtime,
) ?? input.asset;
const properties = {
candidate_role: input.value.role,
loader_authority: "candidate",
declared_path: input.value.path,
resolution_status: "unavailable",
resolved_path: null,
mechanism: input.value.mechanism,
...(input.value.path_base === undefined
? {}
: { path_base: input.value.path_base }),
};
const role = context.accumulator.addNode({
kind: "unknown",
identity: artifactLocalIdentity(
input.file.sha256,
"candidate-electron-role",
JSON.stringify([input.file.path, input.value.path, input.value.location]),
),
observations: [
{
label: input.value.path,
properties,
evidence: staticInferenceEvidence({
sha256: input.file.sha256,
path: input.file.path,
range: input.value.location,
operation: "retain-candidate-electron-role",
coverage: input.coverage,
confidence: "low",
limitations: [limitation],
}),
},
],
});
addStaticInferenceEdge(context, {
source,
target: role,
file: input.file,
range: input.value.location,
coverage: input.coverage,
relation: "loads",
properties,
confidence: "low",
limitations: [limitation],
});
};
const addSourceMapEdge = (
context: JavaScriptArtifactGraphContext,
input: FindingInput<StaticSourceMap>,
@@ -925,6 +1012,9 @@ const unresolvedReferenceNode = (
reference_kind: reference.kind,
specifier: reference.specifier,
expression: reference.expression,
...(reference.loader_authority === "candidate"
? { loader_authority: "candidate" }
: {}),
resolution: "unresolved",
...(resolution === undefined
? {}
@@ -940,6 +1030,9 @@ const unresolvedReferenceNode = (
confidence: "low",
limitations: [
"The static reference could not be resolved within the inventoried artifact.",
...(reference.loader_authority === "candidate"
? ["The module loader is unknown."]
: []),
...(resolution?.limitations ?? []),
],
}),
@@ -5,7 +5,11 @@ import { createHash } from "node:crypto";
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js";
import {
analyzeJavaScriptStaticSource,
analyzeParsedJavaScriptStaticSource,
} from "../../domain/javascript/javascriptStaticAnalysis.js";
import { parseJavaScriptSource } from "../../domain/javascript/javascriptSourceParser.js";
import type {
AnalyzedJavaScriptArtifactFile,
JavaScriptArtifactAnalysis,
@@ -25,6 +29,13 @@ const SHA256 = "a".repeat(64);
const REASON =
"main.js: Resource constraint during analyze_javascript_application: Owned JavaScript worker exhausted its heap while analyzing main.js during static";
/** Preserve the explicit artifact filename when analyzing owned test fixtures. */
const analyzeOwnedSource = (source: string, path: string) => {
const parsed = parseJavaScriptSource(source, path);
if (parsed === null) throw new Error(`Unable to parse ${path}`);
return analyzeParsedJavaScriptStaticSource(source, parsed);
};
const skippedFile = (): JavaScriptArtifactFile => ({
path: "main.js",
container_sha256: SHA256,
@@ -245,15 +256,17 @@ it.each([false, true])(
const analysis = analysisFor([
{
file: setter,
javascript: analyzeJavaScriptStaticSource(
javascript: analyzeOwnedSource(
'const { app } = require("electron"); app.setAppPath("/changed");',
setter.path,
),
semantic: null,
},
{
file: window,
javascript: analyzeJavaScriptStaticSource(
javascript: analyzeOwnedSource(
'const { BrowserWindow } = require("electron"); new BrowserWindow().loadFile("index.html");',
window.path,
),
semantic: null,
},
@@ -361,20 +374,21 @@ it.each([
const entries: AnalyzedJavaScriptArtifactFile[] = [
{
file: main,
javascript: analyzeJavaScriptStaticSource(statement + window),
javascript: analyzeOwnedSource(statement + window, main.path),
semantic: null,
},
{
file: other,
javascript: analyzeJavaScriptStaticSource(window),
javascript: analyzeOwnedSource(window, other.path),
semantic: null,
},
...(gap === "transitive-unselected"
? [
{
file: makeFile("a/middle.cjs"),
javascript: analyzeJavaScriptStaticSource(
javascript: analyzeOwnedSource(
'require("./change.cjs");',
"a/middle.cjs",
),
semantic: null,
},
@@ -19,6 +19,8 @@ import type {
JavaScriptSemanticModuleLink,
} from "../../domain/javascript/javascriptSemanticIr.js";
import {
explicitJavaScriptLoaderMode,
hasCaseVariantJavaScriptLoaderSuffix,
isJsonModuleTarget,
moduleResolutionModeForJavaScriptReference,
} from "../../domain/javascript/javascriptModuleResolutionMode.js";
@@ -88,8 +90,12 @@ export const addJavaScriptSourceModules = (
if (asset === undefined || semantic === null || program === undefined)
continue;
const exports = semantic.ir.moduleLinks
.flatMap(({ exportedName, kind }) =>
exportedName === null || kind === "type-import" ? [] : [exportedName],
.flatMap(({ exportedName, kind, loaderAuthority }) =>
exportedName === null ||
kind === "type-import" ||
loaderAuthority === "candidate"
? []
: [exportedName],
)
.sort(compareUnicodeCodePoints);
const typeExports = semantic.ir.moduleLinks
@@ -97,7 +103,17 @@ export const addJavaScriptSourceModules = (
exportedName === null || kind !== "type-import" ? [] : [exportedName],
)
.sort(compareUnicodeCodePoints);
const retainedExports = [...new Set(exports)];
const retainedExportSet = new Set(exports);
const retainedExports = [...retainedExportSet];
const candidateExportNames = new Set(
semantic.ir.moduleLinks.flatMap(({ exportedName, loaderAuthority }) =>
exportedName !== null &&
loaderAuthority === "candidate" &&
!retainedExportSet.has(exportedName)
? [exportedName]
: [],
),
);
const source = context.accumulator.addNode({
kind: "javascript-module",
identity: artifactLocalIdentity(
@@ -124,7 +140,7 @@ export const addJavaScriptSourceModules = (
export_relationships: exports.length,
export_names: retainedExports,
type_export_names: [...new Set(typeExports)],
omitted_export_names: 0,
omitted_export_names: candidateExportNames.size,
},
evidence: astObservationEvidence({
sha256: file.sha256,
@@ -132,7 +148,14 @@ export const addJavaScriptSourceModules = (
range: program.location,
operation: "recover-source-module",
coverage: semanticCoverage(semantic),
limitations: semantic.ir.limitations,
limitations: [
...semantic.ir.limitations,
...(candidateExportNames.size === 0
? []
: [
"Candidate CommonJS export names are not included in the proven export names; the candidate relationships are retained separately.",
]),
],
}),
},
],
@@ -189,12 +212,17 @@ const addExportRelationship = (
const { context, file, semantic, source, link } = input;
if (link.exportedName === null) return;
const baseCoverage = semanticCoverage(semantic);
const returnShapes = projectJavaScriptExportReturnShapes({
ir: semantic.ir,
link,
modulePath: file.path,
baseCoverage,
});
const candidate = link.loaderAuthority === "candidate";
const candidateLimitation =
"The module wrapper is unknown, so this export is only a candidate.";
const returnShapes = candidate
? null
: projectJavaScriptExportReturnShapes({
ir: semantic.ir,
link,
modulePath: file.path,
baseCoverage,
});
const exported = context.accumulator.addNode({
kind: "javascript-module",
identity: artifactLocalIdentity(
@@ -214,14 +242,20 @@ const addExportRelationship = (
local_name: link.localName,
imported_name: link.importedName,
declared_specifier: link.specifier,
...(candidate ? { loader_authority: "candidate" } : {}),
},
evidence: astObservationEvidence({
evidence: (candidate
? staticInferenceEvidence
: astObservationEvidence)({
sha256: file.sha256,
path: file.path,
range: link.location,
operation: "recover-module-export",
coverage: baseCoverage,
limitations: semantic.ir.limitations,
limitations: candidate
? [...semantic.ir.limitations, candidateLimitation]
: semantic.ir.limitations,
...(candidate ? { confidence: "low" as const } : {}),
}),
},
...(returnShapes === null
@@ -247,7 +281,12 @@ const addExportRelationship = (
target_node_id: exported.node_id,
relation: "exposes",
properties: relationshipProperties(link, null),
evidence: relationshipEvidence(input, "expose-module-export", []),
evidence: relationshipEvidence(
input,
"expose-module-export",
candidate ? [candidateLimitation] : [],
candidate ? "low" : "high",
),
});
const origin = moduleOriginForExport(semantic.ir, link);
if (origin !== null)
@@ -260,7 +299,7 @@ const addImportRelationship = (
omissions: RelationshipOmissionCounter,
origin: JavaScriptModuleOrigin,
): void => {
const target = resolveModuleTarget(input, origin.specifier);
const target = resolveModuleTarget(input, origin);
// A specifier that resolves back to the importing module itself (a literal
// self-import, self-require, or artifact-confined path alias)
// cannot become an edge: the application graph forbids self-referential
@@ -289,8 +328,34 @@ const addImportRelationship = (
const resolveModuleTarget = (
input: RelationshipInput,
specifier: string,
origin: JavaScriptModuleOrigin,
): ResolvedModuleTarget => {
const specifier = origin.specifier;
if (
input.link.loaderAuthority === "candidate" ||
origin.loaderAuthority === "candidate"
) {
// The syntax retains a possible dependency, but an unproven wrapper
// cannot establish the loader or its lookup base.
const resolution: ArtifactPathResolution = {
declared_path: specifier,
resolution_context: "module-specifier",
resolution_status: "unavailable",
resolved_path: null,
limitations: [
"The module loader is unknown, so this dependency cannot be resolved against the source file.",
],
};
return {
node: unresolvedModuleNode(input, specifier, resolution),
path: null,
file: null,
jsonStatus: null,
status: resolution.resolution_status,
limitations: resolution.limitations,
resolution,
};
}
const resolution = resolveArtifactPathByContext({
declaredPath: specifier,
sourcePath: input.file.path,
@@ -370,7 +435,10 @@ const unresolvedModuleNode = (
operation: "retain-unresolved-semantic-module",
coverage: semanticCoverage(input.semantic),
confidence: "low",
limitations: input.semantic.ir.limitations,
limitations: [
...input.semantic.ir.limitations,
...resolution.limitations,
],
}),
},
],
@@ -387,6 +455,9 @@ const moduleOriginForExport = (
: { resolutionMode: link.resolutionMode }),
specifier: link.specifier,
importedPath: link.importedName === null ? [] : [link.importedName],
...(link.loaderAuthority === "candidate"
? { loaderAuthority: "candidate" as const }
: {}),
};
if (link.localName === null) return null;
const program = ir.scopes.find(({ kind }) => kind === "program");
@@ -394,9 +465,14 @@ const moduleOriginForExport = (
({ name, scopeId }) =>
name === link.localName && scopeId === program?.scopeId,
);
return binding?.provenance.status === "module" &&
binding.provenance.origins.length === 1
? (binding.provenance.origins[0] ?? null)
const provenance = binding?.provenance;
if (provenance === undefined || provenance.origins.length !== 1) return null;
const origin = provenance.origins[0];
return origin !== undefined &&
(provenance.status === "module" ||
(provenance.status === "ambiguous" &&
origin.loaderAuthority === "candidate"))
? origin
: null;
};
@@ -406,7 +482,14 @@ const relationshipProperties = (
origin?: JavaScriptModuleOrigin,
) => ({
module_link_kind: link.kind,
...moduleResolutionModeProperties(link.resolutionMode),
...(link.loaderAuthority === "candidate" ||
origin?.loaderAuthority === "candidate"
? { loader_authority: "candidate" }
: {}),
...(link.loaderAuthority === "candidate" ||
origin?.loaderAuthority === "candidate"
? { resolution_mode: null }
: moduleResolutionModeProperties(link.resolutionMode)),
specifier: origin?.specifier ?? link.specifier,
imported_name: origin?.importedPath.at(-1) ?? link.importedName,
imported_path: origin?.importedPath ?? [],
@@ -457,15 +540,19 @@ const moduleFormat = (
path: string,
ir: JavaScriptModuleSemanticIr,
): "commonjs" | "esm" | "mixed" | "unknown" => {
const loader = explicitJavaScriptLoaderMode(path);
if (loader === "module") return "esm";
if (loader === "commonjs") return "commonjs";
if (hasCaseVariantJavaScriptLoaderSuffix(path)) return "unknown";
const extension = posix.extname(path).toLowerCase();
if (extension === ".mjs" || extension === ".mts") return "esm";
if (extension === ".cjs" || extension === ".cts") return "commonjs";
if (extension === ".ts" || extension === ".tsx") return "unknown";
const esm = ir.moduleLinks.some(({ kind }) =>
["import", "export", "re-export"].includes(kind),
);
const commonJs = ir.moduleLinks.some(({ kind }) =>
["require", "commonjs-export"].includes(kind),
const commonJs = ir.moduleLinks.some(
({ kind, loaderAuthority }) =>
loaderAuthority !== "candidate" &&
["require", "commonjs-export"].includes(kind),
);
return esm && commonJs
? "mixed"
@@ -1,3 +1,5 @@
import { posix } from "node:path";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import {
@@ -41,7 +43,7 @@ export const discoverJavaScriptPreloadPaths = async (
(file) =>
file.kind === "javascript" &&
file.text.included &&
/\.js$/iu.test(file.path) &&
posix.extname(file.path) === ".js" &&
packageTypeFor(file) === "module",
)
)
@@ -87,7 +89,11 @@ export const discoverJavaScriptPreloadPaths = async (
break;
}
for (const window of result.value.javascript.electron.browser_windows) {
if (window.preload_path === null) continue;
if (
window.preload_path === null ||
window.loader_authority === "candidate"
)
continue;
const resolution = resolveArtifactPathByContext({
declaredPath: window.preload_path,
sourcePath: file.path,
@@ -9,7 +9,11 @@ import {
addSemanticGraphRelation,
addSemanticGraphUnknown,
} from "./JavaScriptSemanticGraphConstruction.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import {
unknownSemanticEvidence,
builtinSemanticNode,
builtinSemanticRelation,
} from "./JavaScriptSemanticGraphEvidence.js";
import {
projectSemanticResultBindings,
type SemanticFlowProjectionContext,
@@ -158,10 +162,10 @@ export const projectSemanticTimers = (
location: operation.location,
label: operation.method,
functionNodeId: owner?.node_id ?? null,
properties: {
...builtinSemanticNode(context.file, operation, {
delay_milliseconds: operation.delayMilliseconds,
method: operation.method,
},
}),
});
return timer === null ? [] : [[operation.timerId, timer] as const];
}),
@@ -177,17 +181,39 @@ const projectTimerSchedule = (
operation: JavaScriptSemanticTimerOperation,
timerNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): void => {
const authority = builtinSemanticRelation(
context.file,
operation,
"resolved",
);
const candidateEvidence = authority.evidence;
addSemanticGraphRelation(context.state, {
source: context.callSiteAt(operation.location),
target: timerNodes.get(operation.timerId),
relation: "schedules-timer",
resolution: "resolved",
...authority,
...(candidateEvidence === undefined
? {}
: {
properties: { builtin_authority: "candidate" },
}),
});
projectSemanticResultBindings(
context,
timerNodes.get(operation.timerId),
operation.projectedResultBindings,
candidateEvidence,
);
if (candidateEvidence !== undefined)
addSemanticGraphUnknown(context.state, {
node_id: timerNodes.get(operation.timerId)?.node_id ?? null,
family: "timer",
relation_kinds: ["schedules-timer"],
reason: "ambiguous-target",
detail: "The timer identity is conditional on loader or mutation facts.",
candidate_node_ids: [],
evidence: unknownSemanticEvidence(context.file, operation.location),
});
};
const projectTimerCancellation = (
@@ -196,6 +222,12 @@ const projectTimerCancellation = (
timerNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): void => {
const callSite = context.callSiteAt(operation.location);
const authority = builtinSemanticRelation(
context.file,
operation,
operation.resolution === "complete" ? "resolved" : "candidate",
);
const candidateEvidence = authority.evidence;
const target =
operation.linkedTimerId === null
? undefined
@@ -204,15 +236,24 @@ const projectTimerCancellation = (
source: callSite,
target,
relation: "cancels-timer",
resolution: operation.resolution === "complete" ? "resolved" : "candidate",
...authority,
...(candidateEvidence === undefined
? {}
: {
properties: { builtin_authority: "candidate" },
}),
});
if (operation.resolution === "complete") return;
if (operation.resolution === "complete" && candidateEvidence === undefined)
return;
addSemanticGraphUnknown(context.state, {
node_id: callSite?.node_id ?? null,
family: "timer",
relation_kinds: ["cancels-timer"],
reason: "ambiguous-target",
detail: `Static ${operation.method} handle resolution was ${operation.resolution}.`,
detail:
candidateEvidence === undefined
? `Static ${operation.method} handle resolution was ${operation.resolution}.`
: `The ${operation.method} identity is conditional on loader or mutation facts.`,
candidate_node_ids: [],
evidence: unknownSemanticEvidence(context.file, operation.location),
});
@@ -8,7 +8,11 @@ import {
addSemanticGraphRelation,
addSemanticGraphUnknown,
} from "./JavaScriptSemanticGraphConstruction.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import {
unknownSemanticEvidence,
builtinSemanticNode,
builtinSemanticRelation,
} from "./JavaScriptSemanticGraphEvidence.js";
import {
projectSemanticResultBindings,
type SemanticFlowProjectionContext,
@@ -62,13 +66,13 @@ const addChildProcessNode = (
spawn.ownerCallableId === null
? null
: (context.callableNodes.get(spawn.ownerCallableId)?.node_id ?? null),
properties: {
...builtinSemanticNode(context.file, spawn, {
argv_count: spawn.argvCount,
command: spawn.command,
environment_supplied: spawn.environmentSupplied,
method: spawn.method,
stdio_mode: spawn.stdioMode,
},
}),
});
const projectSpawn = (
@@ -77,19 +81,38 @@ const projectSpawn = (
child: JavaScriptSemanticGraphNode,
): void => {
const callSite = context.callSiteAt(spawn.location);
const authority = builtinSemanticRelation(
context.file,
spawn,
spawn.resolution === "complete" ? "resolved" : "candidate",
);
const candidateEvidence = authority.evidence;
addSemanticGraphRelation(context.state, {
source: callSite,
target: child,
relation: "spawns",
resolution: spawn.resolution === "complete" ? "resolved" : "candidate",
...authority,
...(candidateEvidence === undefined
? {}
: {
properties: { builtin_authority: "candidate" },
}),
});
projectSemanticResultBindings(context, child, spawn.projectedResultBindings);
projectSemanticResultBindings(
context,
child,
spawn.projectedResultBindings,
candidateEvidence,
);
if (spawn.argvCount !== null)
addSemanticGraphRelation(context.state, {
source: callSite,
target: child,
relation: "supplies-argv",
resolution: "resolved",
resolution: candidateEvidence === undefined ? "resolved" : "candidate",
...(candidateEvidence === undefined
? {}
: { evidence: candidateEvidence }),
properties: { argument_count: spawn.argvCount },
});
if (spawn.environmentSupplied)
@@ -97,14 +120,21 @@ const projectSpawn = (
source: callSite,
target: child,
relation: "supplies-env",
resolution: "resolved",
resolution: candidateEvidence === undefined ? "resolved" : "candidate",
...(candidateEvidence === undefined
? {}
: { evidence: candidateEvidence }),
});
const stdio = addStdioNode(context, spawn, child);
addSemanticGraphRelation(context.state, {
source: child,
target: stdio,
relation: "connects-stdio",
resolution: spawn.stdioMode === "dynamic" ? "candidate" : "resolved",
resolution:
candidateEvidence !== undefined || spawn.stdioMode === "dynamic"
? "candidate"
: "resolved",
...(candidateEvidence === undefined ? {} : { evidence: candidateEvidence }),
});
if (spawn.stdioMode === "dynamic")
addChildUnknown({
@@ -116,6 +146,16 @@ const projectSpawn = (
detail: `Static ${spawn.method} stdio options remain unresolved.`,
candidateNodeIds: [],
});
if (candidateEvidence !== undefined)
addChildUnknown({
context,
node: child,
location: spawn.location,
relationKinds: ["spawns"],
detail:
"The child-process identity is conditional on loader or mutation facts.",
candidateNodeIds: [child.node_id],
});
if (spawn.resolution !== "complete")
addChildUnknown({
context,
@@ -138,7 +178,9 @@ const addStdioNode = (
location: spawn.location,
label: spawn.stdioMode,
functionNodeId: child.function_node_id,
properties: { mode: spawn.stdioMode },
...builtinSemanticNode(context.file, spawn, {
mode: spawn.stdioMode,
}),
});
const projectInteraction = (
@@ -146,6 +188,12 @@ const projectInteraction = (
interaction: JavaScriptSemanticChildProcessInteraction,
processNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): void => {
const authority = builtinSemanticRelation(
context.file,
interaction,
interaction.resolution === "complete" ? "resolved" : "candidate",
);
const candidateEvidence = authority.evidence;
const children = interaction.linkedProcessIds.flatMap((identifier) => {
const child = processNodes.get(identifier);
return child === undefined ? [] : [child];
@@ -164,8 +212,12 @@ const projectInteraction = (
: interaction.eventName === "exit"
? "listens-exit"
: "listens-error",
resolution:
interaction.resolution === "complete" ? "resolved" : "candidate",
...authority,
...(candidateEvidence === undefined
? {}
: {
properties: { builtin_authority: "candidate" },
}),
});
if (interaction.resolution !== "complete")
addChildUnknown({
@@ -198,10 +250,10 @@ const addChildListenerNode = (
? null
: (context.callableNodes.get(interaction.ownerCallableId)?.node_id ??
null),
properties: {
...builtinSemanticNode(context.file, interaction, {
event_name: interaction.eventName,
method: interaction.method,
},
}),
});
const addSignalNode = (
@@ -218,7 +270,9 @@ const addSignalNode = (
? null
: (context.callableNodes.get(interaction.ownerCallableId)?.node_id ??
null),
properties: { signal_name: interaction.signalName },
...builtinSemanticNode(context.file, interaction, {
signal_name: interaction.signalName,
}),
});
const addChildUnknown = (input: ChildUnknownInput): void => {
@@ -64,6 +64,8 @@ export interface SemanticNodeConstructionInput {
readonly label: string | null;
readonly functionNodeId: string | null;
readonly properties?: Readonly<Record<string, JsonValue>>;
/** Evidence for inferred semantic identities; defaults to exact syntax. */
readonly evidence?: ApplicationGraphEvidence;
}
/** Input for one directed static semantic relationship. */
@@ -131,7 +133,8 @@ export const constructSemanticGraphNode = (
application_node_ids: [],
label: input.label === null ? null : javascriptDisplayText(input.label),
properties: input.properties ?? {},
evidence: observedSemanticEvidence(file, input.location),
evidence:
input.evidence ?? observedSemanticEvidence(file, input.location),
},
state.evidenceContexts,
);
@@ -9,7 +9,11 @@ import {
addSemanticGraphRelation,
addSemanticGraphUnknown,
} from "./JavaScriptSemanticGraphConstruction.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import {
builtinSemanticNode,
builtinSemanticRelation,
unknownSemanticEvidence,
} from "./JavaScriptSemanticGraphEvidence.js";
import {
projectSemanticResultBindings,
type SemanticFlowProjectionContext,
@@ -38,16 +42,22 @@ export const projectSemanticConfiguration = (
source: config,
target,
relation: configurationRelation(operation),
resolution:
...builtinSemanticRelation(
context.file,
operation,
operation.resolution === "complete" ? "resolved" : "candidate",
),
});
if (operation.kind === "default" && operation.sourceConfigId !== null)
addSemanticGraphRelation(context.state, {
source: configNodes.get(operation.sourceConfigId),
target: config,
relation: "overrides",
resolution:
...builtinSemanticRelation(
context.file,
operation,
operation.resolution === "complete" ? "resolved" : "candidate",
),
});
if (operation.resolution !== "complete")
addDataUnknown({
@@ -89,14 +99,14 @@ const addConfigurationNode = (
? null
: (context.callableNodes.get(operation.ownerCallableId)?.node_id ??
null),
properties: {
...builtinSemanticNode(context.file, operation, {
key: operation.key,
source_kind: operation.kind,
value: operation.value,
...(operation.valueResourceLimit === undefined
? {}
: { value_resource_limit: operation.valueResourceLimit }),
},
}),
});
const configurationRelation = (
@@ -163,11 +173,11 @@ const addRequestNode = (
? null
: (context.callableNodes.get(operation.ownerCallableId)?.node_id ??
null),
properties: {
...builtinSemanticNode(context.file, operation, {
endpoint: operation.endpoint,
method: operation.method,
operation_kind: operation.kind,
},
}),
});
const projectRequestConstruction = (
@@ -176,16 +186,22 @@ const projectRequestConstruction = (
request: JavaScriptSemanticGraphNode,
): void => {
const callSite = context.callSiteAt(operation.location);
const authority = builtinSemanticRelation(
context.file,
operation,
operation.resolution === "complete" ? "resolved" : "candidate",
);
addSemanticGraphRelation(context.state, {
source: callSite,
target: request,
relation: "constructs-request",
resolution: operation.resolution === "complete" ? "resolved" : "candidate",
...authority,
});
projectSemanticResultBindings(
context,
request,
operation.projectedResultBindings,
authority.evidence,
);
for (const field of operation.fields)
addSemanticGraphRelation(context.state, {
@@ -195,7 +211,11 @@ const projectRequestConstruction = (
: context.bindingNodes.get(field.sourceBindingId),
target: request,
relation: "supplies-request-field",
resolution: field.sourceBindingId === null ? "candidate" : "resolved",
...builtinSemanticRelation(
context.file,
operation,
field.sourceBindingId === null ? "candidate" : "resolved",
),
properties: { field: field.name },
});
};
@@ -209,7 +229,11 @@ const projectResponseConsumer = (
source: response,
target: context.callSiteAt(operation.location),
relation: "consumed-by",
resolution: operation.resolution === "complete" ? "resolved" : "candidate",
...builtinSemanticRelation(
context.file,
operation,
operation.resolution === "complete" ? "resolved" : "candidate",
),
properties: { method: operation.method },
});
};
@@ -7,6 +7,7 @@ import {
type SemanticGraphProjectionState,
} from "./JavaScriptSemanticGraphConstruction.js";
import { inferredSemanticEvidenceAt } from "./JavaScriptSemanticGraphEvidence.js";
import { ELECTRON_CANDIDATE_LIMITATION } from "./ElectronBoundaryGraphContext.js";
/** Give discovered process-boundary paths budget before bulk library definitions. */
export function* projectElectronSemanticCallPaths(
@@ -20,6 +21,18 @@ export function* projectElectronSemanticCallPaths(
let previous: ReturnType<typeof retainSemanticGraphNode> = null;
let previousKind: ElectronCallPath["steps"][number]["kind"] | undefined;
for (const [index, step] of path.steps.entries()) {
const inferred = inferredSemanticEvidenceAt(file, step.location);
const candidateEvidence =
path.loader_authority === "candidate"
? {
...inferred,
confidence: "low" as const,
limitations: [
...inferred.limitations,
ELECTRON_CANDIDATE_LIMITATION,
],
}
: undefined;
const node = retainSemanticGraphNode(state, file, {
kind:
step.kind === "function"
@@ -31,6 +44,9 @@ export function* projectElectronSemanticCallPaths(
location: step.location,
label: step.label,
functionNodeId: null,
...(candidateEvidence === undefined
? {}
: { evidence: candidateEvidence }),
properties: {
path_id: pathId,
entry: path.entry,
@@ -39,6 +55,9 @@ export function* projectElectronSemanticCallPaths(
: { entry_path: [...path.entry_path] }),
channel: path.channel,
terminal: path.terminal,
...(path.loader_authority === "candidate"
? { loader_authority: "candidate" }
: {}),
step_kind: step.kind,
resolution: "candidate",
conditions: path.conditions.map((c) => ({
@@ -61,7 +80,7 @@ export function* projectElectronSemanticCallPaths(
? "aliases"
: "calls",
resolution: "candidate",
evidence: inferredSemanticEvidenceAt(file, step.location),
evidence: candidateEvidence ?? inferred,
properties: {
path_id: pathId,
entry: path.entry,
@@ -70,6 +89,9 @@ export function* projectElectronSemanticCallPaths(
: { entry_path: [...path.entry_path] }),
channel: path.channel,
basis: "lexical-wrapper-substitution",
...(path.loader_authority === "candidate"
? { loader_authority: "candidate" }
: {}),
},
});
previous = node;
@@ -1,6 +1,7 @@
import type { ApplicationGraphEvidence } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import type { JsonValue } from "../../domain/jsonValue.js";
const INFERENCE_LIMITATION =
"This relationship is a conservative static candidate; it does not prove runtime execution or causal flow.";
@@ -64,6 +65,59 @@ export const inferredSemanticEvidenceAt = (
limitations: [INFERENCE_LIMITATION],
});
/** A built-in operation whose identity is conditional on loader or mutation facts. */
export const candidateBuiltinSemanticEvidence = (
file: JavaScriptArtifactFile,
location: JavaScriptSourceRange,
): ApplicationGraphEvidence => {
const evidence = inferredSemanticEvidenceAt(file, location);
return {
...evidence,
confidence: "low",
limitations: [
...evidence.limitations,
"The built-in identity or its mutation state is unproven; this operation is conditional.",
],
};
};
type SemanticBuiltinOperation = {
readonly builtinAuthority?: "candidate" | undefined;
readonly location: JavaScriptSourceRange;
};
/** Keep the candidate marker and its Evidence together on built-in nodes. */
export const builtinSemanticNode = (
file: JavaScriptArtifactFile,
operation: SemanticBuiltinOperation,
properties: Readonly<Record<string, JsonValue>>,
): {
readonly properties: Readonly<Record<string, JsonValue>>;
readonly evidence?: ApplicationGraphEvidence;
} =>
operation.builtinAuthority === "candidate"
? {
evidence: candidateBuiltinSemanticEvidence(file, operation.location),
properties: { ...properties, builtin_authority: "candidate" },
}
: { properties };
/** Keep conditional built-in authority and relation resolution together. */
export const builtinSemanticRelation = (
file: JavaScriptArtifactFile,
operation: SemanticBuiltinOperation,
resolution: "resolved" | "candidate",
): {
readonly resolution: "resolved" | "candidate";
readonly evidence?: ApplicationGraphEvidence;
} =>
operation.builtinAuthority === "candidate"
? {
resolution: "candidate",
evidence: candidateBuiltinSemanticEvidence(file, operation.location),
}
: { resolution };
/** Explicit absence of semantic syntax for an artifact-level fallback root. */
export const unavailableSemanticRootEvidence = (
rootArtifactSha256: string,
@@ -1,5 +1,6 @@
import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js";
import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js";
import type { ApplicationGraphEvidence } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
import { javaScriptSemanticNodeId } from "../../domain/javascript/javascriptSemanticGraph.js";
import type {
JavaScriptSemanticIr,
@@ -45,6 +46,7 @@ export const projectSemanticResultBindings = (
context: Pick<SemanticFlowProjectionContext, "state" | "bindingNodes">,
source: JavaScriptSemanticGraphNode | undefined,
bindings: readonly JavaScriptSemanticProjectedResultBinding[],
candidateEvidence?: ApplicationGraphEvidence,
): void => {
for (const projected of bindings)
addSemanticGraphRelation(context.state, {
@@ -52,7 +54,12 @@ export const projectSemanticResultBindings = (
target: context.bindingNodes.get(projected.bindingId),
relation: "destructures",
resolution:
projected.resolution === "complete" ? "resolved" : "candidate",
candidateEvidence === undefined && projected.resolution === "complete"
? "resolved"
: "candidate",
...(candidateEvidence === undefined
? {}
: { evidence: candidateEvidence }),
properties: {
projection_path: [...projected.projectionPath],
projection_resolution: projected.resolution,
@@ -5,7 +5,11 @@ import {
addSemanticGraphRelation,
addSemanticGraphUnknown,
} from "./JavaScriptSemanticGraphConstruction.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import {
builtinSemanticNode,
builtinSemanticRelation,
unknownSemanticEvidence,
} from "./JavaScriptSemanticGraphEvidence.js";
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
/** Project built-in resource acquisition and exact local release handles. */
@@ -25,7 +29,7 @@ export const projectSemanticResources = (
source: context.callSiteAt(operation.location),
target: resourceNodes.get(operation.resourceId),
relation: "acquires",
resolution: "resolved",
...builtinSemanticRelation(context.file, operation, "resolved"),
});
else projectRelease(context, operation, resourceNodes);
};
@@ -44,7 +48,9 @@ const addResourceNode = (
? null
: (context.callableNodes.get(operation.ownerCallableId)?.node_id ??
null),
properties: { method: operation.method },
...builtinSemanticNode(context.file, operation, {
method: operation.method,
}),
});
const projectRelease = (
@@ -62,8 +68,11 @@ const projectRelease = (
source: callSite,
target: resource,
relation: "releases",
resolution:
...builtinSemanticRelation(
context.file,
operation,
operation.resolution === "complete" ? "resolved" : "candidate",
),
});
if (operation.resolution === "complete") return;
addSemanticGraphUnknown(context.state, {
@@ -401,3 +401,33 @@ it("uses unavailable evidence when no source produced semantic IR", () => {
},
);
});
it("retains conditional Node timer and child effects without proving a loader", () => {
const graph = graphFor(`
const { spawn } = require('child_process');
const { setTimeout } = require('timers');
const child = spawn('tool', []); child.kill();
setTimeout(() => {}, 1);
`);
const child = graph.nodes.find((node) => node.kind === "child-process");
const timer = graph.nodes.find((node) => node.kind === "timer");
for (const node of [child, timer]) {
expect(node?.properties.builtin_authority).toBe("candidate");
if (node === undefined) throw new Error("Expected candidate node");
expect(
resolveJavaScriptSemanticEvidence(graph, node.evidence),
).toMatchObject({ state: "inferred", confidence: "low" });
}
for (const kind of [
"spawns",
"schedules-timer",
"forwards-signal",
] as const) {
const relation = graph.relations.find((item) => item.relation === kind);
expect(relation).toMatchObject({ resolution: "candidate" });
if (relation === undefined) throw new Error(`Expected ${kind} relation`);
expect(
resolveJavaScriptSemanticEvidence(graph, relation.evidence),
).toMatchObject({ state: "inferred", confidence: "low" });
}
});