refactor(javascript): group application workflows and verification (#896)

This commit is contained in:
ysopwnable
2026-10-07 16:03:37 +08:00
committed by GitHub
parent faaee693d5
commit 0804722cdf
129 changed files with 402 additions and 367 deletions
@@ -0,0 +1,59 @@
import { canonicalDigest } from "../../domain/comparisonSemantics.js";
import type { ProviderIdentity } from "../AnalysisProvider.js";
import type { Evidence, EvidenceObservation } from "../../domain/evidence.js";
import { createEvidence } from "../../domain/evidence.js";
import type {
ElectronActiveObservationInput,
ElectronActiveObservationResult,
} from "../../domain/electronActiveObservation.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
type CanonicalElectronActiveObservationInput =
ElectronActiveObservationInput & {
readonly application_root: string;
};
/** Create Evidence without retaining arbitrary runtime argument values. */
export const createElectronActiveEvidence = (
input: CanonicalElectronActiveObservationInput,
result: ElectronActiveObservationResult,
provider: ProviderIdentity,
): Evidence =>
createEvidence(undefined, provider, {
predicateType: "rea.electron-active-scenario",
operation: "capture_electron_scenario",
parameters: parameters(input),
result: jsonValueSchema.parse(result),
confidence: "observed",
authority: "controlled-replay",
environment: {
id: `${result.application.electron_version}@unknown`,
platform: "unknown",
architecture: "unknown",
isolation: "none",
},
limitations: [
...result.limitations,
"Application host platform and architecture were not observed.",
],
});
const scenarioProjection = (
input: CanonicalElectronActiveObservationInput,
): EvidenceObservation["parameters"] => ({
executable_path: input.executable_path,
application_path: input.application_path,
application_root: input.application_root,
args: [...input.args],
actions: input.actions.map(({ step_id, kind }) => ({ step_id, kind })),
});
const parameters = (
input: CanonicalElectronActiveObservationInput,
): EvidenceObservation["parameters"] => ({
...scenarioProjection(input),
scenario_sha256: canonicalDigest(
scenarioProjection(input),
"Browser scenario",
),
});
@@ -0,0 +1,19 @@
import type {
ExecutionOptions,
ProviderIdentity,
} from "../AnalysisProvider.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type {
ElectronActiveObservationInput,
ElectronActiveObservationResult,
} from "../../domain/electronActiveObservation.js";
import type { Result } from "../../domain/result.js";
/** Provider-neutral boundary for owned Electron runtime experiments. */
export interface ElectronActiveObservationPort {
identity(): ProviderIdentity;
capture(
input: ElectronActiveObservationInput,
options?: ExecutionOptions,
): Promise<Result<ElectronActiveObservationResult, AnalysisError>>;
}
@@ -0,0 +1,85 @@
import { dirname } from "node:path";
import { realpath } from "node:fs/promises";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { ElectronActiveObservationPort } from "./ElectronActiveObservationPort.js";
import {
AnalysisCapabilityUnavailableError,
AnalysisInputError,
} from "../../domain/analysisErrorCore.js";
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
import type { Evidence } from "../../domain/evidence.js";
import type { ElectronActiveObservationInput } from "../../domain/electronActiveObservation.js";
import { err, type Result } from "../../domain/result.js";
import { createElectronActiveEvidence } from "./ElectronActiveEvidence.js";
const OPERATION = "capture_electron_scenario" as const;
const PROVIDER_ID = "rea-playwright-electron-active";
type CanonicalElectronActiveObservationInput =
ElectronActiveObservationInput & {
readonly application_root: string;
};
const canonicalizeInput = async (
input: ElectronActiveObservationInput,
): Promise<Result<CanonicalElectronActiveObservationInput, AnalysisError>> => {
try {
const [executablePath, applicationPath] = await Promise.all([
realpath(input.executable_path),
realpath(input.application_path),
]);
const applicationRoot =
input.application_root === undefined
? dirname(applicationPath)
: await realpath(input.application_root);
return {
ok: true,
value: {
...input,
executable_path: executablePath,
application_path: applicationPath,
application_root: applicationRoot,
},
};
} catch (cause: unknown) {
return err(
new AnalysisInputError(OPERATION, { cause }, [
{
path: ["application_path"],
reason: "invalid_format",
message:
"Electron executable, application, and root must resolve to existing paths",
},
]),
);
}
};
/** Canonicalize request paths and execute one provider-owned Electron run. */
export const captureElectronScenario = async (
provider: ElectronActiveObservationPort | undefined,
input: ElectronActiveObservationInput,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
const canonical = await canonicalizeInput(input);
if (!canonical.ok) return canonical;
if (provider === undefined)
return err(
new AnalysisCapabilityUnavailableError(
PROVIDER_ID,
OPERATION,
"active Electron observation provider is not configured",
),
);
const captured = await provider.capture(canonical.value, options);
return captured.ok
? {
ok: true,
value: createElectronActiveEvidence(
canonical.value,
captured.value,
provider.identity(),
),
}
: captured;
};
@@ -0,0 +1,224 @@
import { compareCodePoints } from "../../domain/canonicalOrdering.js";
import type { ElectronIpcFinding } from "../../domain/electronStaticAnalysisTypes.js";
import type { ElectronBoundarySummary } from "../../domain/javascriptApplicationAnalysis.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
/** One IPC fact retaining its exact owning artifact for graph projection. */
export interface ElectronIpcRecord {
readonly key: string;
readonly file: JavaScriptArtifactFile;
readonly finding: ElectronIpcFinding;
}
/** An unambiguous literal renderer-to-main static pairing. */
export interface ElectronIpcPairing {
readonly transmission: ElectronIpcRecord;
readonly handler: ElectronIpcRecord;
}
/** Pairing state for one renderer transmission without overclaiming ambiguity. */
export interface ElectronIpcPairingState {
readonly transmission: ElectronIpcRecord;
readonly candidates: readonly ElectronIpcRecord[];
readonly status: "paired" | "ambiguous" | "unpaired";
}
/** Flatten all per-file IPC facts into deterministic application records. */
export const collectElectronIpcRecords = (
analysis: JavaScriptArtifactAnalysis,
): readonly ElectronIpcRecord[] =>
analysis.files
.flatMap(({ file, javascript }) =>
(javascript?.electron.ipc ?? []).map((finding) => ({
key: ipcRecordKey(file.path, finding),
file,
finding,
})),
)
.sort((left, right) => compareCodePoints(left.key, right.key));
/** Match only exact literal channels and retain ambiguous matches separately. */
export const classifyElectronIpcPairings = (
records: readonly ElectronIpcRecord[],
): readonly ElectronIpcPairingState[] => {
const handlers = records.filter(isMainHandler);
return records.filter(isPairableRendererTransmission).map((transmission) => {
const candidates =
transmission.finding.channel === null
? []
: handlers.filter(
(handler) =>
handler.finding.channel === transmission.finding.channel &&
compatibleIpcModes(transmission.finding, handler.finding),
);
return {
transmission,
candidates,
status:
candidates.length === 1
? "paired"
: candidates.length > 1
? "ambiguous"
: "unpaired",
};
});
};
/** Return only pairings supported by one unique literal main handler. */
export const unambiguousElectronIpcPairings = (
records: readonly ElectronIpcRecord[],
): readonly ElectronIpcPairing[] =>
classifyElectronIpcPairings(records).flatMap((state) => {
const handler = state.candidates[0];
return state.status === "paired" && handler !== undefined
? [{ transmission: state.transmission, handler }]
: [];
});
/** Summarize static Electron findings without treating gaps as absence. */
export const summarizeElectronBoundaries = (
analysis: JavaScriptArtifactAnalysis,
): ElectronBoundarySummary => {
const files = new Map(analysis.files.map(({ file }) => [file.path, file]));
const javascript = analysis.files.flatMap(({ javascript: value }) =>
value === null ? [] : [value],
);
const windows = javascript.flatMap(
({ electron }) => electron.browser_windows,
);
const bridges = javascript.flatMap(
({ electron }) => electron.context_bridge_apis,
);
const validations = javascript.flatMap(
({ electron }) => electron.sender_validations,
);
const utilities = analysis.files.flatMap(({ file, javascript: value }) =>
(value?.electron.utility_processes ?? []).map((finding) => ({
file,
finding,
})),
);
const nativeBindings = analysis.files.flatMap(({ file, javascript: value }) =>
(value?.electron.native_addon_bindings ?? []).map((finding) => ({
file,
finding,
})),
);
const ipc = collectElectronIpcRecords(analysis);
const pairing = classifyElectronIpcPairings(ipc);
const literalChannels = new Set(
ipc.flatMap(({ finding }) =>
finding.channel === null ? [] : [finding.channel],
),
);
return {
browser_windows: windows.length,
explicit_web_preferences: windows.reduce(
(count, window) => count + window.web_preferences.length,
0,
),
preload_entrypoints: windows.filter(
({ preload_path: path }) => path !== null,
).length,
context_bridge_apis: bridges.length,
exposed_api_members: bridges.reduce(
(count, bridge) => count + bridge.members.length,
0,
),
ipc: {
operations: ipc.length,
literal_channels: literalChannels.size,
dynamic_channel_operations: ipc.filter(
({ finding }) => finding.channel === null,
).length,
renderer_transmissions: ipc.filter(isRendererTransmission).length,
renderer_listeners: ipc.filter(
({ finding }) =>
finding.side === "renderer" && finding.mode === "listen",
).length,
main_handlers: ipc.filter(isMainHandler).length,
paired_renderer_transmissions: pairing.filter(
({ status }) => status === "paired",
).length,
ambiguous_renderer_transmissions: pairing.filter(
({ status }) => status === "ambiguous",
).length,
unpaired_literal_renderer_transmissions: pairing.filter(
({ status, transmission }) =>
status === "unpaired" && transmission.finding.channel !== null,
).length,
},
sender_validation_observations: validations.length,
utility_processes: utilities.length,
resolved_utility_entrypoints: utilities.filter(
({ file, finding }) =>
finding.module_path !== null &&
resolvesToFile({
file,
specifier: finding.module_path,
context: finding.module_resolution_context,
files,
}),
).length,
native_addon_bindings: nativeBindings.length,
resolved_native_addon_bindings: nativeBindings.filter(({ file, finding }) =>
resolvesToFile({
file,
specifier: finding.specifier,
context: "module-specifier",
files,
kind: "native-addon",
moduleKind: finding.module_kind,
}),
).length,
};
};
const compatibleIpcModes = (
transmission: ElectronIpcFinding,
handler: ElectronIpcFinding,
): boolean =>
transmission.mode === "invoke"
? handler.mode === "handle"
: transmission.operation === "send-to-host"
? false
: transmission.mode === "send" && handler.mode === "listen";
const isRendererTransmission = ({ finding }: ElectronIpcRecord): boolean =>
finding.side === "renderer" &&
(finding.mode === "send" || finding.mode === "invoke");
const isPairableRendererTransmission = (record: ElectronIpcRecord): boolean =>
isRendererTransmission(record) && record.finding.operation !== "send-to-host";
const isMainHandler = ({ finding }: ElectronIpcRecord): boolean =>
finding.side === "main" &&
(finding.mode === "listen" || finding.mode === "handle");
interface ResolveToFileInput {
readonly file: JavaScriptArtifactFile;
readonly specifier: string;
readonly context: "module-specifier" | "filesystem-expression";
readonly files: ReadonlyMap<string, JavaScriptArtifactFile>;
readonly kind?: JavaScriptArtifactFile["kind"];
readonly moduleKind?: "import" | "require";
}
const resolvesToFile = (input: ResolveToFileInput): boolean => {
const resolved = resolveArtifactPathByContext({
declaredPath: input.specifier,
sourcePath: input.file.path,
context: input.context,
files: input.files,
...(input.moduleKind === undefined ? {} : { moduleKind: input.moduleKind }),
}).resolved_path;
return (
resolved !== null &&
(input.kind === undefined || input.files.get(resolved)?.kind === input.kind)
);
};
const ipcRecordKey = (path: string, finding: ElectronIpcFinding): string =>
`${path}\0${String(finding.location.start.line)}:${String(finding.location.start.column)}\0${finding.side}\0${finding.operation}`;
@@ -0,0 +1,13 @@
import type { JavaScriptArtifactGraphContext } from "./JavaScriptArtifactGraphContext.js";
import { addElectronIpcBoundaries } from "./ElectronBoundaryGraphIpc.js";
import { addElectronNativeBoundaries } from "./ElectronBoundaryGraphNative.js";
import { addElectronWindowBoundaries } from "./ElectronBoundaryGraphWindows.js";
/** Add every static Electron process and security-boundary fact to the JAG. */
export const addElectronBoundaries = (
context: JavaScriptArtifactGraphContext,
): void => {
addElectronWindowBoundaries(context);
addElectronIpcBoundaries(context);
addElectronNativeBoundaries(context);
};
@@ -0,0 +1,127 @@
import type { ApplicationNode } from "../../domain/javascriptApplicationGraph.js";
import { sha256Text } from "../../domain/javascriptStaticAnalysisHelpers.js";
import type { JavaScriptSourceRange } from "../../domain/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import type {
JavaScriptArtifactGraphContext,
JavaScriptArtifactGraphCoverage,
} from "./JavaScriptArtifactGraphContext.js";
import {
astObservationEvidence,
staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
/** Values for one Electron syntax relationship retained in the graph. */
export interface ElectronGraphEdgeInput {
readonly source: ApplicationNode;
readonly target: ApplicationNode;
readonly file: JavaScriptArtifactFile;
readonly range: JavaScriptSourceRange;
readonly coverage: JavaScriptArtifactGraphCoverage;
readonly relation:
| "contains"
| "loads"
| "imports"
| "maps_to"
| "exposes"
| "sends"
| "invokes"
| "handles";
readonly operation: string;
readonly properties: Readonly<Record<string, unknown>>;
readonly confidence?: "high" | "medium" | "low";
readonly limitations?: readonly string[];
}
/** Resolve the file or recovered bundle module that owns one finding. */
export const electronFindingSourceNode = (
context: JavaScriptArtifactGraphContext,
file: JavaScriptArtifactFile,
moduleKey: string | null,
): ApplicationNode | undefined =>
moduleKey === null
? (context.assetNodes.get(file.path) ?? context.fileNodes.get(file.path))
: context.moduleNodes.get(`${file.path}\0${moduleKey}`);
/** Observation-scoped identity shared safely across different artifact files. */
export const electronObservationIdentity = (
context: JavaScriptArtifactGraphContext,
scope: string,
key: string,
) => ({
strategy: "observation-fingerprint" as const,
stability: "observation-only" as const,
observation_sha256: sha256Text(
`${context.snapshot.manifest.root_sha256}\0${scope}\0${key}`,
),
scope,
});
/** Add a direct AST syntax relationship. */
export const addElectronAstEdge = (
context: JavaScriptArtifactGraphContext,
input: ElectronGraphEdgeInput,
): void => {
context.accumulator.addEdge({
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 }),
}),
});
};
/** Add an Electron relationship that static syntax suggests but cannot prove. */
export const addElectronInferenceEdge = (
context: JavaScriptArtifactGraphContext,
input: ElectronGraphEdgeInput,
): void => {
context.accumulator.addEdge({
source_node_id: input.source.node_id,
target_node_id: input.target.node_id,
relation: input.relation,
properties: input.properties,
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 }),
}),
});
};
/** Deterministic source-range key for artifact-local graph identities. */
export const electronRangeKey = (range: JavaScriptSourceRange): string =>
`${String(range.start.line)}:${String(range.start.column)}-${String(range.end.line)}:${String(range.end.column)}`;
/** Test exact source-range containment without assuming runtime reachability. */
export const electronRangeContains = (
outer: JavaScriptSourceRange,
inner: JavaScriptSourceRange,
): boolean =>
comparePoint(outer.start, inner.start) <= 0 &&
comparePoint(outer.end, inner.end) >= 0;
const comparePoint = (
left: JavaScriptSourceRange["start"],
right: JavaScriptSourceRange["start"],
): number =>
left.line === right.line
? left.column - right.column
: left.line - right.line;
@@ -0,0 +1,342 @@
import type { ApplicationNode } from "../../domain/javascriptApplicationGraph.js";
import type { ElectronSenderValidationFinding } from "../../domain/electronStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import {
artifactLocalIdentity,
javascriptAnalysisCoverage,
type JavaScriptArtifactGraphContext,
type JavaScriptArtifactGraphCoverage,
} from "./JavaScriptArtifactGraphContext.js";
import { astObservationEvidence } from "./JavaScriptArtifactGraphEvidence.js";
import {
collectElectronIpcRecords,
unambiguousElectronIpcPairings,
type ElectronIpcRecord,
} from "./ElectronBoundaryAnalysis.js";
import {
addElectronAstEdge,
addElectronInferenceEdge,
electronFindingSourceNode,
electronObservationIdentity,
electronRangeContains,
electronRangeKey,
} from "./ElectronBoundaryGraphContext.js";
interface IpcGraphState {
readonly context: JavaScriptArtifactGraphContext;
readonly records: readonly ElectronIpcRecord[];
readonly channels: Map<string, ApplicationNode>;
readonly handlers: Map<string, ApplicationNode>;
readonly sources: Map<string, ApplicationNode>;
}
interface IpcHandlerInput {
readonly context: JavaScriptArtifactGraphContext;
readonly record: ElectronIpcRecord;
readonly source: ApplicationNode;
readonly channel: ApplicationNode;
readonly coverage: JavaScriptArtifactGraphCoverage;
}
interface ValidationObservationInput {
readonly context: JavaScriptArtifactGraphContext;
readonly file: JavaScriptArtifactFile;
readonly validation: ElectronSenderValidationFinding;
readonly target: ApplicationNode;
readonly coverage: JavaScriptArtifactGraphCoverage;
}
/** Project IPC syntax, handler locations, validations, and exact pairings. */
export const addElectronIpcBoundaries = (
context: JavaScriptArtifactGraphContext,
): void => {
const state: IpcGraphState = {
context,
records: collectElectronIpcRecords(context.analysis),
channels: new Map(),
handlers: new Map(),
sources: new Map(),
};
for (const record of state.records) addIpcRecord(state, record);
addValidationObservations(state);
addLiteralPairings(state);
};
const addIpcRecord = (
state: IpcGraphState,
record: ElectronIpcRecord,
): void => {
const { context } = state;
const coverage = coverageFor(context, record.file);
if (coverage === undefined) return;
const source = electronFindingSourceNode(
context,
record.file,
record.finding.module_key,
);
if (source === undefined) return;
state.sources.set(record.key, source);
const channel = addChannelNode(context, record, coverage);
state.channels.set(record.key, channel);
if (record.finding.mode === "listen" || record.finding.mode === "handle") {
const handler = addHandlerNode({
context,
record,
source,
channel,
coverage,
});
state.handlers.set(record.key, handler);
return;
}
addElectronInferenceEdge(context, {
source,
target: channel,
file: record.file,
range: record.finding.location,
coverage,
relation: record.finding.mode === "invoke" ? "invokes" : "sends",
operation: "map-ipc-transmission",
properties: {
side: record.finding.side,
operation: record.finding.operation,
channel: record.finding.channel,
channel_expression: record.finding.channel_expression,
resolution: record.finding.channel === null ? "dynamic" : "literal",
},
});
};
const addChannelNode = (
context: JavaScriptArtifactGraphContext,
record: ElectronIpcRecord,
coverage: JavaScriptArtifactGraphCoverage,
): ApplicationNode => {
const { finding, file } = record;
const identityKey =
finding.channel === null
? `dynamic:${record.key}`
: `literal:${finding.channel}`;
return context.accumulator.addNode({
kind: "ipc-channel",
identity: electronObservationIdentity(
context,
"electron-ipc-channel",
identityKey,
),
observations: [
{
label: finding.channel ?? finding.channel_expression,
properties: {
side: finding.side,
operation: finding.operation,
mode: finding.mode,
channel: finding.channel,
channel_expression: finding.channel_expression,
resolution: finding.channel === null ? "dynamic" : "literal",
},
evidence: astObservationEvidence({
sha256: file.sha256,
path: file.path,
range: finding.location,
operation: "observe-ipc-channel-expression",
coverage,
limitations:
finding.channel === null
? [
"The channel expression is dynamic; its runtime value and pairings remain unknown.",
]
: [],
}),
},
],
});
};
const addHandlerNode = (input: IpcHandlerInput): ApplicationNode => {
const { context, record, source, channel, coverage } = input;
const { finding, file } = record;
const handlerRange = finding.handler_location ?? finding.location;
const handler = context.accumulator.addNode({
kind: "ipc-handler",
identity: artifactLocalIdentity(
file.sha256,
"electron-ipc-handler",
`${finding.side}:${finding.operation}:${finding.channel ?? finding.channel_expression}:${electronRangeKey(handlerRange)}`,
),
observations: [
{
label: finding.channel,
properties: {
side: finding.side,
operation: finding.operation,
mode: finding.mode,
channel: finding.channel,
channel_expression: finding.channel_expression,
handler_kind: finding.handler_kind,
handler_location: finding.handler_location,
},
evidence: astObservationEvidence({
sha256: file.sha256,
path: file.path,
range: handlerRange,
operation: "observe-ipc-handler",
coverage,
}),
},
],
});
addElectronAstEdge(context, {
source,
target: handler,
file,
range: handlerRange,
coverage,
relation: "contains",
operation: "locate-ipc-handler",
properties: { side: finding.side, operation: finding.operation },
});
addElectronInferenceEdge(context, {
source: handler,
target: channel,
file,
range: finding.location,
coverage,
relation: "handles",
operation: "map-ipc-handler-registration",
properties: {
channel: finding.channel,
channel_expression: finding.channel_expression,
resolution: finding.channel === null ? "dynamic" : "literal",
},
});
return handler;
};
const addLiteralPairings = (state: IpcGraphState): void => {
for (const pairing of unambiguousElectronIpcPairings(state.records)) {
const source = state.sources.get(pairing.transmission.key);
const handler = state.handlers.get(pairing.handler.key);
const coverage = coverageFor(state.context, pairing.transmission.file);
if (source === undefined || handler === undefined || coverage === undefined)
continue;
addElectronInferenceEdge(state.context, {
source,
target: handler,
file: pairing.transmission.file,
range: pairing.transmission.finding.location,
coverage,
relation:
pairing.transmission.finding.mode === "invoke" ? "invokes" : "sends",
operation: "pair-ipc-literal-channel",
properties: {
channel: pairing.transmission.finding.channel,
pairing_basis: "unique-exact-literal-channel",
handler_path: pairing.handler.file.path,
handler_operation: pairing.handler.finding.operation,
},
limitations: [
"Literal channel equality does not prove that the handler is registered or reachable at runtime.",
],
});
}
};
const addValidationObservations = (state: IpcGraphState): void => {
for (const analyzed of state.context.analysis.files) {
const { file, javascript } = analyzed;
if (javascript === null) continue;
const coverage = javascriptAnalysisCoverage(javascript);
for (const validation of javascript.electron.sender_validations) {
const handlerRecord = containingHandler(state.records, file, validation);
const target =
handlerRecord === undefined
? electronFindingSourceNode(
state.context,
file,
validation.module_key,
)
: state.handlers.get(handlerRecord.key);
if (target !== undefined)
addValidationObservation({
context: state.context,
file,
validation,
target,
coverage,
});
}
}
};
const addValidationObservation = (input: ValidationObservationInput): void => {
const { context, file, validation, target, coverage } = input;
context.accumulator.addNode({
kind: target.kind,
identity: target.identity,
observations: [
{
label: `${validation.subject} validation candidate`,
properties: {
fact_kind: "sender-validation-candidate",
subject: validation.subject,
mechanism: validation.mechanism,
expected: validation.expected,
enforcement: validation.enforcement,
},
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.",
],
}),
},
],
});
};
const containingHandler = (
records: readonly ElectronIpcRecord[],
file: JavaScriptArtifactFile,
validation: ElectronSenderValidationFinding,
): ElectronIpcRecord | undefined =>
records
.filter(
(record) =>
record.file.path === file.path &&
record.finding.handler_location !== null &&
electronRangeContains(
record.finding.handler_location,
validation.location,
),
)
.sort(
(left, right) =>
rangeMagnitude(left.finding.handler_location) -
rangeMagnitude(right.finding.handler_location),
)[0];
const rangeMagnitude = (
range: ElectronIpcRecord["finding"]["handler_location"],
): number =>
range === null
? Number.MAX_SAFE_INTEGER
: (range.end.line - range.start.line) * 1_000_000 +
range.end.column -
range.start.column;
const coverageFor = (
context: JavaScriptArtifactGraphContext,
file: JavaScriptArtifactFile,
): JavaScriptArtifactGraphCoverage | undefined => {
const javascript = context.analysis.files.find(
({ file: candidate }) => candidate.path === file.path,
)?.javascript;
return javascript === null || javascript === undefined
? undefined
: javascriptAnalysisCoverage(javascript);
};
@@ -0,0 +1,114 @@
import type { ElectronNativeAddonBindingFinding } from "../../domain/electronStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import {
javascriptAnalysisCoverage,
resolveArtifactPath,
type JavaScriptArtifactGraphContext,
type JavaScriptArtifactGraphCoverage,
} from "./JavaScriptArtifactGraphContext.js";
import { astObservationEvidence } from "./JavaScriptArtifactGraphEvidence.js";
import {
addElectronInferenceEdge,
electronFindingSourceNode,
electronObservationIdentity,
electronRangeKey,
} from "./ElectronBoundaryGraphContext.js";
interface NativeBindingInput {
readonly context: JavaScriptArtifactGraphContext;
readonly file: JavaScriptArtifactFile;
readonly value: ElectronNativeAddonBindingFinding;
readonly coverage: JavaScriptArtifactGraphCoverage;
}
/** Project JavaScript-requested native addon binding and re-export surfaces. */
export const addElectronNativeBoundaries = (
context: JavaScriptArtifactGraphContext,
): void => {
for (const analyzed of context.analysis.files) {
const { file, javascript } = analyzed;
if (javascript === null) continue;
const coverage = javascriptAnalysisCoverage(javascript);
for (const value of javascript.electron.native_addon_bindings)
addNativeBinding({ context, file, value, coverage });
}
};
const addNativeBinding = (input: NativeBindingInput): void => {
const { context, file, value, coverage } = input;
const source = electronFindingSourceNode(context, file, value.module_key);
if (source === undefined) return;
const resolved = resolveArtifactPath(
value.specifier,
file.path,
context.filesByPath,
value.module_kind,
);
const addon = resolved === null ? undefined : context.fileNodes.get(resolved);
const binding = context.accumulator.addNode({
kind: "native-export",
identity: electronObservationIdentity(
context,
"javascript-requested-native-export",
`${file.path}:${value.specifier}:${value.binding_kind}:${value.members.join("\0")}:${electronRangeKey(value.location)}`,
),
observations: [
{
label: value.members.join(", "),
properties: {
specifier: value.specifier,
binding_kind: value.binding_kind,
requested_members: value.members,
resolved_path: resolved,
native_export_verification: "not-performed",
},
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.",
],
}),
},
],
});
addElectronInferenceEdge(context, {
source,
target: binding,
file,
range: value.location,
coverage,
relation: value.binding_kind === "re-export" ? "exposes" : "imports",
operation: "map-native-addon-binding",
properties: {
specifier: value.specifier,
binding_kind: value.binding_kind,
requested_members: value.members,
},
limitations: [
"Requested JavaScript members are not verified native exports.",
],
});
if (addon === undefined || resolved === null) return;
addElectronInferenceEdge(context, {
source: addon,
target: binding,
file,
range: value.location,
coverage,
relation: "exposes",
operation: "associate-native-addon-requested-exports",
properties: {
resolved_path: resolved,
requested_members: value.members,
verified: false,
},
confidence: "medium",
limitations: [
"The .node artifact path is resolved, but requested JavaScript members have not been verified against native symbols.",
],
});
};
@@ -0,0 +1,276 @@
import type { ApplicationNode } from "../../domain/javascriptApplicationGraph.js";
import type {
ElectronBrowserWindowFinding,
ElectronContextBridgeFinding,
ElectronUtilityProcessFinding,
} from "../../domain/electronStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import {
artifactLocalIdentity,
createElectronRoleNode,
javascriptAnalysisCoverage,
linkElectronRoleToAsset,
type JavaScriptArtifactGraphContext,
type JavaScriptArtifactGraphCoverage,
} from "./JavaScriptArtifactGraphContext.js";
import { astObservationEvidence } from "./JavaScriptArtifactGraphEvidence.js";
import {
addElectronAstEdge,
addElectronInferenceEdge,
electronFindingSourceNode,
electronRangeKey,
} from "./ElectronBoundaryGraphContext.js";
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
interface FindingInput<Value> {
readonly context: JavaScriptArtifactGraphContext;
readonly file: JavaScriptArtifactFile;
readonly source: ApplicationNode;
readonly value: Value;
readonly coverage: JavaScriptArtifactGraphCoverage;
}
/** Project BrowserWindow, contextBridge, preload, and utility-process facts. */
export const addElectronWindowBoundaries = (
context: JavaScriptArtifactGraphContext,
): void => {
for (const analyzed of context.analysis.files) {
const { file, javascript } = analyzed;
if (javascript === null) continue;
const coverage = javascriptAnalysisCoverage(javascript);
for (const value of javascript.electron.browser_windows) {
const source = electronFindingSourceNode(context, file, value.module_key);
if (source !== undefined)
addBrowserWindow({ context, file, source, value, coverage });
}
for (const value of javascript.electron.context_bridge_apis) {
const source = electronFindingSourceNode(context, file, value.module_key);
if (source !== undefined)
addContextBridge({ context, file, source, value, coverage });
}
for (const value of javascript.electron.utility_processes) {
const source = electronFindingSourceNode(context, file, value.module_key);
if (source !== undefined)
addUtilityProcess({ context, file, source, value, coverage });
}
}
};
const addBrowserWindow = (
input: FindingInput<ElectronBrowserWindowFinding>,
): void => {
const { context, file, value, coverage } = input;
const window = context.accumulator.addNode({
kind: "browser-window",
identity: artifactLocalIdentity(
file.sha256,
"electron-browser-window",
electronRangeKey(value.location),
),
observations: [
{
label: `BrowserWindow@${electronRangeKey(value.location)}`,
properties: {
source_path: file.path,
options_status: value.options_status,
web_preferences_status: value.web_preferences_status,
web_preferences: value.web_preferences,
preload_path: value.preload_path,
preload_resolution_context: value.preload_resolution_context,
absence_means_default: false,
},
evidence: astObservationEvidence({
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.",
],
}),
},
],
});
addElectronAstEdge(context, {
source: input.source,
target: window,
file,
range: value.location,
coverage,
relation: "contains",
operation: "locate-browser-window-construction",
properties: { source_path: file.path },
});
addWindowPreload(input, window);
};
const addWindowPreload = (
input: FindingInput<ElectronBrowserWindowFinding>,
window: ApplicationNode,
): void => {
if (input.value.preload_path === null) return;
const {
preload_path: declared,
preload_resolution_context: resolutionContext,
} = input.value;
const resolution = resolveArtifactPathByContext({
declaredPath: declared,
sourcePath: input.file.path,
context: resolutionContext,
files: input.context.filesByPath,
});
const role = createElectronRoleNode(input.context, {
kind: "electron-preload",
anchor: input.file,
resolution,
mechanism: "BrowserWindow:webPreferences.preload",
range: input.value.location,
coverage: input.coverage,
});
addElectronInferenceEdge(input.context, {
source: window,
target: role,
file: input.file,
range: input.value.location,
coverage: input.coverage,
relation: "loads",
operation: "associate-browser-window-preload",
properties: {
declared_path: declared,
resolution_context: resolution.resolution_context,
resolved_path: resolution.resolved_path,
resolution_status: resolution.resolution_status,
limitations: resolution.limitations,
},
});
linkElectronRoleToAsset(input.context, {
role,
anchor: input.file,
resolution,
range: input.value.location,
coverage: input.coverage,
});
};
const addContextBridge = (
input: FindingInput<ElectronContextBridgeFinding>,
): void => {
const { context, file, value, coverage } = input;
const bridge = context.accumulator.addNode({
kind: "context-bridge-api",
identity: artifactLocalIdentity(
file.sha256,
"electron-context-bridge",
`${value.world}:${value.api_key ?? value.api_key_expression ?? "[missing]"}:${electronRangeKey(value.location)}`,
),
observations: [
{
label: value.api_key,
properties: {
world: value.world,
world_id: value.world_id,
api_key: value.api_key,
api_key_expression: value.api_key_expression,
api_status: value.api_status,
members: value.members,
unknown_members: value.unknown_members,
},
evidence: astObservationEvidence({
sha256: file.sha256,
path: file.path,
range: value.location,
operation: "observe-context-bridge-api",
coverage,
}),
},
],
});
addElectronInferenceEdge(context, {
source: input.source,
target: bridge,
file,
range: value.location,
coverage,
relation: "exposes",
operation: "map-context-bridge-exposure",
properties: {
world: value.world,
api_key: value.api_key,
resolution: value.api_key === null ? "dynamic" : "literal",
},
});
};
const addUtilityProcess = (
input: FindingInput<ElectronUtilityProcessFinding>,
): void => {
const { context, file, value, coverage } = input;
const utility = context.accumulator.addNode({
kind: "electron-utility",
identity: artifactLocalIdentity(
file.sha256,
"electron-utility-process",
`${value.module_path ?? value.module_expression}:${electronRangeKey(value.location)}`,
),
observations: [
{
label: value.service_name ?? value.module_path,
properties: {
module_path: value.module_path,
module_resolution_context: value.module_resolution_context,
module_expression: value.module_expression,
service_name: value.service_name,
},
evidence: astObservationEvidence({
sha256: file.sha256,
path: file.path,
range: value.location,
operation: "observe-utility-process-fork",
coverage,
}),
},
],
});
addElectronInferenceEdge(context, {
source: input.source,
target: utility,
file,
range: value.location,
coverage,
relation: "loads",
operation: "map-utility-process-launch",
properties: {
declared_path: value.module_path,
expression: value.module_expression,
},
});
if (value.module_path === null) return;
const resolution = resolveArtifactPathByContext({
declaredPath: value.module_path,
sourcePath: file.path,
context: value.module_resolution_context,
files: context.filesByPath,
});
const resolved = resolution.resolved_path;
const target =
resolved === null
? undefined
: (context.assetNodes.get(resolved) ?? context.fileNodes.get(resolved));
if (target === undefined) return;
addElectronInferenceEdge(context, {
source: utility,
target,
file,
range: value.location,
coverage,
relation: "maps_to",
operation: "resolve-utility-process-entrypoint",
properties: {
declared_path: value.module_path,
resolution_context: resolution.resolution_context,
resolved_path: resolved,
resolution_status: resolution.resolution_status,
},
});
};
@@ -0,0 +1,62 @@
import type { ProviderIdentity } from "../AnalysisProvider.js";
import {
createEvidence,
type Evidence,
type EvidenceObservation,
} from "../../domain/evidence.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import type {
ElectronPageInspection,
ElectronTargetList,
InspectElectronPageInput,
ListElectronTargetsInput,
} from "../../domain/electronObservation.js";
type ElectronOperation = "list_electron_targets" | "inspect_electron_page";
/** Create Evidence for one Electron observation. */
export const createElectronEvidence = (
operation: ElectronOperation,
input: ListElectronTargetsInput | InspectElectronPageInput,
result: ElectronTargetList | ElectronPageInspection,
provider: ProviderIdentity,
): Evidence =>
createEvidence(undefined, provider, {
predicateType:
operation === "list_electron_targets"
? "rea.electron-target-list"
: "rea.electron-page-inspection",
operation,
parameters: parameters(input),
result: jsonValueSchema.parse(result),
confidence: "observed",
authority: "external-service",
environment:
"target" in result
? {
id: `${result.browser.product}@${result.browser.revision}`,
platform: "unknown",
architecture: "unknown",
isolation: "none",
}
: null,
limitations: [
...result.limitations,
...("target" in result
? ["Browser host platform and architecture were not observed."]
: []),
],
});
const parameters = (
input: ListElectronTargetsInput | InspectElectronPageInput,
): EvidenceObservation["parameters"] => ({
cdp_endpoint: input.cdp_endpoint,
...("target_id" in input
? {
target_id: input.target_id,
observation_ms: input.observation_ms,
include_script_sources: input.include_script_sources,
}
: {}),
});
@@ -0,0 +1,25 @@
import type {
ExecutionOptions,
ProviderIdentity,
} from "../AnalysisProvider.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type {
ElectronPageInspection,
ElectronTargetList,
InspectElectronPageInput,
ListElectronTargetsInput,
} from "../../domain/electronObservation.js";
import type { Result } from "../../domain/result.js";
/** Provider-neutral boundary for root-confined Electron file-page observation. */
export interface ElectronObservationPort {
identity(): ProviderIdentity;
listTargets(
input: ListElectronTargetsInput,
options?: ExecutionOptions,
): Promise<Result<ElectronTargetList, AnalysisError>>;
inspectPage(
input: InspectElectronPageInput,
options?: ExecutionOptions,
): Promise<Result<ElectronPageInspection, AnalysisError>>;
}
@@ -0,0 +1,67 @@
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { ElectronObservationPort } from "./ElectronObservationPort.js";
import type { Evidence } from "../../domain/evidence.js";
import type {
InspectElectronPageInput,
ListElectronTargetsInput,
} from "../../domain/electronObservation.js";
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
import { err, ok, type Result } from "../../domain/result.js";
import { createElectronEvidence } from "./ElectronEvidence.js";
/** List local file pages exposed by the explicitly selected endpoint. */
export const listElectronTargets = async (
provider: ElectronObservationPort | undefined,
input: ListElectronTargetsInput,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
const ready = requireProvider(provider, "list_electron_targets");
if (!ready.ok) return ready;
const result = await ready.value.listTargets(input, options);
return result.ok
? ok(
createElectronEvidence(
"list_electron_targets",
input,
result.value,
ready.value.identity(),
),
)
: result;
};
/** Inspect one local file page from the explicitly selected endpoint. */
export const inspectElectronPage = async (
provider: ElectronObservationPort | undefined,
input: InspectElectronPageInput,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
const ready = requireProvider(provider, "inspect_electron_page");
if (!ready.ok) return ready;
const result = await ready.value.inspectPage(input, options);
return result.ok
? ok(
createElectronEvidence(
"inspect_electron_page",
input,
result.value,
ready.value.identity(),
),
)
: result;
};
const requireProvider = (
provider: ElectronObservationPort | undefined,
operation: "list_electron_targets" | "inspect_electron_page",
): Result<ElectronObservationPort, AnalysisError> =>
provider === undefined
? err(
new AnalysisCapabilityUnavailableError(
"rea-cdp-electron",
operation,
"Electron observation provider is not configured",
),
)
: ok(provider);
@@ -0,0 +1,251 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect, it } from "vitest";
import { z } from "zod";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascriptApplicationAnalysis.js";
import { matchJavaScriptApplicationVersions } from "../../domain/javascriptApplicationVersionKeys.js";
import { findApplicationFeatureSeeds } from "../../domain/javascriptFeatureSeed.js";
import { traceApplicationFeatureInputSchema } from "../../domain/javascriptFeatureTraceSchemas.js";
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
const analyzeSource = async (
source: string,
extraFiles: Readonly<Record<string, string>> = {},
) => {
const inputPath = await createTestTempDirectory("rea-js-empty-literal-");
await writeFile(join(inputPath, "app.js"), `${source}\n`);
for (const [path, text] of Object.entries(extraFiles))
await writeFile(join(inputPath, path), text);
const result = await analyzeJavaScriptApplication({
input_path: inputPath,
format: "directory",
});
if (!result.ok)
throw new Error(`Expected analysis success: ${result.error.message}`);
return javascriptApplicationAnalysisResultSchema.parse(
result.value.normalized_result,
);
};
it.each([
"fetch('');",
"new WebSocket('');",
"navigator.serviceWorker.register('');",
"new Worker('');",
"const x = require('');",
"import y from ''; y();",
"import('');",
"localStorage.getItem('');",
"localStorage.setItem('', 1);",
"indexedDB.open('');",
"win.loadFile('');",
"win.loadURL('');",
"app.get('', handler);",
"process.on('', () => 1);",
"const { EventEmitter } = require('events'); new EventEmitter().on('', () => 1);",
"const { spawn } = require('child_process'); spawn('/bin/x').on('', () => 1);",
"const { ipcMain } = require('electron'); ipcMain.handle('', () => 1);",
"const { ipcRenderer } = require('electron'); ipcRenderer.invoke('');",
"const { contextBridge } = require('electron'); contextBridge.exposeInMainWorld('', {});",
])("analyzes the legal empty string literal in %s", async (source) => {
await expect(analyzeSource(source)).resolves.toBeDefined();
});
it("keeps exact values for empty literals without synthetic application labels", async () => {
const output = await analyzeSource(
"fetch(''); fetch('\"\"'); fetch('/api'); process.on('', () => 1);",
);
const endpoints = output.graph.nodes
.filter(({ kind }) => kind === "endpoint")
.map(({ identity, observations }) => ({
key: identity.strategy === "artifact-local-key" ? identity.key : null,
labels: observations.map(({ label }) => label),
values: observations.map(({ properties }) => properties.value),
}));
// The empty endpoint has no display label, so it cannot be confused with
// a literal two-quote endpoint; both keep exact identities.
expect(endpoints).toEqual(
expect.arrayContaining([
{ key: "", labels: [null], values: [""] },
{ key: '""', labels: ['""'], values: ['""'] },
{ key: "/api", labels: ["/api"], values: ["/api"] },
]),
);
expect(endpoints).toHaveLength(3);
expect(
output.semantic_graph?.nodes
.filter(({ kind }) => kind === "event")
.map(({ label, properties }) => ({
label,
eventName: properties.event_name,
})),
).toEqual([{ label: '""', eventName: "" }]);
});
it.each([
{ version: 3, sources: [""], names: [], mappings: "AAAA" },
{
version: 3,
sections: [
{
offset: { line: 0, column: 0 },
map: { version: 3, sources: [""], names: [], mappings: "AAAA" },
},
],
},
])("projects an empty source-map source name: %j", async (map) => {
const output = await analyzeSource("//# sourceMappingURL=app.js.map", {
"app.js.map": JSON.stringify(map),
});
const originals = output.graph.nodes
.filter(({ kind }) => kind === "source-module")
.map(({ identity, observations }) => ({
originalSource:
identity.strategy === "source-map-original"
? identity.original_source
: null,
sources: observations.map(({ properties }) => properties.source),
}));
expect(originals).toEqual([{ originalSource: "", sources: [""] }]);
});
it("keeps empty and two-quote source-map names distinct", async () => {
const output = await analyzeSource("//# sourceMappingURL=app.js.map", {
"app.js.map": JSON.stringify({
version: 3,
sources: ["", '""'],
names: [],
mappings: "AAAA,CCAA",
}),
});
expect(
output.graph.nodes
.filter(({ kind }) => kind === "source-module")
.map(({ identity }) =>
identity.strategy === "source-map-original"
? identity.original_source
: null,
)
.sort(),
).toEqual(["", '""']);
});
it("does not match an empty endpoint with a literal two-quote seed", async () => {
const output = await analyzeSource("fetch(''); fetch('\"\"');");
const endpoints = new Map(
output.graph.nodes
.filter(({ kind }) => kind === "endpoint")
.map((node) => [
node.node_id,
node.identity.strategy === "artifact-local-key"
? node.identity.key
: null,
]),
);
const matches = findApplicationFeatureSeeds(output.graph.nodes, {
kind: "string",
value: '""',
match: "exact",
case_sensitive: true,
}).filter(({ node_id: nodeId }) => endpoints.has(nodeId));
expect(matches.map(({ node_id: nodeId }) => endpoints.get(nodeId))).toEqual([
'""',
]);
});
const IPC_SOURCE =
"const { ipcMain } = require('electron'); ipcMain.handle('', () => 1); ipcMain.handle('\"\"', () => 2);";
const ipcChannels = (
nodes: Awaited<ReturnType<typeof analyzeSource>>["graph"]["nodes"],
) =>
new Map(
nodes
.filter(({ kind }) => kind === "ipc-channel")
.map((node) => [node.node_id, node.observations[0]?.properties.channel]),
);
it("selects an empty IPC channel with an exact empty seed", async () => {
const output = await analyzeSource(IPC_SOURCE);
const channels = ipcChannels(output.graph.nodes);
const matches = findApplicationFeatureSeeds(output.graph.nodes, {
kind: "channel",
value: "",
match: "exact",
case_sensitive: true,
});
expect(matches.map(({ node_id: nodeId }) => channels.get(nodeId))).toEqual([
"",
]);
});
const seedSchema = traceApplicationFeatureInputSchema.shape.seed;
const validateAdvertisedSeed = new Ajv2020({
strict: false,
validateFormats: false,
}).compile(z.toJSONSchema(seedSchema, { io: "input" }));
it.each([
[{ kind: "channel", value: "", match: "exact" }, true],
[{ kind: "string", value: "", match: "exact" }, true],
[{ kind: "channel", value: "/x" }, true],
[{ kind: "string", value: "" }, false],
[{ kind: "channel", value: "" }, false],
[{ kind: "channel", value: "", match: "contains" }, false],
[{ kind: "node-id", value: "", match: "exact" }, false],
] as const)(
"validates empty seed %j at runtime and in the advertised schema",
(seed, accepted) => {
expect(seedSchema.safeParse(seed).success).toBe(accepted);
expect(validateAdvertisedSeed(seed)).toBe(accepted);
},
);
it("pairs the same empty IPC channel across application versions", async () => {
const left = await analyzeSource(IPC_SOURCE);
const right = await analyzeSource(`${IPC_SOURCE} // v2`);
const leftChannels = ipcChannels(left.graph.nodes);
const rightChannels = ipcChannels(right.graph.nodes);
const matching = matchJavaScriptApplicationVersions(
left.graph.nodes,
right.graph.nodes,
);
expect(
matching.pairs
.filter(({ left: node }) => node.kind === "ipc-channel")
.map(({ left: leftNode, right: rightNode }) => [
leftChannels.get(leftNode.node_id),
rightChannels.get(rightNode.node_id),
]),
).toEqual(
expect.arrayContaining([
["", ""],
['""', '""'],
]),
);
expect(matching.unmatchedLeft.map(({ kind }) => kind)).not.toContain(
"ipc-channel",
);
});
it("selects an empty context-bridge API with an exact empty seed", async () => {
const output = await analyzeSource(
"const { contextBridge } = require('electron'); contextBridge.exposeInMainWorld('', {}); contextBridge.exposeInMainWorld('\"\"', {});",
);
const apis = new Map(
output.graph.nodes
.filter(({ kind }) => kind === "context-bridge-api")
.map((node) => [node.node_id, node.observations[0]?.properties.api_key]),
);
const matches = findApplicationFeatureSeeds(output.graph.nodes, {
kind: "api",
value: "",
match: "exact",
case_sensitive: true,
});
expect(matches.map(({ node_id: nodeId }) => apis.get(nodeId))).toEqual([""]);
});
@@ -0,0 +1,43 @@
import {
createEvidence,
type Evidence,
type EvidenceObservation,
} from "../../domain/evidence.js";
import type {
AnalyzeJavaScriptApplicationInput,
JavaScriptApplicationAnalysisResult,
} from "../../domain/javascriptApplicationAnalysis.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import { JAVASCRIPT_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
/** Create Evidence for one deterministic local JavaScript application graph. */
export const createJavaScriptApplicationEvidence = (
input: AnalyzeJavaScriptApplicationInput,
result: JavaScriptApplicationAnalysisResult,
): Evidence =>
createEvidence(
{
path: result.input_path,
sha256: result.root_artifact_sha256,
format: result.format,
},
JAVASCRIPT_APPLICATION_PROVIDER,
{
predicateType: "rea.javascript-application-analysis",
operation: "analyze_javascript_application",
parameters: parameters(input),
result: jsonValueSchema.parse(result),
rawResult: null,
confidence: "derived",
authority: "shipped-artifact",
environment: null,
limitations: result.limitations,
locations: [{ kind: "artifact-path", path: "artifact-root" }],
},
);
const parameters = (
input: AnalyzeJavaScriptApplicationInput,
): EvidenceObservation["parameters"] => ({
format: input.format,
});
@@ -0,0 +1,191 @@
import {
JAVASCRIPT_APPLICATION_PROVIDER,
JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER,
MANAGED_WORKFLOW_PROVIDER,
} from "../InvestigationProviders.js";
import { parseEvidence, type Evidence } from "../../domain/evidence.js";
import {
analyzeJavaScriptApplicationInputSchema,
javascriptApplicationAnalysisResultSchema,
type JavaScriptApplicationAnalysisResult,
} from "../../domain/javascriptApplicationAnalysis.js";
import type { JavaScriptApplicationGraph } from "../../domain/javascriptApplicationGraph.js";
import type { JavaScriptSemanticGraph } from "../../domain/javascriptSemanticGraph.js";
import { javascriptRuntimeReconciliationResultSchema } from "../../domain/javascriptRuntimeReconciliationSchemas.js";
import { managedApplicationGraphResultSchema } from "../../domain/managedApplicationGraph.js";
/** Supported immutable source for an application-level graph workflow. */
export interface ApplicationGraphEvidenceSource {
readonly evidence: Evidence;
readonly graph: JavaScriptApplicationGraph;
readonly kind:
| "static-application"
| "static-runtime-reconciliation"
| "managed-application";
readonly rootArtifactSha256: string;
readonly semanticGraph: JavaScriptSemanticGraph | null;
}
/** Parse and authenticate one REA-produced JavaScript Application Graph Evidence. */
export const parseApplicationGraphEvidence = (
input: unknown,
): ApplicationGraphEvidenceSource => {
const evidence = parseEvidence(input);
if (
evidence.operation === "analyze_javascript_application" &&
evidence.predicate_type === "rea.javascript-application-analysis" &&
providerMatches(evidence, JAVASCRIPT_APPLICATION_PROVIDER)
)
return staticApplicationSource(evidence);
if (
evidence.operation === "reconcile_javascript_runtime" &&
evidence.predicate_type === "rea.javascript-runtime-reconciliation" &&
providerMatches(evidence, JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER)
)
return staticRuntimeSource(evidence);
if (
evidence.operation === "project_managed_application_graph" &&
evidence.predicate_type === "rea.managed-application-graph" &&
providerMatches(evidence, MANAGED_WORKFLOW_PROVIDER)
)
return managedApplicationSource(evidence);
throw new TypeError(
"Application workflow requires authenticated analyze_javascript_application, reconcile_javascript_runtime, or project_managed_application_graph Evidence",
);
};
const staticApplicationSource = (
evidence: Evidence,
): ApplicationGraphEvidenceSource => {
if (
evidence.authority !== "shipped-artifact" ||
evidence.confidence !== "derived"
)
throw new TypeError(
"JavaScript application Evidence authority or confidence is invalid",
);
const result = javascriptApplicationAnalysisResultSchema.parse(
evidence.normalized_result,
);
if (evidence.predicate_type !== "rea.javascript-application-analysis")
throw new TypeError(
"JavaScript application Evidence predicate does not match its result shape",
);
analyzeJavaScriptApplicationInputSchema.parse({
input_path: result.input_path,
...evidence.parameters,
});
assertApplicationSubject(evidence, result);
return {
evidence,
graph: result.graph,
kind: "static-application",
rootArtifactSha256: result.root_artifact_sha256,
semanticGraph: result.semantic_graph,
};
};
const staticRuntimeSource = (
evidence: Evidence,
): ApplicationGraphEvidenceSource => {
if (
evidence.authority !== "analyst-inference" ||
evidence.confidence !== "inferred"
)
throw new TypeError(
"JavaScript runtime reconciliation Evidence authority or confidence is invalid",
);
const result = javascriptRuntimeReconciliationResultSchema.parse(
evidence.normalized_result,
);
if (
result.evidence_links.some(
(evidenceId) => !evidence.evidence_links.includes(evidenceId),
)
)
throw new TypeError(
"Runtime reconciliation result references Evidence outside its envelope",
);
const applicationLayer = result.static_layers.find(
({ role }) => role === "application",
);
if (applicationLayer === undefined)
throw new TypeError("Runtime reconciliation application layer is missing");
return {
evidence,
graph: result.graph,
kind: "static-runtime-reconciliation",
rootArtifactSha256: applicationLayer.root_artifact_sha256,
semanticGraph: null,
};
};
const managedApplicationSource = (
evidence: Evidence,
): ApplicationGraphEvidenceSource => {
if (
evidence.authority !== "analyst-inference" ||
evidence.confidence !== "inferred"
)
throw new TypeError(
"Managed application graph Evidence authority or confidence is invalid",
);
const result = managedApplicationGraphResultSchema.parse(
evidence.normalized_result,
);
if (
result.evidence_links.some(
(evidenceId) => !evidence.evidence_links.includes(evidenceId),
)
)
throw new TypeError(
"Managed application graph result references Evidence outside its envelope",
);
return {
evidence,
graph: result.graph,
kind: "managed-application",
rootArtifactSha256: result.root_artifact_sha256,
semanticGraph: null,
};
};
const assertApplicationSubject = (
evidence: Evidence,
result: JavaScriptApplicationAnalysisResult,
): void => {
if (
evidence.subject === null ||
evidence.subject.digest.sha256 !== result.root_artifact_sha256 ||
evidence.subject.format !== result.format ||
evidence.subject.local_path !== result.input_path
)
throw new TypeError(
"JavaScript application Evidence subject does not match its result",
);
};
const providerMatches = (
evidence: Evidence,
provider: {
readonly id: string;
readonly name: string;
readonly version: string;
},
): boolean =>
evidence.provider.id === provider.id &&
evidence.provider.name === provider.name &&
evidence.provider.version === provider.version;
/** Parse unique, artifact-bound Evidence that may extend a native handoff. */
export const parseNativeApplicationEvidence = (
inputs: readonly unknown[],
): Evidence[] => {
const parsed = inputs.map((input) => parseEvidence(input));
if (parsed.some(({ subject }) => subject === null))
throw new TypeError("Native handoff Evidence requires an artifact subject");
const ids = parsed.map(({ evidence_id: id }) => id);
if (new Set(ids).size !== ids.length)
throw new TypeError("Native handoff Evidence must be unique");
return parsed;
};
@@ -0,0 +1,122 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
describe("JavaScript application failure diagnostics", () => {
it("identifies the rejected result field in caller-visible diagnostics", async () => {
const inputPath = await createTestTempDirectory("rea-js-schema-failure-");
await writeFile(join(inputPath, "main.js"), "export const value = 1;\n");
const cause = new z.ZodError([
{
code: "custom",
path: ["semantic_graph", "nodes", 42, "application_node_ids", 0],
message: "Semantic node references an absent application node",
},
]);
const result = await analyzeJavaScriptApplication(
{ input_path: inputPath, format: "directory" },
{
progress: {
report: async (event) => {
if (event.terminal) throw cause;
},
},
},
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected analysis failure");
expect(result.error.cause).toBe(cause);
expect(projectAnalysisError(result.error)).toMatchObject({
code: "unreadable_output",
details: {
operation: "analyze_javascript_application",
reason:
"Result schema rejected 1 issue at /semantic_graph/nodes/42/application_node_ids/0 (custom)",
},
});
});
it("retains unexpected failures and their input identity in caller-visible diagnostics", async () => {
const inputPath = await createTestTempDirectory("rea-js-failure-");
await writeFile(join(inputPath, "main.js"), "export const value = 1;\n");
const cause = new RangeError("Invalid string length");
const result = await analyzeJavaScriptApplication(
{ input_path: inputPath, format: "directory" },
{
progress: {
report: async (event) => {
if (event.terminal) throw cause;
},
},
},
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected analysis failure");
expect(result.error.cause).toBe(cause);
expect(projectAnalysisError(result.error)).toMatchObject({
code: "execution_failure",
details: {
provider_id: "rea-javascript-application",
operation: "analyze_javascript_application",
diagnostics: {
input_path: inputPath,
error_name: "RangeError",
error_message: "Invalid string length",
},
},
});
});
it("keeps filesystem failures distinct from engine failures", async () => {
const root = await createTestTempDirectory("rea-js-missing-");
const result = await analyzeJavaScriptApplication({
input_path: join(root, "missing"),
format: "directory",
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected missing artifact failure");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "artifact_operation_failed",
details: { operation: "analyze_javascript_application", reason: "io" },
});
});
it.each(["reported failure", null])(
"retains non-Error failures (%s)",
async (cause) => {
const inputPath = await createTestTempDirectory("rea-js-non-error-");
await writeFile(join(inputPath, "main.js"), "export const value = 1;\n");
const result = await analyzeJavaScriptApplication(
{ input_path: inputPath, format: "directory" },
{
progress: {
report: async (event) => {
if (event.terminal) throw cause;
},
},
},
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected analysis failure");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "execution_failure",
details: {
diagnostics: {
error_name: "UnknownError",
error_message:
typeof cause === "string"
? cause
: "JavaScript analysis failed with a non-Error value",
},
},
});
},
);
});
@@ -0,0 +1,145 @@
import { z } from "zod";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import {
analyzeJavaScriptApplicationInputSchema,
javascriptApplicationAnalysisResultSchema,
} from "../../domain/javascriptApplicationAnalysis.js";
import {
AnalysisInputError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
import type { Evidence } from "../../domain/evidence.js";
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
import { err, ok, type Result } from "../../domain/result.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import { createJavaScriptApplicationEvidence } from "./JavaScriptApplicationEvidence.js";
import { reconstructJavaScriptArtifact } from "./JavaScriptArtifactReconstruction.js";
import { JAVASCRIPT_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
const OPERATION = "analyze_javascript_application" as const;
/** Statically analyze one local JavaScript/Electron application. */
export const analyzeJavaScriptApplication = async (
rawInput: unknown,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
const parsed = analyzeJavaScriptApplicationInputSchema.safeParse(rawInput);
if (!parsed.success)
return err(
new AnalysisInputError(
OPERATION,
undefined,
projectInputIssues(parsed.error.issues, rawInput),
),
);
return analyzeJavaScriptApplicationValidated(parsed.data, options);
};
/** Analyze input already parsed by a trusted adapter boundary. */
export const analyzeJavaScriptApplicationValidated = async (
input: z.output<typeof analyzeJavaScriptApplicationInputSchema>,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
await options.progress?.report({
phase: "analyze_javascript_application",
completed: 0,
total: 1,
message: "Inventorying and parsing application artifacts",
});
try {
const reconstructed = await reconstructJavaScriptArtifact(
{
input_path: input.input_path,
format: input.format,
},
options.signal,
);
const { electron_summary: summary, ...application } = reconstructed;
const result = javascriptApplicationAnalysisResultSchema.parse({
...application,
summary,
limitations: reconstructed.graph.limitations,
});
await options.progress?.report({
phase: "analyze_javascript_application",
completed: 1,
total: 1,
message: "Application graph and Electron boundaries reconstructed",
terminal: true,
});
return ok(createJavaScriptApplicationEvidence(input, result));
} catch (cause: unknown) {
if (cause instanceof ArtifactReaderFailure)
return err(
new ArtifactOperationError(
OPERATION,
cause.reason,
cause.details,
cause.message,
),
);
if (cause instanceof z.ZodError)
return err(
new AnalysisOutputError(OPERATION, describeResultSchemaFailure(cause), {
cause,
}),
);
if (
cause instanceof Error &&
"code" in cause &&
typeof cause.code === "string" &&
FILESYSTEM_ERROR_CODES.has(cause.code)
)
return err(new ArtifactOperationError(OPERATION, "io"));
return err(
new ProviderAdapterError(JAVASCRIPT_APPLICATION_PROVIDER.id, OPERATION, {
cause,
diagnostics: {
input_path: input.input_path,
error_name: cause instanceof Error ? cause.name : "UnknownError",
error_message:
cause instanceof Error
? cause.message
: typeof cause === "string"
? cause
: "JavaScript analysis failed with a non-Error value",
},
}),
);
}
};
const describeResultSchemaFailure = (cause: z.ZodError): string => {
const issue = cause.issues[0];
if (issue === undefined) return "Result schema rejected an unknown issue";
const path = issue.path.flatMap((part) =>
typeof part === "string" || typeof part === "number" ? [part] : [],
);
const pointer =
path.length === 0
? "/"
: `/${path.map((part) => escapePointerPart(String(part))).join("/")}`;
return `Result schema rejected ${String(cause.issues.length)} ${cause.issues.length === 1 ? "issue" : "issues"} at ${pointer} (${issue.code})`;
};
const escapePointerPart = (part: string): string =>
part.replaceAll("~", "~0").replaceAll("/", "~1");
const FILESYSTEM_ERROR_CODES = new Set([
"ENOENT",
"EACCES",
"EPERM",
"ENOTDIR",
"EISDIR",
"EIO",
"ELOOP",
"ENAMETOOLONG",
"EMFILE",
"ENFILE",
"ENOSPC",
"EROFS",
]);
@@ -0,0 +1,69 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascriptApplicationAnalysis.js";
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
const graphIdentities = async (source: string) => {
const inputPath = await createTestTempDirectory("rea-js-template-literal-");
await writeFile(join(inputPath, "app.js"), `${source}\n`);
await writeFile(join(inputPath, "dep.js"), "module.exports = 1;\n");
await writeFile(join(inputPath, "worker.js"), "self.onmessage = null;\n");
const result = await analyzeJavaScriptApplication({
input_path: inputPath,
format: "directory",
});
if (!result.ok)
throw new Error(`Expected analysis success: ${result.error.message}`);
const { graph } = javascriptApplicationAnalysisResultSchema.parse(
result.value.normalized_result,
);
return (
graph.nodes
.filter(({ kind }) => kind !== "javascript-asset" && kind !== "artifact")
// Artifact digests differ between spellings; keep the derived keys.
.map(({ kind, identity }) => ({
kind,
strategy: identity.strategy,
...(identity.strategy === "artifact-local-key"
? {
namespace: identity.namespace,
key: identity.key.replace(/^[0-9a-f]{64}:/u, ""),
}
: {}),
}))
.sort((left, right) =>
JSON.stringify(left).localeCompare(JSON.stringify(right)),
)
);
};
// Each quoted literal has the same length as its template spelling, so
// source locations and every derived identity stay comparable.
it.each([
"fetch('https://api.example.com/v1/users');",
"new WebSocket('wss://ws.example.com/socket');",
"const xhr = new XMLHttpRequest(); xhr.open('GET', 'https://xhr.example.com/data');",
"localStorage.setItem('token', 1);",
"sessionStorage.getItem('session-id');",
"indexedDB.open('records');",
"new Worker('./worker.js');",
"const dep = require('./dep.js'); dep();",
"import('./dep.js');",
"navigator.serviceWorker.register('./worker.js');",
])(
"reads a template without substitutions as its exact string: %s",
async (quoted) => {
const template = quoted.replaceAll("'", "`");
const expected = await graphIdentities(quoted);
expect(
expected.some(({ kind }) =>
["endpoint", "storage", "worker", "javascript-module"].includes(kind),
),
).toBe(true);
await expect(graphIdentities(template)).resolves.toEqual(expected);
},
);
@@ -0,0 +1,102 @@
import {
createEvidence,
type Evidence,
type EvidenceObservation,
} from "../../domain/evidence.js";
import type { ApplicationVersionComparisonResult } from "../../domain/javascriptApplicationVersionComparisonSchemas.js";
import type { JavaScriptExportShapeComparisonResult } from "../../domain/javascriptExportShapeComparisonSchemas.js";
import type { ApplicationFeatureTraceResult } from "../../domain/javascriptFeatureTraceSchemas.js";
import type { JavaScriptSemanticTraceResult } from "../../domain/javascriptSemanticTraceSchemas.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import type { SourceToBundleComparisonResult } from "../../domain/sourceToBundleComparisonSchemas.js";
import { JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER } from "../InvestigationProviders.js";
/** Create derived Evidence for one bounded application-graph feature trace. */
export const createApplicationFeatureTraceEvidence = (
parameters: EvidenceObservation["parameters"],
result: ApplicationFeatureTraceResult,
): Evidence =>
createEvidence(undefined, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, {
predicateType: "rea.application-feature-trace",
operation: "trace_application_feature",
parameters,
result: jsonValueSchema.parse(result),
rawResult: null,
confidence: "inferred",
authority: "analyst-inference",
environment: null,
limitations: result.limitations,
evidenceLinks: result.evidence_links,
});
/** Create derived Evidence for one bounded JavaScript semantic trace. */
export const createJavaScriptSemanticTraceEvidence = (
parameters: EvidenceObservation["parameters"],
result: JavaScriptSemanticTraceResult,
): Evidence =>
createEvidence(undefined, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, {
predicateType: "rea.javascript-semantic-trace",
operation: "trace_javascript_semantics",
parameters,
result: jsonValueSchema.parse(result),
rawResult: null,
confidence: "inferred",
authority: "analyst-inference",
environment: null,
limitations: result.limitations,
evidenceLinks: result.evidence_links,
});
/** Create derived Evidence for one tiered application version comparison. */
export const createApplicationVersionComparisonEvidence = (
parameters: EvidenceObservation["parameters"],
result: ApplicationVersionComparisonResult,
): Evidence =>
createEvidence(undefined, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, {
predicateType: "rea.application-version-comparison",
operation: "compare_application_versions",
parameters,
result: jsonValueSchema.parse(result),
rawResult: null,
confidence: "inferred",
authority: "analyst-inference",
environment: null,
limitations: result.limitations,
evidenceLinks: result.evidence_links,
});
/** Create derived Evidence for one historical-source to bundle comparison. */
export const createSourceToBundleComparisonEvidence = (
parameters: EvidenceObservation["parameters"],
result: SourceToBundleComparisonResult,
): Evidence =>
createEvidence(undefined, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, {
predicateType: "rea.source-to-bundle-comparison",
operation: "compare_source_to_bundle",
parameters,
result: jsonValueSchema.parse(result),
rawResult: null,
confidence: "inferred",
authority: "analyst-inference",
environment: null,
limitations: result.limitations,
evidenceLinks: result.evidence_links,
});
/** Create derived Evidence for one bounded static export-shape comparison. */
export const createJavaScriptExportShapeComparisonEvidence = (
parameters: EvidenceObservation["parameters"],
result: JavaScriptExportShapeComparisonResult,
): Evidence =>
createEvidence(undefined, JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER, {
predicateType: "rea.javascript-export-shape-comparison",
operation: "compare_javascript_export_shapes",
parameters,
result: jsonValueSchema.parse(result),
rawResult: null,
confidence: "inferred",
authority: "analyst-inference",
environment: null,
limitations: result.limitations,
evidenceLinks: result.evidence_links,
});
@@ -0,0 +1,280 @@
import { z } from "zod";
import {
AnalysisInputError,
AnalysisProtocolError,
} from "../../domain/analysisErrorCore.js";
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
import type { Evidence } from "../../domain/evidence.js";
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
import { compareJavaScriptApplicationVersions } from "../../domain/javascriptApplicationVersionComparison.js";
import { compareApplicationVersionsInputSchema } from "../../domain/javascriptApplicationVersionComparisonSchemas.js";
import { compareJavaScriptExportShapes } from "../../domain/javascriptExportShapeComparison.js";
import { compareJavaScriptExportShapesInputSchema } from "../../domain/javascriptExportShapeComparisonSchemas.js";
import { traceApplicationFeature } from "../../domain/javascriptFeatureTrace.js";
import { traceApplicationFeatureInputSchema } from "../../domain/javascriptFeatureTraceSchemas.js";
import { err, ok, type Result } from "../../domain/result.js";
import { compareSourceToBundle } from "../../domain/sourceToBundleComparison.js";
import { compareSourceToBundleInputSchema } from "../../domain/sourceToBundleComparisonSchemas.js";
import {
parseApplicationGraphEvidence,
parseNativeApplicationEvidence,
} from "./JavaScriptApplicationEvidenceGraph.js";
import {
createApplicationFeatureTraceEvidence,
createApplicationVersionComparisonEvidence,
createJavaScriptExportShapeComparisonEvidence,
createSourceToBundleComparisonEvidence,
} from "./JavaScriptApplicationWorkflowEvidence.js";
/** Authenticate Evidence and derive one bounded cross-layer feature trace. */
export const traceApplicationFeatureEvidence = (
rawInput: unknown,
): Result<Evidence, AnalysisError> => {
const operation = "trace_application_feature";
const parsed = traceApplicationFeatureInputSchema.safeParse(rawInput);
if (!parsed.success)
return err(
new AnalysisInputError(
operation,
undefined,
projectInputIssues(parsed.error.issues, rawInput),
),
);
return traceApplicationFeatureEvidenceValidated(parsed.data);
};
/** Derive one feature trace from input parsed by a trusted adapter. */
export const traceApplicationFeatureEvidenceValidated = (
input: z.output<typeof traceApplicationFeatureInputSchema>,
): Result<Evidence, AnalysisError> => {
const operation = "trace_application_feature";
try {
const source = parseApplicationGraphEvidence(input.application);
const nativeEvidence = parseNativeApplicationEvidence(
input.native_observations,
);
const result = traceApplicationFeature({
sourceEvidenceId: source.evidence.evidence_id,
graph: source.graph,
nativeEvidence,
seed: input.seed,
direction: input.direction,
});
return ok(
createApplicationFeatureTraceEvidence(
{
application_evidence_id: source.evidence.evidence_id,
native_evidence_ids: nativeEvidence.map(({ evidence_id: id }) => id),
seed: input.seed,
direction: input.direction,
},
result,
),
);
} catch (cause: unknown) {
return workflowFailure(operation, cause, input);
}
};
/** Authenticate both graphs and derive a tiered cross-version change graph. */
export const compareApplicationVersionsEvidence = (
rawInput: unknown,
): Result<Evidence, AnalysisError> => {
const operation = "compare_application_versions";
const parsed = compareApplicationVersionsInputSchema.safeParse(rawInput);
if (!parsed.success)
return err(
new AnalysisInputError(
operation,
undefined,
projectInputIssues(parsed.error.issues, rawInput),
),
);
return compareApplicationVersionsEvidenceValidated(parsed.data);
};
/** Compare versions from input parsed by a trusted adapter. */
export const compareApplicationVersionsEvidenceValidated = (
input: z.output<typeof compareApplicationVersionsInputSchema>,
): Result<Evidence, AnalysisError> => {
const operation = "compare_application_versions";
try {
const left = parseApplicationGraphEvidence(input.left);
const right = parseApplicationGraphEvidence(input.right);
const leftNative = parseNativeApplicationEvidence(
input.left_native_observations,
);
const rightNative = parseNativeApplicationEvidence(
input.right_native_observations,
);
const result = compareJavaScriptApplicationVersions({
left: {
evidenceId: left.evidence.evidence_id,
rootArtifactSha256: left.rootArtifactSha256,
graph: left.graph,
},
right: {
evidenceId: right.evidence.evidence_id,
rootArtifactSha256: right.rootArtifactSha256,
graph: right.graph,
},
leftNativeEvidence: leftNative,
rightNativeEvidence: rightNative,
});
return ok(
createApplicationVersionComparisonEvidence(
{
left_evidence_id: left.evidence.evidence_id,
right_evidence_id: right.evidence.evidence_id,
left_native_evidence_ids: leftNative.map(({ evidence_id: id }) => id),
right_native_evidence_ids: rightNative.map(
({ evidence_id: id }) => id,
),
},
result,
),
);
} catch (cause: unknown) {
return workflowFailure(operation, cause, input);
}
};
/** Compare source and bundle inputs already parsed by a trusted adapter. */
export const compareSourceToBundleEvidenceValidated = (
input: z.output<typeof compareSourceToBundleInputSchema>,
): Result<Evidence, AnalysisError> => {
const operation = "compare_source_to_bundle";
try {
const application = parseApplicationGraphEvidence(input.application);
const result = compareSourceToBundle({
reference: input.reference,
application: {
evidenceId: application.evidence.evidence_id,
rootArtifactSha256: application.rootArtifactSha256,
graph: application.graph,
},
});
return ok(
createSourceToBundleComparisonEvidence(
{
reference_root_sha256: input.reference.root_sha256,
application_evidence_id: application.evidence.evidence_id,
},
result,
),
);
} catch (cause: unknown) {
return workflowFailure(operation, cause, input);
}
};
/**
* Authenticate both graphs and compare one exact export return shape.
* @public
*/
export const compareJavaScriptExportShapesEvidence = (
rawInput: unknown,
): Result<Evidence, AnalysisError> => {
const operation = "compare_javascript_export_shapes";
const parsed = compareJavaScriptExportShapesInputSchema.safeParse(rawInput);
if (!parsed.success)
return err(
new AnalysisInputError(
operation,
undefined,
projectInputIssues(parsed.error.issues, rawInput),
),
);
return compareJavaScriptExportShapesEvidenceValidated(parsed.data);
};
/** Compare exact export shapes from input parsed by a trusted adapter. */
export const compareJavaScriptExportShapesEvidenceValidated = (
input: z.output<typeof compareJavaScriptExportShapesInputSchema>,
): Result<Evidence, AnalysisError> => {
const operation = "compare_javascript_export_shapes";
try {
const left = parseApplicationGraphEvidence(input.left);
const right = parseApplicationGraphEvidence(input.right);
const result = compareJavaScriptExportShapes({
left: {
evidenceId: left.evidence.evidence_id,
graph: left.graph,
modulePath: input.left_module_path,
exportName: input.left_export_name,
},
right: {
evidenceId: right.evidence.evidence_id,
graph: right.graph,
modulePath: input.right_module_path,
exportName: input.right_export_name,
},
});
return ok(
createJavaScriptExportShapeComparisonEvidence(
{
left_evidence_id: left.evidence.evidence_id,
right_evidence_id: right.evidence.evidence_id,
left_module_path: input.left_module_path,
left_export_name: input.left_export_name,
right_module_path: input.right_module_path,
right_export_name: input.right_export_name,
},
result,
),
);
} catch (cause: unknown) {
return workflowFailure(operation, cause, input);
}
};
const workflowFailure = (
operation: string,
cause: unknown,
input: unknown,
): Result<never, AnalysisError> =>
cause instanceof z.ZodError
? err(
new AnalysisInputError(
operation,
undefined,
projectInputIssues(cause.issues, input),
),
)
: cause instanceof TypeError &&
SAFE_APPLICATION_INPUT_CONSTRAINTS.has(cause.message)
? err(
new AnalysisInputError(operation, undefined, [
{ path: [], reason: "invalid_value", message: cause.message },
]),
)
: err(
new AnalysisProtocolError(
"JavaScript application workflow produced an invalid result",
{ cause },
),
);
const SAFE_APPLICATION_INPUT_CONSTRAINTS = new Set([
"Evidence semantic identifier does not match its record",
"Application workflow requires authenticated analyze_javascript_application, reconcile_javascript_runtime, or project_managed_application_graph Evidence",
"JavaScript application Evidence authority or confidence is invalid",
"JavaScript application Evidence predicate does not match its result shape",
"JavaScript application Evidence subject does not match its result",
"Runtime reconciliation Evidence authority or confidence is invalid",
"Runtime reconciliation result references Evidence outside its envelope",
"Runtime reconciliation application layer is missing",
"Managed application graph Evidence authority or confidence is invalid",
"Managed application graph result references Evidence outside its envelope",
"Native handoff Evidence requires an artifact subject",
"Native handoff Evidence must be unique",
...[
"analyze_javascript_application",
"reconcile_javascript_runtime",
"project_managed_application_graph",
].map(
(operation) =>
`Evidence does not match the supported ${operation} contract`,
),
]);
@@ -0,0 +1,360 @@
import { createHash } from "node:crypto";
import { analyzeParsedJavaScriptStaticSource } from "../../domain/javascriptStaticAnalysis.js";
import { analyzeParsedJavaScriptSemantics } from "../../domain/javascriptSemanticAnalysis.js";
import { parseJavaScriptSource } from "../../domain/javascriptSourceParser.js";
import { hasValidSourceMapContents } from "../../domain/sourceMapContents.js";
import { flattenSourceMapLeaves } from "../../domain/sourceMapEnvelope.js";
import type {
JavaScriptSourceRange,
JavaScriptSourcePoint,
JavaScriptStaticAnalysis,
} from "../../domain/javascriptStaticAnalysisTypes.js";
import { failedJavaScriptStaticAnalysis } from "../../domain/javascriptStaticAnalysisHelpers.js";
import type {
JavaScriptArtifactFile,
JavaScriptArtifactFileSet,
} from "./JavaScriptArtifactFiles.js";
import type {
AnalyzedJavaScriptArtifactFile,
JavaScriptArtifactAnalysis,
JavaScriptHtmlScriptObservation,
JavaScriptJsonModuleObservation,
JavaScriptPackageObservation,
JavaScriptSourceMapObservation,
JavaScriptSourceMapOriginal,
} from "./JavaScriptArtifactAnalysisTypes.js";
import { analyzeJavaScriptJsonModule } from "./JavaScriptJsonModules.js";
interface MutableArtifactAnalysis {
readonly files: AnalyzedJavaScriptArtifactFile[];
readonly packages: JavaScriptPackageObservation[];
readonly jsonModules: JavaScriptJsonModuleObservation[];
readonly htmlScripts: JavaScriptHtmlScriptObservation[];
readonly sourceMaps: JavaScriptSourceMapObservation[];
visitedNodes: number;
findings: number;
modules: number;
parseFailures: number;
truncatedScopes: number;
}
interface ArtifactAnalysisContext {
readonly state: MutableArtifactAnalysis;
}
/** Analyze every text source from the artifact without a result quota. */
export const analyzeJavaScriptArtifactFiles = (
fileSet: JavaScriptArtifactFileSet,
): JavaScriptArtifactAnalysis => {
const state = emptyArtifactAnalysis();
const context = { state };
for (const file of fileSet.files) analyzeArtifactFile(file, context);
return finalizeArtifactAnalysis(state);
};
const finalizeArtifactAnalysis = (
state: MutableArtifactAnalysis,
): JavaScriptArtifactAnalysis => {
const truncatedScopes = state.truncatedScopes;
return {
files: state.files,
packages: state.packages,
json_modules: state.jsonModules,
html_scripts: state.htmlScripts,
source_maps: state.sourceMaps,
visited_ast_nodes: state.visitedNodes,
findings: state.findings + state.htmlScripts.length,
modules: state.modules,
parse_failures: state.parseFailures,
truncated_scopes: truncatedScopes,
limitations: [
"JavaScript and HTML were parsed as inert text; bundle bootstrap code was never executed.",
"Static paths and relationships may remain unresolved when expressions are dynamic or obfuscated.",
...(truncatedScopes === 0
? []
: ["Some static-analysis scopes were truncated."]),
],
};
};
const analyzeArtifactFile = (
file: JavaScriptArtifactFile,
context: ArtifactAnalysisContext,
): void => {
const { state } = context;
addStructuredObservations(file, state);
if (file.kind === "html" && file.text.included)
state.htmlScripts.push(...parseHtmlScripts(file.path, file.text.value));
if (file.kind === "source-map") addSourceMap(file, context);
if (file.kind !== "javascript" || !file.text.included) {
state.files.push({ file, javascript: null, semantic: null });
return;
}
const parsed = parseJavaScriptSource(file.text.value);
if (parsed === null) {
const analysis = failedJavaScriptStaticAnalysis();
state.files.push({ file, javascript: analysis, semantic: null });
state.parseFailures += 1;
return;
}
const analysis = analyzeParsedJavaScriptStaticSource(file.text.value, parsed);
const staticFindings = findingCount(analysis);
const semantics =
analysis.parse_status === "complete" || analysis.parse_status === "partial"
? analyzeParsedJavaScriptSemantics(parsed)
: null;
state.files.push({
file,
javascript: analysis,
semantic: semantics === null ? null : { ir: semantics },
});
state.visitedNodes += analysis.visited_ast_nodes;
state.findings += staticFindings + (semantics?.moduleLinks.length ?? 0);
state.modules += analysis.bundler_registrations.reduce(
(count, registration) => count + registration.modules.length,
0,
);
if (analysis.parse_status === "failed") state.parseFailures += 1;
};
const addSourceMap = (
file: JavaScriptArtifactFile,
context: ArtifactAnalysisContext,
): void => {
const sourceMap = parseSourceMap(file);
context.state.sourceMaps.push(sourceMap);
};
const emptyArtifactAnalysis = (): MutableArtifactAnalysis => ({
files: [],
packages: [],
jsonModules: [],
htmlScripts: [],
sourceMaps: [],
visitedNodes: 0,
findings: 0,
modules: 0,
parseFailures: 0,
truncatedScopes: 0,
});
const addStructuredObservations = (
file: JavaScriptArtifactFile,
state: MutableArtifactAnalysis,
): void => {
if (file.kind === "package-json") state.packages.push(parsePackage(file));
if (file.kind !== "json") return;
const json = analyzeJavaScriptJsonModule(file);
state.jsonModules.push(json);
if (json.status === "invalid") state.parseFailures += 1;
};
const parsePackage = (
file: JavaScriptArtifactFile,
): JavaScriptPackageObservation => {
if (!file.text.included)
return unavailablePackage(file, "Package metadata text was unavailable.");
let value: unknown;
try {
value = JSON.parse(file.text.value);
} catch (cause: unknown) {
void cause;
return invalidPackage(file, "package.json is not valid JSON.");
}
if (!isRecord(value))
return invalidPackage(file, "package.json root is not an object.");
return {
path: file.path,
sha256: file.sha256,
status: "included",
name: optionalString(value.name),
version: optionalString(value.version),
main: optionalString(value.main),
renderer:
optionalString(value.renderer) ??
optionalString(value.browser) ??
optionalString(value.module),
limitation: null,
};
};
const invalidPackage = (
file: JavaScriptArtifactFile,
limitation: string,
): JavaScriptPackageObservation => ({
...unavailablePackage(file, limitation),
status: "invalid",
});
const unavailablePackage = (
file: JavaScriptArtifactFile,
limitation: string,
): JavaScriptPackageObservation & { readonly status: "unavailable" } => ({
path: file.path,
sha256: file.sha256,
status: "unavailable",
name: null,
version: null,
main: null,
renderer: null,
limitation,
});
const parseHtmlScripts = (
path: string,
text: string,
): JavaScriptHtmlScriptObservation[] => {
const scripts: JavaScriptHtmlScriptObservation[] = [];
const baseHref = htmlBaseHref(text);
const pattern = /<script\b[^>]*\bsrc\s*=\s*(["'])([^"']+)\1[^>]*>/giu;
for (const match of text.matchAll(pattern)) {
const script = match[2];
if (script === undefined) continue;
const start = match.index;
scripts.push({
html_path: path,
script_path: script,
base_href: baseHref,
location: rangeForOffsets(text, start, start + match[0].length),
});
}
return scripts;
};
const htmlBaseHref = (text: string): string | null => {
const match = /<base\b[^>]*\bhref\s*=\s*(["'])([^"']+)\1[^>]*>/iu.exec(text);
return match?.[2] ?? null;
};
const parseSourceMap = (
file: JavaScriptArtifactFile,
): JavaScriptSourceMapObservation => {
if (!file.text.included) return unavailableSourceMap(file);
let value: unknown;
try {
value = JSON.parse(file.text.value);
} catch (cause: unknown) {
void cause;
return invalidSourceMap(file, "Source map is not valid JSON.");
}
const maps = flattenSourceMaps(value);
if (maps === undefined)
return invalidSourceMap(file, "Source map is not a version 3 map.");
return collectSourceMapOriginals(file, maps);
};
const collectSourceMapOriginals = (
file: JavaScriptArtifactFile,
maps: readonly Readonly<Record<string, unknown>>[],
): JavaScriptSourceMapObservation => {
const sources: JavaScriptSourceMapOriginal[] = [];
for (const map of maps) {
const names = map.sources;
if (!Array.isArray(names))
return invalidSourceMap(file, "Source map has no sources array.");
const contents = map.sourcesContent;
if (!hasValidSourceMapContents(names.length, contents))
return invalidSourceMap(
file,
"Source map sourcesContent must contain one string or null entry per source.",
);
const root = typeof map.sourceRoot === "string" ? map.sourceRoot : "";
for (const [index, raw] of names.entries()) {
if (typeof raw !== "string")
return invalidSourceMap(
file,
"Source map contains a non-string source name.",
);
const rawContent = Array.isArray(contents) ? contents[index] : undefined;
const content = typeof rawContent === "string" ? rawContent : null;
sources.push({
source: resolveSourceName(root, raw),
content,
content_sha256: content === null ? null : sha256(content),
});
}
}
return {
path: file.path,
sha256: file.sha256,
status: "included",
sources,
limitation: null,
};
};
const unavailableSourceMap = (
file: JavaScriptArtifactFile,
): JavaScriptSourceMapObservation => {
if (file.text.included)
throw new TypeError("Expected unavailable source-map text");
return {
path: file.path,
sha256: file.sha256,
status: "invalid",
sources: [],
limitation: "Source-map text could not be decoded as UTF-8.",
};
};
const flattenSourceMaps = (
root: unknown,
): Readonly<Record<string, unknown>>[] | undefined => {
const leaves = flattenSourceMapLeaves(root);
return leaves === undefined ? undefined : [...leaves];
};
const invalidSourceMap = (
file: JavaScriptArtifactFile,
limitation: string,
): JavaScriptSourceMapObservation => ({
path: file.path,
sha256: file.sha256,
status: "invalid",
sources: [],
limitation,
});
const findingCount = (analysis: JavaScriptStaticAnalysis): number =>
analysis.references.length +
analysis.endpoints.length +
analysis.storage.length +
analysis.role_paths.length +
analysis.source_map_urls.length +
analysis.bundler_registrations.length +
analysis.electron.browser_windows.length +
analysis.electron.context_bridge_apis.length +
analysis.electron.ipc.length +
analysis.electron.sender_validations.length +
analysis.electron.utility_processes.length +
analysis.electron.native_addon_bindings.length;
const rangeForOffsets = (
text: string,
start: number,
end: number,
): JavaScriptSourceRange => ({
start: pointForOffset(text, start),
end: pointForOffset(text, end),
});
const pointForOffset = (
text: string,
offset: number,
): JavaScriptSourcePoint => {
const lines = text.slice(0, offset).split("\n");
return { line: lines.length, column: lines.at(-1)?.length ?? 0 };
};
const resolveSourceName = (root: string, source: string): string =>
`${root}${root !== "" && !root.endsWith("/") ? "/" : ""}${source}`;
const optionalString = (value: unknown): string | null =>
typeof value === "string" && value.length > 0 ? value : null;
const sha256 = (value: string): string =>
createHash("sha256").update(value).digest("hex");
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
typeof value === "object" && value !== null && !Array.isArray(value);
@@ -0,0 +1,112 @@
import type {
JavaScriptSourceRange,
JavaScriptStaticAnalysis,
} from "../../domain/javascriptStaticAnalysisTypes.js";
import type { JavaScriptSemanticIr } from "../../domain/javascriptSemanticIr.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
interface JavaScriptStructuredObservation {
readonly path: string;
readonly sha256: string;
}
interface JavaScriptPackageValues {
readonly name: string | null;
readonly version: string | null;
readonly main: string | null;
readonly renderer: string | null;
}
/** Parsed package metadata used only for static entrypoint discovery. */
export type JavaScriptPackageObservation = JavaScriptStructuredObservation &
(
| (JavaScriptPackageValues & {
readonly status: "included";
readonly limitation: null;
})
| {
readonly status: "invalid" | "unavailable";
readonly name: null;
readonly version: null;
readonly main: null;
readonly renderer: null;
readonly limitation: string;
}
);
/** One HTML script source observed without building or executing a DOM. */
export interface JavaScriptHtmlScriptObservation {
readonly html_path: string;
readonly script_path: string;
readonly base_href: string | null;
readonly location: JavaScriptSourceRange;
}
/** One original source declared by a local source map. */
export interface JavaScriptSourceMapOriginal {
readonly source: string;
readonly content: string | null;
readonly content_sha256: string | null;
}
/** Local source-map parse result. */
export type JavaScriptSourceMapObservation = JavaScriptStructuredObservation &
(
| {
readonly status: "included";
readonly sources: readonly JavaScriptSourceMapOriginal[];
readonly limitation: null;
}
| {
readonly status: "invalid";
readonly sources: readonly [];
readonly limitation: string;
}
);
/** Parse status for one inventoried JSON module. */
export type JavaScriptJsonModuleObservation = JavaScriptStructuredObservation &
(
| {
readonly status: "included";
readonly top_level_keys: readonly string[];
readonly omitted_top_level_keys: number;
readonly limitation: null;
}
| {
readonly status: "invalid";
readonly top_level_keys: readonly [];
readonly omitted_top_level_keys: 0;
readonly limitation: string;
}
| {
readonly status: "unavailable";
readonly top_level_keys: readonly [];
readonly omitted_top_level_keys: null;
readonly limitation: string;
}
);
/** One relevant file plus optional AST-only JavaScript facts. */
export interface AnalyzedJavaScriptArtifactFile {
readonly file: JavaScriptArtifactFile;
readonly javascript: JavaScriptStaticAnalysis | null;
readonly semantic: {
readonly ir: JavaScriptSemanticIr;
} | null;
}
/** Complete static-analysis projection before graph construction. */
export interface JavaScriptArtifactAnalysis {
readonly files: readonly AnalyzedJavaScriptArtifactFile[];
readonly packages: readonly JavaScriptPackageObservation[];
readonly json_modules: readonly JavaScriptJsonModuleObservation[];
readonly html_scripts: readonly JavaScriptHtmlScriptObservation[];
readonly source_maps: readonly JavaScriptSourceMapObservation[];
readonly visited_ast_nodes: number;
readonly findings: number;
readonly modules: number;
readonly parse_failures: number;
readonly truncated_scopes: number;
readonly limitations: readonly string[];
}
@@ -0,0 +1,381 @@
import { createHash } from "node:crypto";
import { isAbsolute } from "node:path";
import type { Readable } from "node:stream";
import { compareCodePoints } from "../../domain/canonicalOrdering.js";
import { AsarArtifactReader } from "../../artifacts/AsarArtifactReader.js";
import {
ArtifactPathRegistry,
normalizeArtifactPath,
} from "../../artifacts/ArtifactPaths.js";
import {
ArtifactReaderFailure,
type ArtifactEntry,
type ArtifactReader,
} from "../../artifacts/ArtifactReader.js";
import { streamChunkToBuffer } from "../../artifacts/StreamBytes.js";
import { hashReadable } from "../ArtifactInventory/hash.js";
import type { ArtifactInventorySnapshot } from "../ArtifactInventory.js";
/** Relevant file categories projected from the complete artifact inventory. */
export type JavaScriptArtifactFileKind =
| "package-json"
| "json"
| "javascript"
| "html"
| "source-map"
| "native-addon";
/** One content-addressed local artifact file and its text availability. */
export interface JavaScriptArtifactFile {
readonly path: string;
readonly container_sha256: string;
readonly sha256: string;
readonly bytes: number;
readonly inventory_artifact_id: string;
readonly kind: JavaScriptArtifactFileKind;
readonly unpacked: boolean;
readonly text:
| { readonly included: true; readonly value: string }
| {
readonly included: false;
readonly reason: "not-applicable" | "invalid-utf8";
};
}
/** One filesystem-backed ASAR nested beneath a directory input. */
export interface JavaScriptArtifactContainer {
readonly path: string;
readonly sha256: string;
readonly bytes: number;
readonly inventory_artifact_id: string;
}
/** Deterministic relevant-file projection plus explicit omissions. */
export interface JavaScriptArtifactFileSet {
readonly files: readonly JavaScriptArtifactFile[];
readonly containers: readonly JavaScriptArtifactContainer[];
readonly text_bytes_read: number;
readonly invalid_utf8_files: number;
}
interface ExpectedFile {
readonly path: string;
readonly sha256: string;
readonly bytes: number;
readonly inventoryArtifactId: string;
readonly kind: JavaScriptArtifactFileKind;
}
interface ReadContext {
readonly expected: ReadonlyMap<string, ExpectedFile>;
readonly expectedContainers: ReadonlyMap<string, JavaScriptArtifactContainer>;
readonly registry: ArtifactPathRegistry;
readonly files: JavaScriptArtifactFile[];
readonly containers: JavaScriptArtifactContainer[];
readonly signal: AbortSignal | undefined;
textBytes: number;
invalidUtf8: number;
}
interface ReadTextInput {
readonly reader: ArtifactReader;
readonly entry: ArtifactEntry;
readonly expected: ExpectedFile;
}
/** Read all relevant textual entries through an already-inventoried reader. */
export const readJavaScriptArtifactFiles = async (
reader: ArtifactReader,
snapshot: ArtifactInventorySnapshot,
signal?: AbortSignal,
): Promise<JavaScriptArtifactFileSet> => {
if (reader instanceof AsarArtifactReader) {
const root = snapshot.nodes.find(
({ artifact_id }) => artifact_id === snapshot.manifest.root_artifact_id,
);
if (root === undefined)
throw new ArtifactReaderFailure(
"integrity",
"Root artifact node is missing",
);
await verifyEntryBytes(
reader.openContainer(signal),
{ path: ".", sha256: root.sha256, bytes: root.size },
signal,
);
}
const inventory = expectedInventory(snapshot);
const expected = inventory.files;
const context: ReadContext = {
expected,
expectedContainers: inventory.containers,
registry: new ArtifactPathRegistry(),
files: [],
containers: [],
signal,
textBytes: 0,
invalidUtf8: 0,
};
await visitReader(reader, "", snapshot.manifest.root_sha256, context);
const files = context.files.sort((left, right) =>
compareCodePoints(left.path, right.path),
);
assertExpectedFilesWereVisited(expected, files);
return {
files,
containers: context.containers.sort((left, right) =>
compareCodePoints(left.path, right.path),
),
text_bytes_read: context.textBytes,
invalid_utf8_files: context.invalidUtf8,
};
};
const visitReader = async (
reader: ArtifactReader,
prefix: string,
containerSha256: string,
context: ReadContext,
): Promise<void> => {
const stack: Array<{
readonly reader: ArtifactReader;
readonly prefix: string;
readonly containerSha256: string;
readonly iterator: AsyncIterator<ArtifactEntry>;
readonly owned: boolean;
}> = [
{
reader,
prefix,
containerSha256,
iterator: reader.entries(context.signal)[Symbol.asyncIterator](),
owned: false,
},
];
try {
while (stack.length > 0) {
const frame = stack.at(-1);
if (frame === undefined) break;
const next = await frame.iterator.next();
if (next.done) {
stack.pop();
if (frame.owned) await frame.reader.close();
continue;
}
const entry = next.value;
abortIfNeeded(context.signal);
const path = normalizeArtifactPath(
frame.prefix === "" ? entry.path : `${frame.prefix}/${entry.path}`,
);
const nestedAsar = isFilesystemAsar(entry, path);
context.registry.add(path, nestedAsar ? "directory" : entry.kind);
if (nestedAsar) {
const inventory = expectedContainer(path, context);
await verifyEntryBytes(
await frame.reader.open(entry, context.signal),
inventory,
context.signal,
);
context.containers.push(inventory);
const nested = new AsarArtifactReader(entry.adapterKey);
stack.push({
reader: nested,
prefix: path,
containerSha256: inventory.sha256,
iterator: nested.entries(context.signal)[Symbol.asyncIterator](),
owned: true,
});
continue;
}
const expected = context.expected.get(path);
if (expected === undefined) continue;
const text = await readText(context, {
reader: frame.reader,
entry,
expected,
});
context.files.push({
path,
container_sha256: frame.containerSha256,
sha256: expected.sha256,
bytes: expected.bytes,
inventory_artifact_id: expected.inventoryArtifactId,
kind: expected.kind,
unpacked: entry.unpacked,
text,
});
}
} finally {
await Promise.allSettled(
stack
.filter(({ owned }) => owned)
.map(async ({ reader, iterator }) => {
await iterator.return?.();
await reader.close();
}),
);
}
};
const expectedContainer = (
path: string,
context: ReadContext,
): JavaScriptArtifactContainer => {
const container = context.expectedContainers.get(path);
if (container === undefined)
throw new ArtifactReaderFailure(
"integrity",
`Nested ASAR disappeared from inventory: ${path}`,
);
return container;
};
const readText = async (
context: ReadContext,
input: ReadTextInput,
): Promise<JavaScriptArtifactFile["text"]> => {
if (input.expected.kind === "native-addon") {
await verifyEntryBytes(
await input.reader.open(input.entry, context.signal),
input.expected,
context.signal,
);
return { included: false, reason: "not-applicable" };
}
const bytes = await readAll(
await input.reader.open(input.entry, context.signal),
context.signal,
);
const digest = createHash("sha256").update(bytes).digest("hex");
if (digest !== input.expected.sha256 || bytes.length !== input.expected.bytes)
throw new ArtifactReaderFailure(
"integrity",
`Artifact entry changed after inventory: ${input.expected.path}`,
);
context.textBytes += bytes.length;
try {
return {
included: true,
value: new TextDecoder("utf-8", { fatal: true }).decode(bytes),
};
} catch (cause: unknown) {
// Non-UTF8 bytes are counted; the fixed reason preserves the schema.
void cause;
context.invalidUtf8 += 1;
return { included: false, reason: "invalid-utf8" };
}
};
const verifyEntryBytes = async (
stream: Readable,
expected: Pick<ExpectedFile, "path" | "sha256" | "bytes">,
signal?: AbortSignal,
): Promise<void> => {
try {
const digest = await hashReadable(stream, signal);
if (digest.sha256 !== expected.sha256 || digest.bytes !== expected.bytes)
throw new ArtifactReaderFailure(
"integrity",
`Artifact entry changed after inventory: ${expected.path}`,
);
} catch (cause: unknown) {
abortIfNeeded(signal);
throw cause;
}
};
const expectedInventory = (
snapshot: ArtifactInventorySnapshot,
): {
readonly files: ReadonlyMap<string, ExpectedFile>;
readonly containers: ReadonlyMap<string, JavaScriptArtifactContainer>;
} => {
const nodes = new Map(snapshot.nodes.map((node) => [node.artifact_id, node]));
const files = new Map<string, ExpectedFile>();
const containers = new Map<string, JavaScriptArtifactContainer>();
for (const occurrence of snapshot.occurrences) {
if (occurrence.artifact_id === null || occurrence.logical_path === ".")
continue;
const node = nodes.get(occurrence.artifact_id);
if (node === undefined)
throw new ArtifactReaderFailure(
"integrity",
`Artifact inventory node is missing: ${occurrence.logical_path}`,
);
// Inventory occurrences can have content identities for directories too.
// Suffixes only describe file candidates; a directory such as
// node_modules/@zip.js remains a graph node and must never be opened.
if (occurrence.entry_kind !== "file") continue;
if (occurrence.logical_path.toLowerCase().endsWith(".asar"))
containers.set(occurrence.logical_path, {
path: occurrence.logical_path,
sha256: node.sha256,
bytes: node.size,
inventory_artifact_id: node.artifact_id,
});
const kind = relevantKind(occurrence.logical_path);
if (kind === undefined) continue;
files.set(occurrence.logical_path, {
path: occurrence.logical_path,
sha256: node.sha256,
bytes: node.size,
inventoryArtifactId: node.artifact_id,
kind,
});
}
return { files, containers };
};
const readAll = async (
stream: Readable,
signal?: AbortSignal,
): Promise<Buffer> => {
const chunks: Buffer[] = [];
let bytes = 0;
for await (const raw of stream) {
abortIfNeeded(signal);
const chunk = streamChunkToBuffer(raw);
bytes += chunk.length;
chunks.push(chunk);
}
return Buffer.concat(chunks, bytes);
};
const assertExpectedFilesWereVisited = (
expected: ReadonlyMap<string, ExpectedFile>,
actual: readonly JavaScriptArtifactFile[],
): void => {
const observed = new Set(actual.map(({ path }) => path));
for (const path of expected.keys())
if (!observed.has(path))
throw new ArtifactReaderFailure(
"integrity",
`Relevant artifact entry disappeared after inventory: ${path}`,
);
};
const relevantKind = (path: string): JavaScriptArtifactFileKind | undefined => {
const lower = path.toLowerCase();
if (lower === "package.json" || lower.endsWith("/package.json"))
return "package-json";
if (lower.endsWith(".json")) return "json";
if (/\.(?:cjs|mjs|js|jsx|ts|tsx)$/u.test(lower)) return "javascript";
if (/\.html?$/u.test(lower)) return "html";
if (lower.endsWith(".map")) return "source-map";
if (lower.endsWith(".node")) return "native-addon";
return undefined;
};
const isFilesystemAsar = (entry: ArtifactEntry, path: string): boolean =>
entry.kind === "file" &&
path.toLowerCase().endsWith(".asar") &&
isAbsolute(entry.adapterKey);
const abortIfNeeded = (signal?: AbortSignal): void => {
if (signal?.aborted === true)
throw new ArtifactReaderFailure(
"cancelled",
"JavaScript artifact reconstruction cancelled",
);
};
@@ -0,0 +1,88 @@
import {
createJavaScriptApplicationEdge,
createJavaScriptApplicationNode,
type ApplicationEdge,
type ApplicationNode,
} from "../../domain/javascriptApplicationGraph.js";
/** Deduplicate graph entities while retaining distinct evidence observations. */
export class JavaScriptArtifactGraphAccumulator {
readonly #nodes = new Map<string, ApplicationNode>();
readonly #edges = new Map<string, ApplicationEdge>();
/** Create or merge one canonical node. */
addNode<Input extends DisplayableNodeInput>(input: Input): ApplicationNode {
const created = createJavaScriptApplicationNode(displayableNode(input));
const existing = this.#nodes.get(created.node_id);
if (existing === undefined) {
this.#nodes.set(created.node_id, created);
return created;
}
const bySemanticId = new Map(
[...existing.observations, ...created.observations].map((observation) => [
observation.observation_id,
observationInput(observation),
]),
);
const observations = [...bySemanticId.entries()]
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([, observation]) => observation);
const merged = createJavaScriptApplicationNode({
kind: existing.kind,
identity: existing.identity,
observations,
});
this.#nodes.set(merged.node_id, merged);
return merged;
}
/** Create or replace one canonical edge by semantic identifier. */
addEdge(input: unknown): ApplicationEdge {
const edge = createJavaScriptApplicationEdge(input);
this.#edges.set(edge.edge_id, edge);
return edge;
}
/** Return all accumulated canonical nodes. */
nodes(): readonly ApplicationNode[] {
return [...this.#nodes.values()];
}
/** Return all accumulated canonical edges. */
edges(): readonly ApplicationEdge[] {
return [...this.#edges.values()];
}
}
const observationInput = (
observation: ApplicationNode["observations"][number],
) => {
const {
observation_id: _observationId,
identifier_strategy: _strategy,
...input
} = observation;
return input;
};
/** Node fields whose display form is normalized before validation. */
interface DisplayableNodeInput {
readonly observations: readonly { readonly label: string | null }[];
}
/**
* Static findings can carry legal empty strings, such as `fetch("")`,
* `require("")`, or a source map's `sources: [""]`. Labels must be nonempty,
* so an empty value has no label rather than synthetic text that a real value
* could share; literal feature seeds match labels. Identities and observation
* properties keep the exact value.
*/
const displayableNode = (
input: DisplayableNodeInput,
): DisplayableNodeInput => ({
...input,
observations: input.observations.map((observation) => ({
...observation,
label: observation.label === "" ? null : observation.label,
})),
});
@@ -0,0 +1,167 @@
import type { ArtifactInventorySnapshot } from "../ArtifactInventory.js";
import {
createJavaScriptApplicationGraph,
type JavaScriptApplicationGraph,
} from "../../domain/javascriptApplicationGraph.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptArtifactFileSet } from "./JavaScriptArtifactFiles.js";
import { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGraphAccumulator.js";
import type { JavaScriptArtifactGraphContext } from "./JavaScriptArtifactGraphContext.js";
import {
addJavaScriptHtmlRoles,
addJavaScriptSourceMapOriginals,
} from "./JavaScriptArtifactGraphDocuments.js";
import { addJavaScriptStaticFindings } from "./JavaScriptArtifactGraphFindings.js";
import {
addJavaScriptModuleRelationships,
addJavaScriptSourceModules,
} from "./JavaScriptModuleRelationships.js";
import {
completeApplicationCoverage,
partialApplicationCoverage,
} from "../../domain/javascriptApplicationEvidenceSchemas.js";
import {
addJavaScriptArtifactContainers,
addJavaScriptArtifactFiles,
addJavaScriptBundlerNodes,
addJavaScriptPackageNodes,
createJavaScriptArtifactRootNode,
} from "./JavaScriptArtifactGraphStructure.js";
import { addElectronBoundaries } from "./ElectronBoundaryGraph.js";
import {
classifyElectronIpcPairings,
collectElectronIpcRecords,
} from "./ElectronBoundaryAnalysis.js";
/** Project artifact and AST facts into JavaScript Application Graph. */
export const buildJavaScriptArtifactGraph = (
snapshot: ArtifactInventorySnapshot,
fileSet: JavaScriptArtifactFileSet,
analysis: JavaScriptArtifactAnalysis,
): JavaScriptApplicationGraph => {
const accumulator = new JavaScriptArtifactGraphAccumulator();
const root = createJavaScriptArtifactRootNode(accumulator, snapshot);
const context: JavaScriptArtifactGraphContext = {
accumulator,
snapshot,
fileSet,
analysis,
root,
filesByPath: new Map(fileSet.files.map((file) => [file.path, file])),
fileNodes: new Map(),
assetNodes: new Map(),
sourceModuleNodes: new Map(),
chunkNodes: new Map(),
moduleNodes: new Map(),
containerNodes: new Map([[snapshot.manifest.root_sha256, root]]),
};
addJavaScriptArtifactContainers(context);
addJavaScriptArtifactFiles(context);
const packageRoots = addJavaScriptPackageNodes(context);
addJavaScriptSourceModules(context);
addJavaScriptBundlerNodes(context);
addJavaScriptModuleRelationships(context);
addJavaScriptStaticFindings(context);
addElectronBoundaries(context);
addJavaScriptHtmlRoles(context);
addJavaScriptSourceMapOriginals(context);
const coverage = graphCoverage(context);
return createJavaScriptApplicationGraph({
schema: "JavaScriptApplicationGraph",
root_node_ids:
packageRoots.length === 0
? [root.node_id]
: packageRoots.map(({ node_id: id }) => id),
nodes: accumulator.nodes(),
edges: accumulator.edges(),
coverage,
limitations: graphLimitations(context, coverage.status),
});
};
const graphCoverage = (context: JavaScriptArtifactGraphContext) => {
const sourceMapPolicyGap = context.analysis.source_maps.some(
({ status }) => status === "invalid",
);
const malformedStructuredData =
context.analysis.packages.some(({ status }) => status !== "included") ||
context.analysis.json_modules.some(({ status }) => status !== "included") ||
context.analysis.source_maps.some(({ status }) => status === "invalid");
const partialJavaScript = context.analysis.files.some(
({ javascript }) =>
javascript !== null && javascript.parse_status === "partial",
);
const unknownGap =
context.analysis.parse_failures > 0 ||
context.fileSet.invalid_utf8_files > 0 ||
sourceMapPolicyGap ||
malformedStructuredData ||
partialJavaScript;
if (context.analysis.truncated_scopes > 0)
return partialApplicationCoverage([], null);
if (unknownGap) return partialApplicationCoverage([], null);
return completeApplicationCoverage();
};
const graphLimitations = (
context: JavaScriptArtifactGraphContext,
coverage: "complete" | "partial" | "unknown" | "unavailable",
): string[] => {
const ipc = collectElectronIpcRecords(context.analysis);
const pairings = classifyElectronIpcPairings(ipc);
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),
0,
);
return [
...context.analysis.limitations,
"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.",
...(electronFindings === 0
? []
: [
"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.",
]
: []),
...(pairings.some(({ status }) => status === "ambiguous")
? [
"Some literal IPC channels have multiple compatible main handlers; ambiguous calls are not paired to any handler.",
]
: []),
...(context.analysis.files.some(
({ javascript }) =>
(javascript?.electron.sender_validations.length ?? 0) > 0,
)
? [
"Sender, frame, URL, and origin checks are observations only; REA does not claim that they enforce a complete authorization policy.",
]
: []),
...(context.analysis.files.some(
({ javascript }) =>
(javascript?.electron.native_addon_bindings.length ?? 0) > 0,
)
? [
"Native member names are requested by JavaScript syntax and are not verified binary exports in this workflow.",
]
: []),
...(coverage === "complete"
? []
: [
"Graph coverage is incomplete; unavailable facts remain explicit and must not be read as absence.",
]),
];
};
@@ -0,0 +1,303 @@
import type { ApplicationNode } from "../../domain/javascriptApplicationGraph.js";
import type { JavaScriptBundlerRegistration } from "../../domain/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import {
addAstContainsEdge,
artifactLocalIdentity,
chunkLookupKey,
javascriptAnalysisCoverage,
moduleLookupKey,
type JavaScriptArtifactGraphContext,
type JavaScriptArtifactGraphCoverage,
} from "./JavaScriptArtifactGraphContext.js";
import {
astObservationEvidence,
staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
/** Project Webpack/Rspack chunk and factory literals recovered from AST. */
export const addJavaScriptBundlerNodes = (
context: JavaScriptArtifactGraphContext,
): void => {
for (const analyzed of context.analysis.files) {
const { file, javascript } = analyzed;
const asset = context.assetNodes.get(file.path);
if (javascript === null || asset === undefined) continue;
const coverage = javascriptAnalysisCoverage(javascript);
const records: BundlerGraphRecord[] = [];
for (const registration of javascript.bundler_registrations) {
const input = {
file,
registration,
coverage,
limitations: javascript.limitations,
};
const chunk = createBundlerChunkNode(context, input);
addAstContainsEdge(context, {
source: asset,
target: chunk,
file,
range: registration.location,
coverage,
properties: { bundler: registration.bundler },
});
for (const key of registration.chunk_keys)
context.chunkNodes.set(
chunkLookupKey(file.path, registration.runtime, key),
chunk,
);
records.push({ file, registration, chunk, coverage });
addBundlerModuleNodes(context, { ...input, chunk });
}
for (const record of records) addBundlerRuntimeEdges(context, record);
}
};
interface BundlerProjectionInput {
readonly file: JavaScriptArtifactFile;
readonly registration: JavaScriptBundlerRegistration;
readonly coverage: JavaScriptArtifactGraphCoverage;
readonly limitations: readonly string[];
}
interface BundlerModuleProjectionInput extends BundlerProjectionInput {
readonly chunk: ApplicationNode;
}
const createBundlerChunkNode = (
context: JavaScriptArtifactGraphContext,
input: BundlerProjectionInput,
): ApplicationNode => {
const { file, registration, coverage, limitations } = input;
const chunkKey = `${registration.runtime}:${registration.chunk_keys.join(",")}`;
return context.accumulator.addNode({
kind: "javascript-chunk",
identity: artifactLocalIdentity(file.sha256, "bundler-chunk", chunkKey),
observations: [
{
label: chunkKey,
properties: {
path: file.path,
bundler: registration.bundler,
runtime: registration.runtime,
chunk_keys: registration.chunk_keys,
unknown_chunk_keys: registration.unknown_chunk_keys,
runtime_require_name: registration.runtime_require_name,
runtime_module_cache_status: registration.runtime_module_cache_status,
entry_module_keys: registration.entry_module_keys,
unknown_entry_module_keys: registration.unknown_entry_module_keys,
async_chunk_keys: registration.async_chunk_keys,
unknown_async_chunk_keys: registration.unknown_async_chunk_keys,
module_count: registration.modules.length,
},
evidence: astObservationEvidence({
sha256: file.sha256,
path: file.path,
range: registration.location,
operation: "extract-bundler-registration",
coverage,
limitations,
}),
},
],
});
};
const addBundlerModuleNodes = (
context: JavaScriptArtifactGraphContext,
input: BundlerModuleProjectionInput,
): void => {
const { file, registration, chunk, coverage, limitations } = input;
for (const moduleValue of registration.modules) {
const module = context.accumulator.addNode({
kind: "javascript-module",
identity: artifactLocalIdentity(
file.sha256,
`${registration.runtime}:module`,
moduleValue.module_key,
),
observations: [
{
label: moduleValue.module_key,
properties: {
path: file.path,
bundler: registration.bundler,
runtime: registration.runtime,
chunk_keys: registration.chunk_keys,
module_key: moduleValue.module_key,
factory_require_name: moduleValue.factory_require_name,
runtime_entry: registration.entry_module_keys.includes(
moduleValue.module_key,
),
source_sha256: moduleValue.source_sha256,
structural_fingerprint_sha256:
moduleValue.structural_fingerprint_sha256,
structural_fingerprint_algorithm:
moduleValue.structural_fingerprint_algorithm,
exports: moduleValue.exports,
},
evidence: astObservationEvidence({
sha256: file.sha256,
path: file.path,
range: moduleValue.location,
operation: "extract-bundler-module",
coverage,
limitations,
}),
},
],
});
context.moduleNodes.set(
moduleLookupKey(file.path, moduleValue.module_key),
module,
);
addAstContainsEdge(context, {
source: chunk,
target: module,
file,
range: moduleValue.location,
coverage,
properties: {
bundler: registration.bundler,
runtime: registration.runtime,
chunk_keys: registration.chunk_keys,
module_key: moduleValue.module_key,
},
});
}
};
interface BundlerGraphRecord {
readonly file: JavaScriptArtifactFile;
readonly registration: JavaScriptBundlerRegistration;
readonly chunk: ApplicationNode;
readonly coverage: JavaScriptArtifactGraphCoverage;
}
const addBundlerRuntimeEdges = (
context: JavaScriptArtifactGraphContext,
record: BundlerGraphRecord,
): void => {
const { file, registration, chunk, coverage } = record;
for (const moduleKey of registration.entry_module_keys) {
const resolved = context.moduleNodes.get(
moduleLookupKey(file.path, moduleKey),
);
const target =
resolved ?? unresolvedBundlerModuleNode(context, record, moduleKey);
context.accumulator.addEdge({
source_node_id: chunk.node_id,
target_node_id: target.node_id,
relation: "loads",
properties: {
kind: "bundler-entry-module",
bundler: registration.bundler,
runtime: registration.runtime,
module_key: moduleKey,
resolution_status: resolved === undefined ? "not-found" : "resolved",
},
evidence: staticInferenceEvidence({
sha256: file.sha256,
path: file.path,
range: registration.location,
operation: "resolve-bundler-entry-module",
coverage,
confidence: resolved === undefined ? "low" : "high",
}),
});
}
for (const chunkKey of registration.async_chunk_keys) {
const lookup = chunkLookupKey(file.path, registration.runtime, chunkKey);
const resolved = context.chunkNodes.get(lookup);
const target =
resolved ?? unresolvedBundlerChunkNode(context, record, chunkKey);
context.accumulator.addEdge({
source_node_id: chunk.node_id,
target_node_id: target.node_id,
relation: "imports",
properties: {
kind: "bundler-async-chunk",
bundler: registration.bundler,
runtime: registration.runtime,
chunk_key: chunkKey,
resolution_status: resolved === undefined ? "not-found" : "resolved",
},
evidence: staticInferenceEvidence({
sha256: file.sha256,
path: file.path,
range: registration.location,
operation: "resolve-bundler-async-chunk",
coverage,
confidence: resolved === undefined ? "low" : "high",
}),
});
}
};
const unresolvedBundlerModuleNode = (
context: JavaScriptArtifactGraphContext,
record: BundlerGraphRecord,
moduleKey: string,
): ApplicationNode =>
context.accumulator.addNode({
kind: "javascript-module",
identity: artifactLocalIdentity(
record.file.sha256,
`${record.registration.runtime}:unresolved-entry-module`,
moduleKey,
),
observations: [
{
label: moduleKey,
properties: {
semantic_role: "bundler-module-reference",
bundler: record.registration.bundler,
runtime: record.registration.runtime,
module_key: moduleKey,
resolution_status: "not-found",
},
evidence: staticInferenceEvidence({
sha256: record.file.sha256,
path: record.file.path,
range: record.registration.location,
operation: "retain-unresolved-bundler-entry-module",
coverage: record.coverage,
confidence: "low",
}),
},
],
});
const unresolvedBundlerChunkNode = (
context: JavaScriptArtifactGraphContext,
record: BundlerGraphRecord,
chunkKey: string,
): ApplicationNode =>
context.accumulator.addNode({
kind: "javascript-chunk",
identity: artifactLocalIdentity(
record.file.sha256,
`${record.registration.runtime}:unresolved-async-chunk`,
chunkKey,
),
observations: [
{
label: chunkKey,
properties: {
semantic_role: "bundler-chunk-reference",
bundler: record.registration.bundler,
runtime: record.registration.runtime,
chunk_key: chunkKey,
resolution_status: "not-found",
},
evidence: staticInferenceEvidence({
sha256: record.file.sha256,
path: record.file.path,
range: record.registration.location,
operation: "retain-unresolved-bundler-async-chunk",
coverage: record.coverage,
confidence: "low",
}),
},
],
});
@@ -0,0 +1,343 @@
import type { ArtifactInventorySnapshot } from "../ArtifactInventory.js";
import {
completeApplicationCoverage,
partialApplicationCoverage,
type ApplicationGraphEvidence,
} from "../../domain/javascriptApplicationEvidenceSchemas.js";
import type { ApplicationNode } from "../../domain/javascriptApplicationGraph.js";
import type { JavaScriptSourceRange } from "../../domain/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type {
JavaScriptArtifactFile,
JavaScriptArtifactFileSet,
JavaScriptArtifactFileKind,
} from "./JavaScriptArtifactFiles.js";
import { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGraphAccumulator.js";
import {
artifactObservationEvidence,
astObservationEvidence,
staticInferenceEvidence,
unavailableAstEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
import {
resolveArtifactPathByContext,
type ArtifactPathResolution,
} from "./JavaScriptArtifactPathResolution.js";
/** Mutable graph-construction state scoped to one reconstruction. */
export interface JavaScriptArtifactGraphContext {
readonly accumulator: JavaScriptArtifactGraphAccumulator;
readonly snapshot: ArtifactInventorySnapshot;
readonly fileSet: JavaScriptArtifactFileSet;
readonly analysis: JavaScriptArtifactAnalysis;
readonly root: ApplicationNode;
readonly filesByPath: ReadonlyMap<string, JavaScriptArtifactFile>;
readonly fileNodes: Map<string, ApplicationNode>;
readonly assetNodes: Map<string, ApplicationNode>;
readonly sourceModuleNodes: Map<string, ApplicationNode>;
readonly chunkNodes: Map<string, ApplicationNode>;
readonly moduleNodes: Map<string, ApplicationNode>;
readonly containerNodes: Map<string, ApplicationNode>;
}
/** Evidence coverage shape shared by reconstruction graph helpers. */
export type JavaScriptArtifactGraphCoverage =
ApplicationGraphEvidence["coverage"];
/** Direct artifact-containment edge values. */
export interface ContainsEdgeInput {
readonly source: ApplicationNode;
readonly target: ApplicationNode;
readonly file: JavaScriptArtifactFile;
readonly operation: string;
}
/** Exact AST containment edge values. */
export interface AstEdgeInput {
readonly source: ApplicationNode;
readonly target: ApplicationNode;
readonly file: JavaScriptArtifactFile;
readonly range: JavaScriptSourceRange;
readonly coverage: JavaScriptArtifactGraphCoverage;
readonly properties: Readonly<Record<string, unknown>>;
}
/** Inferred static relationship edge values. */
export interface InferenceEdgeInput extends AstEdgeInput {
readonly relation:
| "imports"
| "loads"
| "maps_to"
| "exposes"
| "calls"
| "persists_to";
}
/** Electron role identity and static discovery evidence. */
export interface RoleNodeInput {
readonly kind: "electron-main" | "electron-preload" | "electron-renderer";
readonly anchor: JavaScriptArtifactFile;
readonly resolution: ArtifactPathResolution;
readonly mechanism: string;
readonly range?: JavaScriptSourceRange;
readonly coverage?: JavaScriptArtifactGraphCoverage;
}
/** Resolution from an inferred Electron role to an artifact asset. */
export interface RoleAssetLinkInput {
readonly role: ApplicationNode;
readonly anchor: JavaScriptArtifactFile;
readonly resolution: ArtifactPathResolution;
readonly range?: JavaScriptSourceRange;
readonly coverage?: JavaScriptArtifactGraphCoverage;
}
/** Explicit package, JavaScript, or source-map parse gap. */
export interface UnavailableParseInput {
readonly file: JavaScriptArtifactFile;
readonly asset: ApplicationNode;
readonly operation: string;
readonly limitation: string;
}
/** Add a direct artifact-containment edge. */
export const addArtifactContainsEdge = (
context: JavaScriptArtifactGraphContext,
input: ContainsEdgeInput,
): void => {
context.accumulator.addEdge({
source_node_id: input.source.node_id,
target_node_id: input.target.node_id,
relation: "contains",
properties: { path: input.file.path },
evidence: artifactObservationEvidence({
sha256:
input.operation === "inventory-entry"
? input.file.container_sha256
: input.file.sha256,
path: input.file.path,
operation: input.operation,
coverage: completeApplicationCoverage(),
}),
});
};
/** Add an exact AST containment edge. */
export const addAstContainsEdge = (
context: JavaScriptArtifactGraphContext,
input: AstEdgeInput,
): void => {
context.accumulator.addEdge({
source_node_id: input.source.node_id,
target_node_id: input.target.node_id,
relation: "contains",
properties: input.properties,
evidence: astObservationEvidence({
sha256: input.file.sha256,
path: input.file.path,
range: input.range,
operation: "map-bundler-structure",
coverage: input.coverage,
}),
});
};
/** Add an explicitly inferred static relationship edge. */
export const addStaticInferenceEdge = (
context: JavaScriptArtifactGraphContext,
input: InferenceEdgeInput,
): void => {
context.accumulator.addEdge({
source_node_id: input.source.node_id,
target_node_id: input.target.node_id,
relation: input.relation,
properties: input.properties,
evidence: staticInferenceEvidence({
sha256: input.file.sha256,
path: input.file.path,
range: input.range,
operation: "map-static-relationship",
coverage: input.coverage,
}),
});
};
/** Preserve a static parse or approval gap as an explicit unknown scope. */
export const addUnavailableStaticParseScope = (
context: JavaScriptArtifactGraphContext,
input: UnavailableParseInput,
): void => {
const node = context.accumulator.addNode({
kind: "unknown",
identity: artifactLocalIdentity(
input.file.sha256,
"static-parse-scope",
`${input.operation}:${input.file.path}`,
),
observations: [
{
label: `${input.file.path} parse scope`,
properties: {
path: input.file.path,
operation: input.operation,
limitation: input.limitation,
},
evidence: unavailableAstEvidence({
sha256: input.file.sha256,
path: input.file.path,
operation: input.operation,
coverage: partialApplicationCoverage([], null),
limitation: input.limitation,
}),
},
],
});
context.accumulator.addEdge({
source_node_id: input.asset.node_id,
target_node_id: node.node_id,
relation: "contains",
properties: { scope: "unavailable-static-analysis" },
evidence: artifactObservationEvidence({
sha256: input.file.sha256,
path: input.file.path,
operation: "associate-unavailable-parse-scope",
coverage: completeApplicationCoverage(),
}),
});
};
/** Coverage for one bounded JavaScript AST analysis. */
export const javascriptAnalysisCoverage = (
analysis: NonNullable<
JavaScriptArtifactAnalysis["files"][number]["javascript"]
>,
): JavaScriptArtifactGraphCoverage =>
analysis.parse_status === "complete"
? completeApplicationCoverage()
: partialApplicationCoverage([], null);
/** Stable identity for a fact scoped to one exact artifact. */
export const artifactLocalIdentity = (
sha256: string,
namespace: string,
key: string,
) => ({
strategy: "artifact-local-key" as const,
stability: "artifact-version" as const,
artifact_sha256: sha256,
namespace,
key,
});
/** Select the graph node kind for one inventoried relevant file. */
export const artifactFileNodeKind = (
kind: JavaScriptArtifactFileKind,
): ApplicationNode["kind"] =>
kind === "javascript"
? "javascript-asset"
: kind === "source-map"
? "source-map"
: kind === "native-addon"
? "native-addon"
: "artifact";
/** Create or merge an Electron role inferred from static syntax or metadata. */
export const createElectronRoleNode = (
context: JavaScriptArtifactGraphContext,
input: RoleNodeInput,
): ApplicationNode =>
context.accumulator.addNode({
kind: input.kind,
identity: artifactLocalIdentity(
input.anchor.sha256,
"electron-role",
`${input.kind}:${input.resolution.resolution_context}:${input.resolution.resolved_path ?? input.resolution.declared_path}`,
),
observations: [
{
label: input.resolution.resolved_path ?? input.resolution.declared_path,
properties: {
declared_path: input.resolution.declared_path,
resolution_context: input.resolution.resolution_context,
resolved_path: input.resolution.resolved_path,
resolution_status: input.resolution.resolution_status,
limitations: input.resolution.limitations,
mechanism: input.mechanism,
},
evidence: staticInferenceEvidence({
sha256: input.anchor.sha256,
path: input.anchor.path,
operation: "discover-electron-entrypoint",
coverage: input.coverage ?? completeApplicationCoverage(),
limitations: input.resolution.limitations,
...(input.range === undefined ? {} : { range: input.range }),
}),
},
],
});
/** Resolve an Electron role to an inventoried asset when available. */
export const linkElectronRoleToAsset = (
context: JavaScriptArtifactGraphContext,
input: RoleAssetLinkInput,
): void => {
const path = input.resolution.resolved_path;
if (path === null) return;
const asset = context.assetNodes.get(path) ?? context.fileNodes.get(path);
if (asset === undefined) return;
context.accumulator.addEdge({
source_node_id: input.role.node_id,
target_node_id: asset.node_id,
relation: "maps_to",
properties: {
declared_path: input.resolution.declared_path,
resolution_context: input.resolution.resolution_context,
resolved_path: path,
resolution_status: input.resolution.resolution_status,
},
evidence: staticInferenceEvidence({
sha256: input.anchor.sha256,
path: input.anchor.path,
operation: "resolve-electron-entrypoint",
coverage: input.coverage ?? completeApplicationCoverage(),
limitations: input.resolution.limitations,
...(input.range === undefined ? {} : { range: input.range }),
}),
});
};
/** Deterministic key for one recovered module inside a bundle asset. */
export const moduleLookupKey = (path: string, moduleKey: string): string =>
`${path}\0${moduleKey}`;
/** Deterministic key for one recovered bundler chunk inside a bundle asset. */
export const chunkLookupKey = (
path: string,
runtime: string,
chunkKey: string,
): string => `${path}\0${runtime}\0${chunkKey}`;
/** Resolve a relative static specifier without escaping the artifact root. */
export const resolveArtifactPath = (
specifier: string,
sourcePath: string,
files: ReadonlyMap<string, JavaScriptArtifactFile>,
moduleKind?: "import" | "require",
): string | null =>
resolveArtifactPathByContext({
declaredPath: specifier,
sourcePath,
context: "module-specifier",
files,
...(moduleKind === undefined ? {} : { moduleKind }),
}).resolved_path;
/** Locate the recovered module that owns a static finding. */
export const sourceNodeFor = (
context: JavaScriptArtifactGraphContext,
path: string,
moduleKey: string | null,
): ApplicationNode | undefined =>
moduleKey === null
? context.sourceModuleNodes.get(path)
: context.moduleNodes.get(moduleLookupKey(path, moduleKey));
@@ -0,0 +1,137 @@
import {
addArtifactContainsEdge,
addStaticInferenceEdge,
createElectronRoleNode,
linkElectronRoleToAsset,
type JavaScriptArtifactGraphContext,
} from "./JavaScriptArtifactGraphContext.js";
import { artifactObservationEvidence } from "./JavaScriptArtifactGraphEvidence.js";
import { completeApplicationCoverage } from "../../domain/javascriptApplicationEvidenceSchemas.js";
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
/** Project HTML renderer entrypoints and their local script assets. */
export const addJavaScriptHtmlRoles = (
context: JavaScriptArtifactGraphContext,
): void => {
const byHtml = new Map<string, typeof context.analysis.html_scripts>();
for (const script of context.analysis.html_scripts) {
const existing = byHtml.get(script.html_path);
byHtml.set(
script.html_path,
existing === undefined ? [script] : [...existing, script],
);
}
for (const [htmlPath, scripts] of byHtml) {
const html = context.filesByPath.get(htmlPath);
if (html === undefined) continue;
const roleResolution = {
declared_path: html.path,
resolution_context: "html-reference" as const,
resolved_path: html.path,
resolution_status: "resolved" as const,
limitations: [],
};
const role = createElectronRoleNode(context, {
kind: "electron-renderer",
anchor: html,
resolution: roleResolution,
mechanism: "html-script-entrypoint",
});
addArtifactContainsEdge(context, {
source: context.root,
target: role,
file: html,
operation: "discover-html-renderer",
});
linkElectronRoleToAsset(context, {
role,
anchor: html,
resolution: roleResolution,
});
for (const script of scripts) {
const resolution = resolveArtifactPathByContext({
declaredPath: script.script_path,
sourcePath: html.path,
context: "html-reference",
files: context.filesByPath,
htmlBaseHref: script.base_href,
});
const resolvedPath = resolution.resolved_path;
if (resolvedPath === null) continue;
const resolved = context.fileNodes.get(resolvedPath);
if (resolved === undefined) continue;
addStaticInferenceEdge(context, {
source: role,
target: resolved,
file: html,
range: script.location,
coverage: completeApplicationCoverage(),
relation: "loads",
properties: {
script_path: script.script_path,
base_href: script.base_href,
resolution_context: resolution.resolution_context,
resolved_path: resolvedPath,
resolution_status: resolution.resolution_status,
limitations: resolution.limitations,
},
});
}
}
};
/** Project original source names and optional content digests from approved maps. */
export const addJavaScriptSourceMapOriginals = (
context: JavaScriptArtifactGraphContext,
): void => {
for (const sourceMap of context.analysis.source_maps) {
const mapFile = context.filesByPath.get(sourceMap.path);
const mapNode = context.fileNodes.get(sourceMap.path);
if (mapFile === undefined || mapNode === undefined) continue;
const coverage = completeApplicationCoverage();
for (const original of sourceMap.sources) {
const node = context.accumulator.addNode({
kind: "source-module",
identity: {
strategy: "source-map-original",
stability: "source-map-exact",
source_map_sha256: mapFile.sha256,
original_source: original.source,
source_sha256: original.content_sha256,
},
observations: [
{
label: original.source,
properties: {
source: original.source,
content_available: original.content !== null,
source_sha256: original.content_sha256,
},
evidence: artifactObservationEvidence({
sha256: mapFile.sha256,
path: mapFile.path,
operation: "parse-local-source-map",
coverage,
limitations:
sourceMap.limitation === null ? [] : [sourceMap.limitation],
}),
},
],
});
context.accumulator.addEdge({
source_node_id: mapNode.node_id,
target_node_id: node.node_id,
relation: "contains",
properties: { original_source: original.source },
evidence: artifactObservationEvidence({
sha256: mapFile.sha256,
path: mapFile.path,
operation: "parse-local-source-map",
coverage,
limitations:
sourceMap.limitation === null ? [] : [sourceMap.limitation],
}),
});
}
}
};
@@ -0,0 +1,118 @@
import type { ApplicationGraphEvidence } from "../../domain/javascriptApplicationEvidenceSchemas.js";
import type { JavaScriptSourceRange } from "../../domain/javascriptStaticAnalysisTypes.js";
const EXTRACTOR = {
name: "rea-javascript-artifact-reconstruction",
version: "1",
executable_sha256: null,
} as const;
/** Coverage shape accepted by reconstruction evidence helpers. */
export type ReconstructionEvidenceCoverage =
ApplicationGraphEvidence["coverage"];
/** Shared exact artifact location and bounded coverage for static evidence. */
export interface EvidenceInput {
readonly sha256: string;
readonly path: string;
readonly operation: string;
readonly coverage: ReconstructionEvidenceCoverage;
readonly limitations?: readonly string[];
readonly range?: JavaScriptSourceRange;
}
/** Exact artifact-byte evidence for one safely inventoried local file. */
export const artifactObservationEvidence = (
input: EvidenceInput,
): ApplicationGraphEvidence => ({
authority: "artifact-bytes",
state: "observed",
confidence: "exact",
artifact: artifactReference(input.sha256),
location: {
available: true,
value: { kind: "artifact-path", path: input.path },
},
extractor: { ...EXTRACTOR, operation: input.operation },
coverage: input.coverage,
limitations: [...(input.limitations ?? [])],
evidence_ids: [],
});
/** Exact AST syntax evidence with an actionable source range. */
export const astObservationEvidence = (
input: EvidenceInput & { readonly range: JavaScriptSourceRange },
): ApplicationGraphEvidence => {
const limitations = [
...(input.limitations ?? []),
...(input.coverage.status === "complete"
? []
: [
"AST coverage is incomplete; omitted or dynamic facts must not be interpreted as absence.",
]),
];
return {
authority: "ast-static-analysis",
state: "observed",
confidence: "exact",
artifact: artifactReference(input.sha256),
location: {
available: true,
value: { kind: "source-range", source: input.path, ...input.range },
},
extractor: { ...EXTRACTOR, operation: input.operation },
coverage: input.coverage,
limitations,
evidence_ids: [],
};
};
/** Explicit static relationship inference; never promoted to observation. */
export const staticInferenceEvidence = (
input: EvidenceInput & {
readonly confidence?: "high" | "medium" | "low";
},
): ApplicationGraphEvidence => ({
authority: "static-relationship-inference",
state: "inferred",
confidence: input.confidence ?? "high",
artifact: artifactReference(input.sha256),
location:
input.range === undefined
? { available: true, value: { kind: "artifact-path", path: input.path } }
: {
available: true,
value: { kind: "source-range", source: input.path, ...input.range },
},
extractor: { ...EXTRACTOR, operation: input.operation },
coverage: input.coverage,
limitations: [
"Static syntax does not prove that this relationship executes at runtime.",
...(input.limitations ?? []),
],
evidence_ids: [],
});
/** Unavailable AST fact that preserves a parse, approval, or limit gap. */
export const unavailableAstEvidence = (
input: EvidenceInput & { readonly limitation: string },
): ApplicationGraphEvidence => ({
authority: "ast-static-analysis",
state: "unavailable",
confidence: "unknown",
artifact: artifactReference(input.sha256),
location: {
available: true,
value: { kind: "artifact-path", path: input.path },
},
extractor: { ...EXTRACTOR, operation: input.operation },
coverage: input.coverage,
limitations: [input.limitation, ...(input.limitations ?? [])],
evidence_ids: [],
});
const artifactReference = (sha256: string) => ({
available: true as const,
artifact_id: `art_${sha256}`,
sha256,
});
@@ -0,0 +1,433 @@
import type { ApplicationNode } from "../../domain/javascriptApplicationGraph.js";
import type { JavaScriptStaticAnalysis } from "../../domain/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import {
addStaticInferenceEdge,
artifactLocalIdentity,
chunkLookupKey,
createElectronRoleNode,
javascriptAnalysisCoverage,
linkElectronRoleToAsset,
moduleLookupKey,
resolveArtifactPath,
sourceNodeFor,
type JavaScriptArtifactGraphContext,
type JavaScriptArtifactGraphCoverage,
} from "./JavaScriptArtifactGraphContext.js";
import {
astObservationEvidence,
staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
type StaticReference = JavaScriptStaticAnalysis["references"][number];
type StaticEndpoint = JavaScriptStaticAnalysis["endpoints"][number];
type StaticStorage = JavaScriptStaticAnalysis["storage"][number];
type StaticRolePath = JavaScriptStaticAnalysis["role_paths"][number];
type StaticSourceMap = JavaScriptStaticAnalysis["source_map_urls"][number];
interface FindingInput<Value> {
readonly file: JavaScriptArtifactFile;
readonly asset: ApplicationNode;
readonly value: Value;
readonly coverage: JavaScriptArtifactGraphCoverage;
}
interface ResolvedReference {
readonly path: string;
readonly file: JavaScriptArtifactFile;
readonly node: ApplicationNode;
}
/** Project imports, workers, roles, endpoints, storage, and source-map links. */
export const addJavaScriptStaticFindings = (
context: JavaScriptArtifactGraphContext,
): void => {
for (const analyzed of context.analysis.files) {
const { file, javascript } = analyzed;
const asset = context.assetNodes.get(file.path);
if (javascript === null || asset === undefined) continue;
const coverage = javascriptAnalysisCoverage(javascript);
for (const value of javascript.references)
addReference(context, { file, asset, value, coverage });
for (const value of javascript.endpoints)
addEndpoint(context, { file, asset, value, coverage });
for (const value of javascript.storage)
addStorage(context, { file, asset, value, coverage });
for (const value of javascript.role_paths)
addDiscoveredRole(context, { file, asset, value, coverage });
for (const value of javascript.source_map_urls)
addSourceMapEdge(context, { file, asset, value, coverage });
}
};
const addReference = (
context: JavaScriptArtifactGraphContext,
input: FindingInput<StaticReference>,
): void => {
const source =
sourceNodeFor(context, input.file.path, input.value.module_key) ??
input.asset;
const resolved =
input.value.specifier === null
? null
: resolveReference(context, input.file.path, input.value);
if (input.value.kind === "worker" || input.value.kind === "service-worker") {
const worker = context.accumulator.addNode({
kind: input.value.kind === "worker" ? "worker" : "service-worker",
identity: artifactLocalIdentity(
input.file.sha256,
input.value.kind,
input.value.specifier ?? input.value.expression,
),
observations: [
{
label: input.value.specifier,
properties: {
declared_specifier: input.value.specifier,
expression: input.value.expression,
resolved_path: resolved?.path ?? null,
},
evidence: staticInferenceEvidence({
sha256: input.file.sha256,
path: input.file.path,
range: input.value.location,
operation: "discover-worker",
coverage: input.coverage,
}),
},
],
});
addStaticInferenceEdge(context, {
source,
target: worker,
file: input.file,
range: input.value.location,
coverage: input.coverage,
relation: "loads",
properties: { kind: input.value.kind },
});
if (resolved !== null)
addStaticInferenceEdge(context, {
source: worker,
target: resolved.node,
file: input.file,
range: input.value.location,
coverage: input.coverage,
relation: "maps_to",
properties: { resolved_path: resolved.path },
});
return;
}
const target =
resolved?.node ??
unresolvedReferenceNode(context, input.file, input.value, input.coverage);
addStaticInferenceEdge(context, {
source,
target,
file: input.file,
range: input.value.location,
coverage: input.coverage,
relation: resolved?.file.kind === "native-addon" ? "loads" : "imports",
properties: {
kind: input.value.kind,
specifier: input.value.specifier,
expression: input.value.expression,
resolved_path: resolved?.path ?? null,
},
});
};
const addEndpoint = (
context: JavaScriptArtifactGraphContext,
input: FindingInput<StaticEndpoint>,
): void => {
const source =
sourceNodeFor(context, input.file.path, input.value.module_key) ??
input.asset;
const node = context.accumulator.addNode({
kind: "endpoint",
identity: artifactLocalIdentity(
input.file.sha256,
input.value.kind,
input.value.value,
),
observations: [
{
label: input.value.value,
properties: {
endpoint_kind: input.value.kind,
value: input.value.value,
mechanism: input.value.mechanism,
},
evidence: astObservationEvidence({
sha256: input.file.sha256,
path: input.file.path,
range: input.value.location,
operation: "discover-static-endpoint",
coverage: input.coverage,
}),
},
],
});
addStaticInferenceEdge(context, {
source,
target: node,
file: input.file,
range: input.value.location,
coverage: input.coverage,
relation: input.value.kind === "route" ? "exposes" : "calls",
properties: { mechanism: input.value.mechanism },
});
};
const addStorage = (
context: JavaScriptArtifactGraphContext,
input: FindingInput<StaticStorage>,
): void => {
const source =
sourceNodeFor(context, input.file.path, input.value.module_key) ??
input.asset;
const node = context.accumulator.addNode({
kind: "storage",
identity: artifactLocalIdentity(
input.file.sha256,
input.value.kind,
input.value.name ?? input.value.mechanism,
),
observations: [
{
label: input.value.name ?? input.value.kind,
properties: {
storage_kind: input.value.kind,
name: input.value.name,
mechanism: input.value.mechanism,
},
evidence: astObservationEvidence({
sha256: input.file.sha256,
path: input.file.path,
range: input.value.location,
operation: "discover-static-storage",
coverage: input.coverage,
}),
},
],
});
addStaticInferenceEdge(context, {
source,
target: node,
file: input.file,
range: input.value.location,
coverage: input.coverage,
relation: "persists_to",
properties: { mechanism: input.value.mechanism },
});
};
const addDiscoveredRole = (
context: JavaScriptArtifactGraphContext,
input: FindingInput<StaticRolePath>,
): void => {
const resolution = resolveArtifactPathByContext({
declaredPath: input.value.path,
sourcePath: input.file.path,
context: input.value.resolution_context,
files: context.filesByPath,
});
const role = createElectronRoleNode(context, {
kind:
input.value.role === "preload" ? "electron-preload" : "electron-renderer",
anchor: input.file,
resolution,
mechanism: input.value.mechanism,
range: input.value.location,
coverage: input.coverage,
});
const source =
sourceNodeFor(context, input.file.path, input.value.module_key) ??
input.asset;
addStaticInferenceEdge(context, {
source,
target: role,
file: input.file,
range: input.value.location,
coverage: input.coverage,
relation: "loads",
properties: { mechanism: input.value.mechanism },
});
linkElectronRoleToAsset(context, {
role,
anchor: input.file,
resolution,
range: input.value.location,
coverage: input.coverage,
});
};
const addSourceMapEdge = (
context: JavaScriptArtifactGraphContext,
input: FindingInput<StaticSourceMap>,
): void => {
const resolvedPath = resolveArtifactPath(
input.value.declared_url,
input.file.path,
context.filesByPath,
);
const resolvedFile =
resolvedPath === null ? undefined : context.filesByPath.get(resolvedPath);
const resolvedNode =
resolvedPath === null ? undefined : context.fileNodes.get(resolvedPath);
const target =
resolvedFile?.kind === "source-map" && resolvedNode !== undefined
? resolvedNode
: context.accumulator.addNode({
kind: "source-map",
identity: artifactLocalIdentity(
input.file.sha256,
"unresolved-source-map",
input.value.declared_url,
),
observations: [
{
label: input.value.declared_url,
properties: {
declared_url: input.value.declared_url,
resolved_path: resolvedPath,
available: false,
},
evidence: staticInferenceEvidence({
sha256: input.file.sha256,
path: input.file.path,
range: input.value.location,
operation: "discover-source-map",
coverage: input.coverage,
confidence: "medium",
limitations: [
"The declared source map was not present in the inventoried artifact.",
],
}),
},
],
});
addStaticInferenceEdge(context, {
source: input.asset,
target,
file: input.file,
range: input.value.location,
coverage: input.coverage,
relation: "maps_to",
properties: {
declared_url: input.value.declared_url,
resolved_path: resolvedPath,
},
});
};
const resolveReference = (
context: JavaScriptArtifactGraphContext,
sourcePath: string,
reference: StaticReference,
): ResolvedReference | null => {
const specifier = reference.specifier;
if (specifier === null) return null;
const chunk = resolveBundlerChunkReference(context, sourcePath, reference);
if (chunk !== null) return chunk;
const module = context.moduleNodes.get(
moduleLookupKey(sourcePath, specifier),
);
if (module !== undefined) {
const file = context.filesByPath.get(sourcePath);
return file === undefined
? null
: { path: `${sourcePath}#module:${specifier}`, file, node: module };
}
const path = resolveArtifactPath(
specifier,
sourcePath,
context.filesByPath,
reference.kind === "require"
? "require"
: reference.kind === "static-import" ||
reference.kind === "dynamic-import"
? "import"
: undefined,
);
if (path === null) return null;
const file = context.filesByPath.get(path);
const node = context.fileNodes.get(path);
return file === undefined || node === undefined ? null : { path, file, node };
};
const resolveBundlerChunkReference = (
context: JavaScriptArtifactGraphContext,
sourcePath: string,
reference: StaticReference,
): ResolvedReference | null => {
const key = reference.specifier?.startsWith("chunk:")
? reference.specifier.slice("chunk:".length)
: null;
if (key === null) return null;
const file = context.filesByPath.get(sourcePath);
if (file === undefined) return null;
const registrations = context.analysis.files
.find(({ file: analyzedFile }) => analyzedFile.path === sourcePath)
?.javascript?.bundler_registrations.filter(({ modules }) =>
reference.module_key === null
? true
: modules.some(
({ module_key: moduleKey }) => moduleKey === reference.module_key,
),
);
if (registrations === undefined) return null;
const candidates = registrations.flatMap(({ runtime }) => {
const node = context.chunkNodes.get(
chunkLookupKey(sourcePath, runtime, key),
);
return node === undefined ? [] : [node];
});
const unique = [
...new Map(candidates.map((node) => [node.node_id, node])).values(),
];
if (unique.length !== 1) return null;
const node = unique[0];
return node === undefined
? null
: { path: `${sourcePath}#chunk:${key}`, file, node };
};
const unresolvedReferenceNode = (
context: JavaScriptArtifactGraphContext,
file: JavaScriptArtifactFile,
reference: StaticReference,
coverage: JavaScriptArtifactGraphCoverage,
): ApplicationNode =>
context.accumulator.addNode({
kind: "javascript-module",
identity: artifactLocalIdentity(
file.sha256,
"unresolved-reference",
`${reference.kind}:${reference.specifier ?? reference.expression}`,
),
observations: [
{
label: reference.specifier,
properties: {
reference_kind: reference.kind,
specifier: reference.specifier,
expression: reference.expression,
resolution: "unresolved",
},
evidence: staticInferenceEvidence({
sha256: file.sha256,
path: file.path,
range: reference.location,
operation: "resolve-static-reference",
coverage,
confidence: "low",
limitations: [
"The static reference could not be resolved within the inventoried artifact.",
],
}),
},
],
});
@@ -0,0 +1,400 @@
import type { ArtifactInventorySnapshot } from "../ArtifactInventory.js";
import type { ApplicationNode } from "../../domain/javascriptApplicationGraph.js";
import { completeApplicationCoverage } from "../../domain/javascriptApplicationEvidenceSchemas.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type {
JavaScriptArtifactContainer,
JavaScriptArtifactFile,
} from "./JavaScriptArtifactFiles.js";
import type { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGraphAccumulator.js";
import {
addArtifactContainsEdge,
addUnavailableStaticParseScope,
artifactFileNodeKind,
artifactLocalIdentity,
createElectronRoleNode,
linkElectronRoleToAsset,
type JavaScriptArtifactGraphContext,
} from "./JavaScriptArtifactGraphContext.js";
import {
artifactObservationEvidence,
staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
export { addJavaScriptBundlerNodes } from "./JavaScriptArtifactGraphBundlers.js";
interface PackageRoleInput {
readonly packageNode: ApplicationNode;
readonly packageFile: JavaScriptArtifactFile;
readonly kind: "electron-main" | "electron-renderer";
readonly declaredPath: string | null;
}
/** Create the exact root artifact node from the inventory manifest. */
export const createJavaScriptArtifactRootNode = (
accumulator: JavaScriptArtifactGraphAccumulator,
snapshot: ArtifactInventorySnapshot,
): ApplicationNode => {
const root = snapshot.nodes.find(
({ artifact_id: id }) => id === snapshot.manifest.root_artifact_id,
);
if (root === undefined)
throw new TypeError("Artifact inventory root is missing");
return accumulator.addNode({
kind: "artifact",
identity: {
strategy: "content-digest",
stability: "global-exact",
sha256: root.sha256,
},
observations: [
{
label: "artifact root",
properties: {
format: snapshot.manifest.root_format,
bytes: root.size,
inventory_manifest_id: snapshot.manifest.manifest_id,
inventory_graph_sha256: snapshot.manifest.graph_sha256,
inventory_artifact_id: root.artifact_id,
},
evidence: artifactObservationEvidence({
sha256: root.sha256,
path: "artifact-root",
operation: "inventory-root",
coverage: completeApplicationCoverage(),
limitations: [
"artifact-root is a path-independent alias; the application result retains the canonical local input path.",
],
}),
},
],
});
};
/** Project nested ASAR containers inventoried inside a directory. */
export const addJavaScriptArtifactContainers = (
context: JavaScriptArtifactGraphContext,
): void => {
for (const container of context.fileSet.containers) {
const node = context.accumulator.addNode({
kind: "artifact",
identity: {
strategy: "content-digest",
stability: "global-exact",
sha256: container.sha256,
},
observations: [
{
label: container.path,
properties: containerProperties(container),
evidence: artifactObservationEvidence({
sha256: container.sha256,
path: container.path,
operation: "inventory-nested-asar",
coverage: completeApplicationCoverage(),
}),
},
],
});
context.containerNodes.set(container.sha256, node);
context.accumulator.addEdge({
source_node_id: context.root.node_id,
target_node_id: node.node_id,
relation: "contains",
properties: { path: container.path, format: "asar" },
evidence: artifactObservationEvidence({
sha256: context.snapshot.manifest.root_sha256,
path: container.path,
operation: "inventory-nested-asar",
coverage: completeApplicationCoverage(),
}),
});
}
};
/** Project relevant files, ASAR entries, and explicit unavailable parse scopes. */
export const addJavaScriptArtifactFiles = (
context: JavaScriptArtifactGraphContext,
): void => {
for (const analyzed of context.analysis.files) {
const { file } = analyzed;
const target = createFileTarget(context, file, analyzed.javascript);
context.fileNodes.set(file.path, target);
if (file.kind === "javascript") context.assetNodes.set(file.path, target);
const entry = createAsarEntry(context, file);
const parent =
context.containerNodes.get(file.container_sha256) ?? context.root;
if (entry === undefined)
addArtifactContainsEdge(context, {
source: parent,
target,
file,
operation: "inventory-file",
});
else {
addArtifactContainsEdge(context, {
source: parent,
target: entry,
file,
operation: "inventory-entry",
});
context.accumulator.addEdge({
source_node_id: entry.node_id,
target_node_id: target.node_id,
relation: "maps_to",
properties: { sha256: file.sha256 },
evidence: artifactObservationEvidence({
sha256: file.container_sha256,
path: file.path,
operation: "map-entry-content",
coverage: completeApplicationCoverage(),
}),
});
}
if (
file.kind === "javascript" &&
(analyzed.javascript === null ||
analyzed.javascript.parse_status === "failed")
)
addUnavailableStaticParseScope(context, {
file,
asset: target,
operation: "parse-javascript",
limitation: file.text.included
? "JavaScript syntax could not be parsed."
: `JavaScript text was unavailable: ${file.text.reason}.`,
});
const packageValue = context.analysis.packages.find(
({ path }) => path === file.path,
);
if (packageValue !== undefined && packageValue.status !== "included")
addUnavailableStaticParseScope(context, {
file,
asset: target,
operation: "parse-package-json",
limitation: packageValue.limitation,
});
const jsonValue = context.analysis.json_modules.find(
({ path }) => path === file.path,
);
if (jsonValue !== undefined && jsonValue.status !== "included")
addUnavailableStaticParseScope(context, {
file,
asset: target,
operation: "parse-json-module",
limitation: jsonValue.limitation,
});
const sourceMap = context.analysis.source_maps.find(
({ path }) => path === file.path,
);
if (sourceMap !== undefined && sourceMap.status === "invalid")
addUnavailableStaticParseScope(context, {
file,
asset: target,
operation: "parse-local-source-map",
limitation: sourceMap.limitation,
});
}
};
/** Project package metadata and its declared Electron roles. */
export const addJavaScriptPackageNodes = (
context: JavaScriptArtifactGraphContext,
): ApplicationNode[] => {
const roots: ApplicationNode[] = [];
for (const packageValue of context.analysis.packages) {
const file = context.filesByPath.get(packageValue.path);
if (file === undefined) continue;
const node = context.accumulator.addNode({
kind: "package",
identity: artifactLocalIdentity(file.sha256, "package-json", file.path),
observations: [
{
label: packageValue.name ?? file.path,
properties: {
path: file.path,
name: packageValue.name,
version: packageValue.version,
main: packageValue.main,
renderer: packageValue.renderer,
parse_status: packageValue.status,
},
evidence: artifactObservationEvidence({
sha256: file.sha256,
path: file.path,
operation: "parse-package-json",
coverage: completeApplicationCoverage(),
limitations:
packageValue.limitation === null ? [] : [packageValue.limitation],
}),
},
],
});
if (roots.length === 0) {
roots.push(node);
context.accumulator.addEdge({
source_node_id: node.node_id,
target_node_id: context.root.node_id,
relation: "contains",
properties: { basis: "package-metadata-within-artifact" },
evidence: staticInferenceEvidence({
sha256: file.sha256,
path: file.path,
operation: "associate-package-artifact",
coverage: completeApplicationCoverage(),
}),
});
} else
addArtifactContainsEdge(context, {
source: context.root,
target: node,
file,
operation: "inventory-package",
});
addPackageRole(context, {
packageNode: node,
packageFile: file,
kind: "electron-main",
declaredPath: packageValue.main,
});
addPackageRole(context, {
packageNode: node,
packageFile: file,
kind: "electron-renderer",
declaredPath: packageValue.renderer,
});
}
return roots;
};
const createFileTarget = (
context: JavaScriptArtifactGraphContext,
file: JavaScriptArtifactFile,
javascript: JavaScriptArtifactAnalysis["files"][number]["javascript"],
): ApplicationNode => {
const kind = artifactFileNodeKind(file.kind);
const json = context.analysis.json_modules.find(
({ path }) => path === file.path,
);
return context.accumulator.addNode({
kind,
identity: {
strategy: "content-digest",
stability: "global-exact",
sha256: file.sha256,
},
observations: [
{
label: file.path,
properties: {
path: file.path,
bytes: file.bytes,
inventory_artifact_id: file.inventory_artifact_id,
unpacked: file.unpacked,
file_kind: file.kind,
text_status: file.text.included ? "included" : file.text.reason,
parse_status: javascript?.parse_status ?? null,
json_parse_status: json?.status ?? null,
json_top_level_keys: json?.top_level_keys ?? [],
omitted_json_top_level_keys: json?.omitted_top_level_keys ?? 0,
vendor_markers: javascript?.vendors ?? [],
},
evidence: artifactObservationEvidence({
sha256: file.sha256,
path: file.path,
operation: "inventory-relevant-file",
coverage: completeApplicationCoverage(),
}),
},
],
});
};
const createAsarEntry = (
context: JavaScriptArtifactGraphContext,
file: JavaScriptArtifactFile,
): ApplicationNode | undefined => {
const isAsar =
context.snapshot.manifest.root_format === "asar" ||
file.container_sha256 !== context.snapshot.manifest.root_sha256;
if (!isAsar) return undefined;
return context.accumulator.addNode({
kind: "asar-entry",
identity: {
strategy: "canonical-path",
stability: "artifact-version",
artifact_sha256: file.container_sha256,
path: file.path,
},
observations: [
{
label: file.path,
properties: {
entry_sha256: file.sha256,
bytes: file.bytes,
unpacked: file.unpacked,
inventory_artifact_id: file.inventory_artifact_id,
},
evidence: artifactObservationEvidence({
sha256: file.container_sha256,
path: file.path,
operation: "inventory-asar-entry",
coverage: completeApplicationCoverage(),
}),
},
],
});
};
const addPackageRole = (
context: JavaScriptArtifactGraphContext,
input: PackageRoleInput,
): void => {
if (input.declaredPath === null) return;
const resolution = resolveArtifactPathByContext({
declaredPath: input.declaredPath,
sourcePath: input.packageFile.path,
context: "package-entrypoint",
files: context.filesByPath,
});
const role = createElectronRoleNode(context, {
kind: input.kind,
anchor: input.packageFile,
resolution,
mechanism:
input.kind === "electron-main"
? "package.json:main"
: "package.json:renderer",
});
context.accumulator.addEdge({
source_node_id: input.packageNode.node_id,
target_node_id: role.node_id,
relation: "loads",
properties: {
declared_path: input.declaredPath,
resolution_context: resolution.resolution_context,
resolved_path: resolution.resolved_path,
resolution_status: resolution.resolution_status,
limitations: resolution.limitations,
},
evidence: staticInferenceEvidence({
sha256: input.packageFile.sha256,
path: input.packageFile.path,
operation: "discover-package-entrypoint",
coverage: completeApplicationCoverage(),
limitations: resolution.limitations,
}),
});
linkElectronRoleToAsset(context, {
role,
anchor: input.packageFile,
resolution,
});
};
const containerProperties = (container: JavaScriptArtifactContainer) => ({
path: container.path,
format: "asar",
bytes: container.bytes,
inventory_artifact_id: container.inventory_artifact_id,
});
@@ -0,0 +1,587 @@
import { describe, expect, it } from "vitest";
import {
resolveArtifactPathByContext,
type ResolveArtifactPathInput,
} from "./JavaScriptArtifactPathResolution.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import { artifactLocalIdentity } from "./JavaScriptArtifactGraphContext.js";
import { applicationNodeIdentitySchema } from "../../domain/javascriptApplicationEvidenceSchemas.js";
import { analyzeJavaScriptStaticSource } from "../../domain/javascriptStaticAnalysis.js";
describe("artifact-local graph identity", () => {
it("preserves complete long namespace and key values", () => {
const digest = "a".repeat(64);
const namespacePrefix = "n".repeat(512);
const keyPrefix = "k".repeat(4_096);
const first = artifactLocalIdentity(
digest,
`${namespacePrefix}a`,
`${keyPrefix}a`,
);
const second = artifactLocalIdentity(
digest,
`${namespacePrefix}b`,
`${keyPrefix}b`,
);
expect(applicationNodeIdentitySchema.parse(first)).toEqual(first);
expect(applicationNodeIdentitySchema.parse(second)).toEqual(second);
expect(first).not.toEqual(second);
});
});
describe("contextual JavaScript artifact path resolution", () => {
it.each([
["main.js", "app/main.js"],
["./main.js", "app/main.js"],
["src/main", "app/src/main.cjs"],
["entry", "app/entry/start.mjs"],
["fallback", "app/fallback/index.ts"],
])("resolves package entrypoint %s", (declaredPath, expected) => {
const files = fileMap([
file("app/package.json", "root"),
file("app/main.js", "root"),
file("app/src/main.cjs", "root"),
file("app/entry/package.json", "root", '{"main":"start"}'),
file("app/entry/start.mjs", "root"),
file("app/fallback/index.ts", "root"),
]);
expect(
resolve({
declaredPath,
sourcePath: "app/package.json",
context: "package-entrypoint",
files,
}),
).toMatchObject({
resolution_context: "package-entrypoint",
resolution_status: "resolved",
resolved_path: expected,
limitations: [],
});
});
it("resolves dirname-derived bare paths but keeps bare module specifiers external", () => {
const files = fileMap([
file("app/main.js", "root"),
file("app/preload.js", "root"),
]);
const base = {
declaredPath: "preload.js",
sourcePath: "app/main.js",
files,
} as const;
expect(
resolve({ ...base, context: "filesystem-expression" }),
).toMatchObject({
resolution_status: "resolved",
resolved_path: "app/preload.js",
});
expect(resolve({ ...base, context: "module-specifier" })).toMatchObject({
resolution_status: "external",
resolved_path: null,
});
});
it("applies document and base-href semantics to HTML references", () => {
const files = fileMap([
file("renderer/index.html", "root"),
file("assets/app.js", "root"),
file("renderer/local.js", "root"),
]);
expect(
resolve({
declaredPath: "app.js?v=1",
sourcePath: "renderer/index.html",
context: "html-reference",
htmlBaseHref: "/assets/",
files,
}),
).toMatchObject({
resolution_status: "resolved",
resolved_path: "assets/app.js",
});
expect(
resolve({
declaredPath: "./local.js",
sourcePath: "renderer/index.html",
context: "html-reference",
files,
}),
).toMatchObject({
resolution_status: "resolved",
resolved_path: "renderer/local.js",
});
expect(
resolve({
declaredPath: "file:///assets/app.js#entry",
sourcePath: "main.js",
context: "module-specifier",
files,
}),
).toMatchObject({
resolution_status: "resolved",
resolved_path: "assets/app.js",
});
});
it.each(["node:fs", "electron", "../../escape.js", "%2e%2e/escape.js"])(
"fails closed for unsafe or external module declaration %s",
(declaredPath) => {
const result = resolve({
declaredPath,
sourcePath: "app/main.js",
context: "module-specifier",
files: fileMap([file("app/main.js", "root")]),
});
expect(result.resolved_path).toBeNull();
expect(["external", "rejected"]).toContain(result.resolution_status);
expect(result.limitations.length).toBeGreaterThan(0);
},
);
it("does not cross nested ASAR container identities", () => {
const files = fileMap([
file("app.asar/main.js", "asar-a"),
file("app.asar/preload.js", "asar-b"),
]);
expect(
resolve({
declaredPath: "preload.js",
sourcePath: "app.asar/main.js",
context: "filesystem-expression",
files,
}),
).toMatchObject({ resolution_status: "not-found", resolved_path: null });
});
it("resolves inventoried paths longer than the former character ceiling", () => {
const declaredPath = `./${"a".repeat(5_000)}.js`;
const expectedPath = `app/${declaredPath.slice(2)}`;
const files = fileMap([
file("app/consumer.js", "root"),
file(expectedPath, "root"),
]);
expect(
resolve({
declaredPath,
sourcePath: "app/consumer.js",
context: "module-specifier",
files,
}),
).toMatchObject({
resolution_status: "resolved",
resolved_path: expectedPath,
limitations: [],
});
});
});
describe("contextual JavaScript package path resolution", () => {
it("follows package entrypoints beyond the former recursion ceiling", () => {
const files = fileMap([
file("app/consumer.js", "root"),
file(
"app/node_modules/pkg-1/package.json",
"root",
'{"main":"../pkg-2"}',
),
file(
"app/node_modules/pkg-2/package.json",
"root",
'{"main":"../pkg-3"}',
),
file(
"app/node_modules/pkg-3/package.json",
"root",
'{"main":"../pkg-4"}',
),
file(
"app/node_modules/pkg-4/package.json",
"root",
'{"main":"../pkg-5"}',
),
file(
"app/node_modules/pkg-5/package.json",
"root",
'{"main":"../pkg-6"}',
),
file(
"app/node_modules/pkg-6/package.json",
"root",
'{"main":"entry.js"}',
),
file("app/node_modules/pkg-6/entry.js", "root"),
]);
expect(
resolve({
declaredPath: "pkg-1",
sourcePath: "app/consumer.js",
context: "module-specifier",
files,
}),
).toMatchObject({
resolution_status: "resolved",
resolved_path: "app/node_modules/pkg-6/entry.js",
limitations: [],
});
});
it("keeps an inventoried but unreadable directory package unavailable", () => {
const files = fileMap([
file("app/package.json", "root"),
file("app/entry/package.json", "root", null),
]);
expect(
resolve({
declaredPath: "entry",
sourcePath: "app/package.json",
context: "package-entrypoint",
files,
}),
).toMatchObject({
resolution_status: "unavailable",
resolved_path: null,
limitations: [expect.stringContaining("text is unavailable")],
});
});
it("resolves nearest inventoried bare packages with import/require conditions", () => {
const files = fileMap([
file("app/src/consumer.js", "root"),
file(
"app/node_modules/fixture/package.json",
"root",
JSON.stringify({
exports: {
".": {
import: "./esm.mjs",
require: "./cjs.cjs",
default: "./fallback.js",
},
},
}),
),
file("app/node_modules/fixture/esm.mjs", "root"),
file("app/node_modules/fixture/cjs.cjs", "root"),
file("app/node_modules/fixture/fallback.js", "root"),
file("app/node_modules/fixture/hidden.js", "root"),
file("app/node_modules/fs/index.js", "root"),
]);
expect(
resolve({
declaredPath: "fixture",
sourcePath: "app/src/consumer.js",
context: "module-specifier",
moduleKind: "import",
files,
}),
).toMatchObject({
resolution_status: "resolved",
resolved_path: "app/node_modules/fixture/esm.mjs",
});
expect(
resolve({
declaredPath: "fixture",
sourcePath: "app/src/consumer.js",
context: "module-specifier",
moduleKind: "require",
files,
}),
).toMatchObject({
resolution_status: "resolved",
resolved_path: "app/node_modules/fixture/cjs.cjs",
});
expect(
resolve({
declaredPath: "fs",
sourcePath: "app/src/consumer.js",
context: "module-specifier",
moduleKind: "require",
files,
}),
).toMatchObject({ resolution_status: "external", resolved_path: null });
expect(
resolve({
declaredPath: "fixture/hidden.js",
sourcePath: "app/src/consumer.js",
context: "module-specifier",
moduleKind: "import",
files,
}),
).toMatchObject({
resolution_status: "external",
resolved_path: null,
limitations: [expect.stringContaining("subpaths remain unresolved")],
});
});
});
describe("directory package entrypoint precedence", () => {
it.each([
["require", '{"main":"actual.cjs"}', "actual.cjs"],
["require", '{"exports":{"require":"./actual.cjs"}}', "actual.cjs"],
["import", '{"exports":{"import":"./actual.mjs"}}', "actual.mjs"],
] as const)(
"prefers declared %s entrypoints over index files",
(moduleKind, metadata, entry) => {
const files = fileMap([
file("app/consumer.js", "root"),
file("app/node_modules/fixture/package.json", "root", metadata),
file(`app/node_modules/fixture/${entry}`, "root"),
file("app/node_modules/fixture/index.js", "root"),
]);
expect(
resolve({
declaredPath: "fixture",
sourcePath: "app/consumer.js",
context: "module-specifier",
moduleKind,
files,
}),
).toMatchObject({
resolution_status: "resolved",
resolved_path: `app/node_modules/fixture/${entry}`,
});
},
);
it.each(["{}", '{"main":"."}'])(
"preserves index fallback for package metadata %s",
(metadata) => {
const files = fileMap([
file("app/consumer.js", "root"),
file("app/entry/package.json", "root", metadata),
file("app/entry/index.js", "root"),
]);
expect(
resolve({
declaredPath: "./entry",
sourcePath: "app/consumer.js",
context: "module-specifier",
moduleKind: "require",
files,
}),
).toMatchObject({
resolution_status: "resolved",
resolved_path: "app/entry/index.js",
});
},
);
it.each([null, "{"])(
"keeps unreadable or invalid metadata explicit even when an index exists",
(metadata) => {
const files = fileMap([
file("app/consumer.js", "root"),
file("app/entry/package.json", "root", metadata),
file("app/entry/index.js", "root"),
]);
expect(
resolve({
declaredPath: "./entry",
sourcePath: "app/consumer.js",
context: "module-specifier",
moduleKind: "require",
files,
}),
).toMatchObject({
resolution_status: "unavailable",
resolved_path: null,
});
},
);
it("does not replace a missing exports target with an index", () => {
const files = fileMap([
file("app/consumer.js", "root"),
file(
"app/node_modules/fixture/package.json",
"root",
'{"exports":"./missing.js"}',
),
file("app/node_modules/fixture/index.js", "root"),
]);
expect(
resolve({
declaredPath: "fixture",
sourcePath: "app/consumer.js",
context: "module-specifier",
moduleKind: "require",
files,
}),
).toMatchObject({
resolution_status: "not-found",
resolved_path: null,
});
});
it("keeps direct files ahead of a relative directory package", () => {
const files = fileMap([
file("app/consumer.js", "root"),
file("app/entry.js", "root"),
file("app/entry/package.json", "root", '{"main":"actual.js"}'),
file("app/entry/actual.js", "root"),
file("app/entry/index.js", "root"),
]);
expect(
resolve({
declaredPath: "./entry",
sourcePath: "app/consumer.js",
context: "module-specifier",
moduleKind: "require",
files,
}),
).toMatchObject({
resolution_status: "resolved",
resolved_path: "app/entry.js",
});
});
it("retains cycle diagnostics when directory packages refer to each other", () => {
const files = fileMap([
file("app/consumer.js", "root"),
file("app/a/package.json", "root", '{"main":"../b"}'),
file("app/b/package.json", "root", '{"main":"../a"}'),
]);
expect(
resolve({
declaredPath: "./a",
sourcePath: "app/consumer.js",
context: "module-specifier",
moduleKind: "require",
files,
}),
).toMatchObject({
resolution_status: "unavailable",
resolved_path: null,
limitations: [expect.stringContaining("cycle")],
});
});
});
describe("contextual JavaScript module identity", () => {
it("retains CommonJS and ESM file-base identity during inert extraction", () => {
const analysis = analyzeJavaScriptStaticSource(
`
new BrowserWindow({ webPreferences: {
preload: path.resolve("ignored", __dirname, "preload.cjs")
}});
new BrowserWindow({ webPreferences: {
preload: fileURLToPath(new URL("./preload.mjs", import.meta.url))
}});
new BrowserWindow({ webPreferences: {
preload: path.join(path.dirname(__filename), "legacy-preload.js")
}});
new BrowserWindow({ webPreferences: {
preload: path.posix.resolve("/ignored", dirname(fileURLToPath(import.meta.url)), "esm-preload.js")
}});
new BrowserWindow({ webPreferences: {
preload: path.posix.resolve(__dirname, "/shared", "absolute-preload.js/")
}});
new BrowserWindow({ webPreferences: {
preload: path.posix.resolve(\`/templates\`, "template-preload.js")
}});
new BrowserWindow({ webPreferences: {
preload: new URL("./remote-base.js", location.href)
}});
window.loadURL("file:///renderer/index.html");
`,
);
expect(analysis.role_paths).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: "preload.cjs",
resolution_context: "filesystem-expression",
}),
expect.objectContaining({
path: "./preload.mjs",
resolution_context: "filesystem-expression",
}),
expect.objectContaining({
path: "legacy-preload.js",
resolution_context: "filesystem-expression",
}),
expect.objectContaining({
path: "esm-preload.js",
resolution_context: "filesystem-expression",
}),
expect.objectContaining({
path: "/shared/absolute-preload.js",
resolution_context: "filesystem-expression",
}),
expect.objectContaining({
path: "/templates/template-preload.js",
resolution_context: "filesystem-expression",
}),
expect.objectContaining({
path: "file:///renderer/index.html",
resolution_context: "module-specifier",
}),
]),
);
expect(analysis.electron.browser_windows).toEqual(
expect.arrayContaining([
expect.objectContaining({
preload_path: "preload.cjs",
preload_resolution_context: "filesystem-expression",
}),
expect.objectContaining({
preload_path: "./preload.mjs",
preload_resolution_context: "filesystem-expression",
}),
expect.objectContaining({
preload_path: "esm-preload.js",
preload_resolution_context: "filesystem-expression",
}),
expect.objectContaining({
preload_path: "/shared/absolute-preload.js",
preload_resolution_context: "filesystem-expression",
}),
expect.objectContaining({
preload_path: "/templates/template-preload.js",
preload_resolution_context: "filesystem-expression",
}),
]),
);
expect(analysis.role_paths).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "./remote-base.js" }),
]),
);
});
});
const resolve = (input: ResolveArtifactPathInput) =>
resolveArtifactPathByContext(input);
const fileMap = (files: readonly JavaScriptArtifactFile[]) =>
new Map(files.map((value) => [value.path, value]));
const file = (
path: string,
container: string,
text: string | null = "",
): JavaScriptArtifactFile => ({
path,
container_sha256: container.padEnd(64, "0").slice(0, 64),
sha256: path.padEnd(64, "0").slice(0, 64),
bytes: text?.length ?? 0,
inventory_artifact_id: `artifact-${path}`,
kind: path.endsWith("package.json") ? "package-json" : "javascript",
unpacked: false,
text:
text === null
? { included: false, reason: "invalid-utf8" }
: { included: true, value: text },
});
@@ -0,0 +1,575 @@
import { builtinModules } from "node:module";
import { posix } from "node:path";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import {
admitsCanonicalPathSyntax,
hasScheme,
looksExternal,
stripQueryAndFragment,
} from "../../domain/artifactPathSyntax.js";
type ArtifactPathResolutionContext =
| "package-entrypoint"
| "filesystem-expression"
| "module-specifier"
| "html-reference";
type UnresolvedArtifactPathStatus =
| "not-found"
| "unavailable"
| "external"
| "rejected";
interface ArtifactPathResolutionBase {
readonly declared_path: string;
readonly resolution_context: ArtifactPathResolutionContext;
readonly limitations: readonly string[];
}
/** Explicit outcome of artifact-confined path resolution. */
export type ArtifactPathResolution = ArtifactPathResolutionBase &
(
| {
readonly resolved_path: string;
readonly resolution_status: "resolved";
}
| {
readonly resolved_path: null;
readonly resolution_status: UnresolvedArtifactPathStatus;
}
);
/** Values needed to resolve a declaration without filesystem access. */
export interface ResolveArtifactPathInput {
readonly declaredPath: string;
readonly sourcePath: string;
readonly context: ArtifactPathResolutionContext;
readonly files: ReadonlyMap<string, JavaScriptArtifactFile>;
readonly htmlBaseHref?: string | null;
readonly moduleKind?: "import" | "require";
}
const EXTENSIONS = [
".js",
".cjs",
".mjs",
".ts",
".tsx",
".json",
".html",
".node",
];
const NODE_BUILTINS = new Set(
builtinModules.map((name) => name.replace(/^node:/u, "")),
);
type CandidateResolution =
| {
readonly resolvedPath: string;
readonly status: "resolved";
readonly limitations: readonly string[];
}
| {
readonly resolvedPath: null;
readonly status: UnresolvedArtifactPathStatus;
readonly limitations: readonly string[];
};
/** Resolve one declaration under its exact syntax/metadata context. */
export const resolveArtifactPathByContext = (
input: ResolveArtifactPathInput,
): ArtifactPathResolution => resolvePackagePath(input, new Set());
const resolvePackagePath = (
input: ResolveArtifactPathInput,
packageChain: ReadonlySet<string>,
mode: "full" | "files-and-index" = "full",
): ArtifactPathResolution => {
const rejected = rejectDeclaration(input);
if (rejected !== null) return rejected;
const candidate = contextualCandidate(input);
if (typeof candidate !== "string") return candidate;
const confined = confineCandidate(input, candidate);
if (typeof confined !== "string") return confined;
const resolved =
mode === "files-and-index"
? (resolveFileCandidates(input, [
...fileCandidates(confined),
...indexCandidates(confined),
]) ?? notFoundCandidate())
: resolveCandidate(input, confined, packageChain);
return outcome(input, resolved);
};
const rejectDeclaration = (
input: ResolveArtifactPathInput,
): ArtifactPathResolution | null => {
const declared = input.declaredPath;
if (declared.length === 0)
return unresolvedOutcome(input, "rejected", [
"The declared path is empty.",
]);
if (declared.includes("\0") || declared.includes("\\"))
return unresolvedOutcome(input, "rejected", [
"NUL and backslash path syntax are not admitted for canonical artifact paths.",
]);
if (!admitsCanonicalPathSyntax(declared))
return unresolvedOutcome(input, "rejected", [
"Encoded dot or separator bytes are rejected before artifact path resolution.",
]);
return null;
};
const contextualCandidate = (
input: ResolveArtifactPathInput,
): string | ArtifactPathResolution => {
const { context } = input;
const declared =
context === "html-reference" ||
(context === "module-specifier" && input.moduleKind !== "require")
? stripQueryAndFragment(input.declaredPath)
: input.declaredPath;
if (context === "html-reference") return htmlCandidate(input);
if (context === "module-specifier") {
const fileUrl = fileUrlPath(declared);
if (fileUrl !== undefined) return fileUrl;
if (hasScheme(declared))
return unresolvedOutcome(input, "external", [
"URL and Node builtin schemes are outside static artifact module resolution.",
]);
if (!declared.startsWith(".") && !declared.startsWith("/"))
return bareModuleCandidate(input, declared);
} else if (hasScheme(declared))
return unresolvedOutcome(input, "external", [
"URL schemes are outside this local artifact path context.",
]);
const relative = declared.startsWith("/") ? declared.slice(1) : declared;
return declared.startsWith("/")
? relative
: posix.join(posix.dirname(input.sourcePath), relative);
};
const bareModuleCandidate = (
input: ResolveArtifactPathInput,
declared: string,
): string | ArtifactPathResolution => {
const packageName = barePackageName(declared);
if (
packageName === null ||
NODE_BUILTINS.has(packageName) ||
declared.startsWith("#")
)
return unresolvedOutcome(input, "external", [
"The bare specifier is a Node builtin, package import map, or invalid package name.",
]);
if (declared !== packageName)
return unresolvedOutcome(input, "external", [
"Bare package subpaths remain unresolved unless an exact package-exports subpath model is available.",
]);
const source = input.files.get(input.sourcePath);
let directory = posix.dirname(input.sourcePath);
while (true) {
const candidate = posix.join(directory, "node_modules", declared);
if (hasContainerCandidate(input.files, candidate, source?.container_sha256))
return candidate;
if (directory === "." || directory === "") break;
directory = posix.dirname(directory);
}
return unresolvedOutcome(input, "external", [
"No matching bare package was inventoried in an enclosing node_modules directory.",
]);
};
const barePackageName = (specifier: string): string | null => {
const segments = specifier.split("/");
if (specifier.startsWith("@"))
return segments.length >= 2 && segments[0] !== "" && segments[1] !== ""
? `${segments[0]}/${segments[1]}`
: null;
return segments[0] === "" ? null : (segments[0] ?? null);
};
const hasContainerCandidate = (
files: ReadonlyMap<string, JavaScriptArtifactFile>,
candidate: string,
containerSha256: string | undefined,
): boolean =>
[
...fileCandidates(candidate),
...indexCandidates(candidate),
posix.join(candidate, "package.json"),
].some((path) => {
const file = files.get(path);
return (
file !== undefined &&
(containerSha256 === undefined ||
file.container_sha256 === containerSha256)
);
});
const htmlCandidate = (
input: ResolveArtifactPathInput,
): string | ArtifactPathResolution => {
const declared = stripQueryAndFragment(input.declaredPath);
if (looksExternal(declared))
return unresolvedOutcome(input, "external", [
"External HTML references are not mapped to local artifact assets.",
]);
const rawBase = input.htmlBaseHref;
const base =
rawBase === undefined || rawBase === null
? rawBase
: stripQueryAndFragment(rawBase);
if (base !== undefined && base !== null && looksExternal(base))
return unresolvedOutcome(input, "external", [
"The document base href is external, so its script reference is not a local artifact path.",
]);
if (declared.startsWith("/")) return declared.slice(1);
if (base === undefined || base === null || base === "")
return posix.join(posix.dirname(input.sourcePath), declared);
// A local base href is a second untrusted path input; apply the same
// admission rules a declared path gets so it cannot smuggle traversal or
// separator syntax past canonicalization.
if (!admitsCanonicalPathSyntax(base))
return unresolvedOutcome(input, "rejected", [
"The document base href uses NUL, backslash, or encoded dot and separator bytes that are not admitted for canonical artifact paths.",
]);
const basePath = base.startsWith("/")
? base.slice(1)
: posix.join(posix.dirname(input.sourcePath), base);
return posix.join(htmlBaseDirectory(base, basePath), declared);
};
/**
* The directory a relative HTML reference resolves against. A base path
* ending in "/" or in a "." or ".." segment is already a directory, because a
* relative reference resolves against the base URL's directory and those
* segments are dropped rather than stepped through.
*/
const htmlBaseDirectory = (base: string, basePath: string): string =>
base.endsWith("/") ||
base.endsWith("/.") ||
base.endsWith("/..") ||
base === "." ||
base === ".."
? basePath
: posix.dirname(basePath);
const confineCandidate = (
input: ResolveArtifactPathInput,
candidate: string,
): string | ArtifactPathResolution => {
const normalized = posix.normalize(candidate);
if (
normalized === ".." ||
normalized.startsWith("../") ||
normalized.startsWith("/")
)
return unresolvedOutcome(input, "rejected", [
"The resolved candidate escapes the canonical artifact root.",
]);
return normalized === "." ? "" : normalized;
};
const resolveCandidate = (
input: ResolveArtifactPathInput,
candidate: string,
packageChain: ReadonlySet<string>,
): CandidateResolution => {
const direct = resolveFileCandidates(input, fileCandidates(candidate));
if (direct !== null) return direct;
const source = input.files.get(input.sourcePath);
const packagePath = posix.join(candidate, "package.json");
const packageFile = input.files.get(packagePath);
if (
packageFile === undefined ||
(source !== undefined &&
packageFile.container_sha256 !== source.container_sha256)
)
return (
resolveFileCandidates(input, indexCandidates(candidate)) ??
notFoundCandidate()
);
if (!packageFile.text.included)
return {
resolvedPath: null,
status: "unavailable",
limitations: [
`Directory package metadata ${packagePath} was inventoried but its text is unavailable: ${packageFile.text.reason}.`,
],
};
const main = packageEntry(
packageFile.text.value,
input.moduleKind,
input.context === "module-specifier" &&
!input.declaredPath.startsWith(".") &&
!input.declaredPath.startsWith("/") &&
!hasScheme(input.declaredPath),
);
if (main.status === "invalid")
return {
resolvedPath: null,
status: "unavailable",
limitations: [
`Directory package metadata ${packagePath} is not valid package JSON.`,
],
};
if (main.status === "unmatched")
return {
resolvedPath: null,
status: "external",
limitations: [
`Directory package metadata ${packagePath} declares no exports target for the active conditions; it declares ${main.declared.join(", ")}.`,
],
};
if (packageChain.has(packagePath)) {
return {
resolvedPath: null,
status: "unavailable",
limitations: [
`Directory package entrypoint cycle includes ${packagePath}.`,
],
};
}
if (main.status === "missing")
return (
resolveFileCandidates(input, indexCandidates(candidate)) ??
notFoundCandidate()
);
const entryInput: ResolveArtifactPathInput = {
declaredPath: main.value,
sourcePath: packagePath,
context: "package-entrypoint",
files: input.files,
};
const chain = new Set([...packageChain, packagePath]);
if (main.source === "legacy") {
const entry = resolvePackagePath(entryInput, chain, "files-and-index");
if (entry.resolution_status !== "not-found") return candidateOutcome(entry);
const index = resolveFileCandidates(input, indexCandidates(candidate));
if (index !== null) return index;
}
// Preserve recursive artifact package lookup only after legacy file/index fallbacks.
return candidateOutcome(resolvePackagePath(entryInput, chain));
};
const candidateOutcome = (
nested: ArtifactPathResolution,
): CandidateResolution =>
nested.resolution_status === "resolved"
? {
resolvedPath: nested.resolved_path,
status: nested.resolution_status,
limitations: nested.limitations,
}
: {
resolvedPath: null,
status: nested.resolution_status,
limitations: nested.limitations,
};
const resolveFileCandidates = (
input: ResolveArtifactPathInput,
candidates: readonly string[],
): CandidateResolution | null => {
const source = input.files.get(input.sourcePath);
for (const path of candidates) {
const target = input.files.get(path);
if (
target !== undefined &&
(source === undefined ||
target.container_sha256 === source.container_sha256)
)
return { resolvedPath: path, status: "resolved", limitations: [] };
}
return null;
};
const fileCandidates = (candidate: string): readonly string[] => [
candidate,
...EXTENSIONS.map((extension) => `${candidate}${extension}`),
];
const indexCandidates = (candidate: string): readonly string[] =>
EXTENSIONS.map((extension) => posix.join(candidate, `index${extension}`));
const packageEntry = (
text: string,
moduleKind: ResolveArtifactPathInput["moduleKind"],
useExports: boolean,
):
| {
readonly status: "value";
readonly value: string;
readonly source: "legacy" | "exports";
}
| { readonly status: "missing" }
| { readonly status: "invalid" }
| { readonly status: "unmatched"; readonly declared: readonly string[] } => {
try {
const value: unknown = JSON.parse(text);
if (typeof value !== "object" || value === null)
return { status: "invalid" };
const rawExports = Reflect.get(value, "exports");
if (useExports && rawExports !== undefined && rawExports !== null) {
const exported = packageExport(rawExports, moduleKind);
return exported.status === "value"
? { ...exported, source: "exports" }
: exported;
}
const preferred =
moduleKind === "import"
? [Reflect.get(value, "module"), Reflect.get(value, "main")]
: [Reflect.get(value, "main"), Reflect.get(value, "module")];
const entry = preferred.find((candidate) => candidate !== undefined);
if (entry === undefined) return { status: "missing" };
const legacy = packagePathValue(entry);
return legacy.status === "value" ? { ...legacy, source: "legacy" } : legacy;
} catch (cause: unknown) {
// Reflect-based manifest reads fail closed as invalid.
void cause;
return { status: "invalid" };
}
};
const packageExport = (
value: unknown,
moduleKind: ResolveArtifactPathInput["moduleKind"],
): PackageExportOutcome => {
if (typeof value === "string") return packagePathValue(value);
if (typeof value !== "object" || value === null || Array.isArray(value))
return { status: "invalid" };
const root = Reflect.get(value, ".") ?? value;
if (typeof root === "string") return packagePathValue(root);
if (typeof root !== "object" || root === null || Array.isArray(root))
return { status: "invalid" };
const flattened = exportTargets(root, packageExportConditions(moduleKind));
if (flattened.kind === "invalid") return { status: "invalid" };
const first =
flattened.kind === "unmatched" ? undefined : flattened.values[0];
return first === undefined
? { status: "unmatched", declared: Object.keys(root) }
: { status: "value", value: first };
};
type ExportTargets =
| { readonly kind: "targets"; readonly values: readonly string[] }
| { readonly kind: "unmatched" }
| { readonly kind: "invalid" };
/**
* Resolve a target in Node's declared order. Explicit null blocks an active
* condition, whereas an unmatched nested object permits the next condition.
* An array skips unmatched and invalid entries, stops at its first string, and
* preserves the final invalid-versus-null refusal if no string is selected.
*/
const exportTargets = (
value: unknown,
conditions: ReadonlySet<string>,
): ExportTargets => {
if (typeof value === "string")
return value.startsWith("./")
? { kind: "targets", values: [value] }
: { kind: "invalid" };
if (value === null) return { kind: "targets", values: [] };
if (Array.isArray(value)) {
let invalid = false;
for (const entry of value) {
const nested = exportTargets(entry, conditions);
if (nested.kind === "invalid") {
invalid = true;
continue;
}
if (nested.kind === "unmatched") continue;
invalid = false;
if (nested.values.length > 0) return nested;
}
return invalid ? { kind: "invalid" } : { kind: "targets", values: [] };
}
if (typeof value !== "object") return { kind: "invalid" };
for (const [condition, target] of Object.entries(value)) {
if (!conditions.has(condition)) continue;
const nested = exportTargets(target, conditions);
if (nested.kind !== "unmatched") return nested;
}
return { kind: "unmatched" };
};
/**
* Node selects an exports target by walking the declared keys in order and
* taking the first whose condition is active for the calling resolver.
* "default" is always active, and "node" plus "node-addons" are active for the
* built-in resolver that owns installed-package imports and requires.
*/
const packageExportConditions = (
moduleKind: ResolveArtifactPathInput["moduleKind"],
): ReadonlySet<string> =>
new Set([
"node",
"node-addons",
...(moduleKind === undefined ? [] : [moduleKind]),
"default",
]);
type PackageExportOutcome =
| { readonly status: "value"; readonly value: string }
| { readonly status: "invalid" }
| { readonly status: "unmatched"; readonly declared: readonly string[] };
const packagePathValue = (
value: unknown,
):
| { readonly status: "value"; readonly value: string }
| { readonly status: "invalid" } =>
typeof value === "string" && value.length > 0
? { status: "value", value }
: { status: "invalid" };
const notFoundCandidate = (): CandidateResolution => ({
resolvedPath: null,
status: "not-found",
limitations: [
"No extension, directory package, or index candidate exists in the inventoried artifact container.",
],
});
const fileUrlPath = (value: string): string | undefined => {
if (!value.startsWith("file://")) return undefined;
try {
const url = new URL(value);
if (url.protocol !== "file:" || url.hostname !== "") return undefined;
return decodeURIComponent(url.pathname).replace(/^\/+/, "");
} catch (cause: unknown) {
// Unparseable file URLs have no path to resolve.
void cause;
return undefined;
}
};
const outcome = (
input: ResolveArtifactPathInput,
resolution: CandidateResolution,
): ArtifactPathResolution =>
resolution.status === "resolved"
? {
declared_path: input.declaredPath,
resolution_context: input.context,
resolved_path: resolution.resolvedPath,
resolution_status: resolution.status,
limitations: resolution.limitations,
}
: {
declared_path: input.declaredPath,
resolution_context: input.context,
resolved_path: null,
resolution_status: resolution.status,
limitations: resolution.limitations,
};
const unresolvedOutcome = (
input: ResolveArtifactPathInput,
status: UnresolvedArtifactPathStatus,
limitations: readonly string[],
): ArtifactPathResolution =>
outcome(input, { resolvedPath: null, status, limitations });
@@ -0,0 +1,141 @@
import { lstat, realpath } from "node:fs/promises";
import { AsarArtifactReader } from "../../artifacts/AsarArtifactReader.js";
import {
ArtifactReaderFailure,
type ArtifactReader,
} from "../../artifacts/ArtifactReader.js";
import { DirectoryArtifactReader } from "../../artifacts/DirectoryArtifactReader.js";
import type { JavaScriptApplicationGraph } from "../../domain/javascriptApplicationGraph.js";
import type { JavaScriptSemanticGraph } from "../../domain/javascriptSemanticGraph.js";
import type { ElectronBoundarySummary } from "../../domain/javascriptApplicationAnalysis.js";
import { analyzeJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js";
import { readJavaScriptArtifactFiles } from "./JavaScriptArtifactFiles.js";
import { buildJavaScriptArtifactGraph } from "./JavaScriptArtifactGraphBuilder.js";
import {
javascriptArtifactReconstructionInputSchema,
type JavaScriptArtifactReconstructionInput,
} from "./JavaScriptArtifactReconstructionInput.js";
import { scanCanonicalArtifactInventory } from "../ArtifactInventory.js";
import { summarizeElectronBoundaries } from "./ElectronBoundaryAnalysis.js";
import { buildJavaScriptSemanticGraph } from "./JavaScriptSemanticGraphBuilder.js";
/** Application-layer result retaining local diagnostics outside the canonical graph. */
export interface JavaScriptArtifactReconstructionResult {
readonly input_path: string;
readonly format: "asar" | "directory";
readonly root_artifact_sha256: string;
readonly inventory_manifest_id: string;
readonly inventory_graph_sha256: string;
readonly graph: JavaScriptApplicationGraph;
readonly semantic_graph: JavaScriptSemanticGraph;
readonly electron_summary: ElectronBoundarySummary;
readonly statistics: {
readonly relevant_files: number;
readonly nested_asar_containers: number;
readonly text_bytes_read: number;
readonly invalid_utf8_files: number;
readonly parsed_javascript_files: number;
readonly visited_ast_nodes: number;
readonly findings: number;
readonly modules: number;
readonly parse_failures: number;
readonly truncated_scopes: number;
};
readonly limitations: readonly string[];
}
/** Reconstruct one local ASAR or extracted directory without executing code. */
export const reconstructJavaScriptArtifact = async (
rawInput: unknown,
signal?: AbortSignal,
): Promise<JavaScriptArtifactReconstructionResult> => {
const input = javascriptArtifactReconstructionInputSchema.parse(rawInput);
abortIfNeeded(signal);
const path = await realpath(input.input_path);
const format = await resolveFormat(path, input);
const snapshot = await scanCanonicalArtifactInventory(path, { signal });
if (snapshot.manifest.root_format !== format)
throw new ArtifactReaderFailure(
"format",
`Artifact inventory classified ${path} as ${snapshot.manifest.root_format}, not ${format}`,
);
const reader = createReader(path, format);
try {
const files = await readJavaScriptArtifactFiles(reader, snapshot, signal);
const analysis = analyzeJavaScriptArtifactFiles(files);
abortIfNeeded(signal);
const graph = buildJavaScriptArtifactGraph(snapshot, files, analysis);
const semanticGraph = buildJavaScriptSemanticGraph({
rootArtifactSha256: snapshot.manifest.root_sha256,
applicationGraph: graph,
analysis,
});
return {
input_path: path,
format,
root_artifact_sha256: snapshot.manifest.root_sha256,
inventory_manifest_id: snapshot.manifest.manifest_id,
inventory_graph_sha256: snapshot.manifest.graph_sha256,
graph,
semantic_graph: semanticGraph,
electron_summary: summarizeElectronBoundaries(analysis),
statistics: {
relevant_files: files.files.length,
nested_asar_containers: files.containers.length,
text_bytes_read: files.text_bytes_read,
invalid_utf8_files: files.invalid_utf8_files,
parsed_javascript_files: analysis.files.filter(
({ javascript }) => javascript !== null,
).length,
visited_ast_nodes: analysis.visited_ast_nodes,
findings: analysis.findings,
modules: analysis.modules,
parse_failures: analysis.parse_failures,
truncated_scopes: analysis.truncated_scopes,
},
limitations: analysis.limitations,
};
} finally {
await reader.close();
}
};
const resolveFormat = async (
path: string,
input: JavaScriptArtifactReconstructionInput,
): Promise<"asar" | "directory"> => {
const metadata = await lstat(path);
const observed = metadata.isDirectory()
? "directory"
: metadata.isFile() && path.toLowerCase().endsWith(".asar")
? "asar"
: undefined;
if (observed === undefined)
throw new ArtifactReaderFailure(
"format",
`JavaScript artifact reconstruction accepts only a directory or .asar file: ${path}`,
);
if (input.format !== "auto" && input.format !== observed)
throw new ArtifactReaderFailure(
"format",
`Requested ${input.format} input but observed ${observed}: ${path}`,
);
return observed;
};
const createReader = (
path: string,
format: "asar" | "directory",
): ArtifactReader =>
format === "asar"
? new AsarArtifactReader(path)
: new DirectoryArtifactReader(path);
const abortIfNeeded = (signal?: AbortSignal): void => {
if (signal?.aborted === true)
throw new ArtifactReaderFailure(
"cancelled",
"JavaScript artifact reconstruction cancelled",
);
};
@@ -0,0 +1,12 @@
import { z } from "zod";
/** Local ASAR/directory reconstruction request. */
export const javascriptArtifactReconstructionInputSchema = z.strictObject({
input_path: z.string().min(1),
format: z.enum(["auto", "asar", "directory"]).default("auto"),
});
/** Parsed local reconstruction request. */
export type JavaScriptArtifactReconstructionInput = z.infer<
typeof javascriptArtifactReconstructionInputSchema
>;
@@ -0,0 +1,95 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, relative } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { parseExpression } from "@babel/parser";
import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { javascriptApplicationAnalysisResultSchema } from "../../domain/javascriptApplicationAnalysis.js";
import { staticPath } from "../../domain/javascriptStaticAnalysisHelpers.js";
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
const urlCases = [
["./preload%20name.cjs", "preload name.cjs", "main.mjs"],
["./pr%C3%A9load.cjs", "préload.cjs", "main.mjs"],
["./preload%3F%23.cjs", "preload?#.cjs", "main.mjs"],
["./preload.cjs?cache=1#v2", "preload.cjs", "main.mjs"],
["../preload.cjs?cache=1", "preload.cjs", "src/main.mjs"],
["./sub/../preload.cjs", "preload.cjs", "main.mjs"],
["./preload.cjs", "preload.cjs", "main.mjs"],
] as const;
const unknownExpressions = [
'fileURLToPath(new URL("./invalid%.cjs", import.meta.url))',
'fileURLToPath(new URL("./preload%2fname.cjs", import.meta.url))',
'fileURLToPath(new URL("./preload%5cname.cjs", import.meta.url))',
'fileURLToPath(new URL("./%2e/preload.cjs", import.meta.url))',
'fileURLToPath(new URL("https://example.test/preload.cjs", import.meta.url))',
'fileURLToPath(new URL("//example.test/preload.cjs", import.meta.url))',
'fileURLToPath(new URL("#fragment", import.meta.url))',
'fileURLToPath(new URL("?cache=1", import.meta.url))',
'fileURLToPath(new URL("./preload.cjs", runtimeBase))',
"fileURLToPath(new URL(runtimePath, import.meta.url))",
'convertFileURLToPath(new URL("./preload.cjs", import.meta.url))',
];
describe("source-relative file URL paths", () => {
it.each(urlCases)(
"resolves %s to the file read by Node",
async (reference, target, sourcePath) => {
const root = await createTestTempDirectory("rea-file-url-path-");
const sourceFile = join(root, sourcePath);
await mkdir(dirname(sourceFile), { recursive: true });
await writeFile(join(root, target), 'module.exports = "real preload";\n');
if (reference.includes("%") && sourcePath === "main.mjs")
await writeFile(
join(root, reference),
'module.exports = "encoded-name decoy";\n',
);
const nativePath = fileURLToPath(
new URL(reference, pathToFileURL(sourceFile)),
);
expect(relative(root, nativePath)).toBe(target);
expect(await readFile(nativePath, "utf8")).toContain("real preload");
await writeFile(
sourceFile,
`import { fileURLToPath } from "node:url";\nnew BrowserWindow({ webPreferences: { preload: fileURLToPath(new URL(${JSON.stringify(reference)}, import.meta.url)) } });\n`,
);
const result = await analyzeJavaScriptApplication({
input_path: root,
format: "directory",
});
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
const { graph } = javascriptApplicationAnalysisResultSchema.parse(
result.value.normalized_result,
);
const preloads = graph.nodes.filter(
({ kind }) => kind === "electron-preload",
);
expect(preloads).toHaveLength(1);
expect(preloads[0]?.observations).not.toHaveLength(0);
for (const observation of preloads[0]?.observations ?? [])
expect(observation.properties).toMatchObject({
resolved_path: target,
resolution_status: "resolved",
resolution_context: "filesystem-expression",
});
},
);
it.each(unknownExpressions)("leaves unsupported %s unknown", (expression) => {
expect(
staticPath(parseExpression(expression, { sourceType: "module" })),
).toBeUndefined();
});
it("does not URL-decode literal filesystem punctuation", () => {
const expression = 'path.join(__dirname, "preload%20?#.cjs")';
expect(
staticPath(parseExpression(expression, { sourceType: "module" })),
).toBe("preload%20?#.cjs");
});
});
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
const paths = [
"renderer/index.html",
"assets/app.js",
"renderer/app.js",
"renderer/a/app.js",
"app.js",
];
const files = new Map<string, JavaScriptArtifactFile>(
paths.map((path) => [
path,
{
path,
container_sha256: "a".repeat(64),
sha256: "b".repeat(64),
bytes: 0,
inventory_artifact_id: path,
kind: "javascript",
unpacked: false,
text: { included: true, value: "" },
},
]),
);
const resolve = (declaredPath: string, htmlBaseHref: string) =>
resolveArtifactPathByContext({
declaredPath,
htmlBaseHref,
sourcePath: "renderer/index.html",
context: "html-reference",
files,
});
const documentUrl = "https://artifact.test/renderer/index.html";
describe("HTML base href URL components", () => {
it.each([
"/assets/?cache=/wrong/",
"/assets/#/wrong/",
"/assets/index.html?cache=/wrong/",
])("ignores query and fragment path-looking characters in %s", (base) => {
expect(
new URL(
"app.js",
new URL(base, "https://artifact.test/renderer/index.html"),
).pathname,
).toBe("/assets/app.js");
expect(resolve("app.js", base)).toMatchObject({
resolution_status: "resolved",
resolved_path: "assets/app.js",
});
});
it.each(["https://external.test/", "//external.test/"])(
"keeps root-relative references external when their base is %s",
(base) => {
expect(resolve("/assets/app.js", base)).toMatchObject({
resolution_status: "external",
resolved_path: null,
});
},
);
});
describe("HTML base href dot segments", () => {
it.each([
[".", "renderer/app.js"],
["./", "renderer/app.js"],
["a/.", "renderer/a/app.js"],
["a/..", "renderer/app.js"],
["..", "app.js"],
])(
"resolves %s against the document directory like the URL parser",
(base, expected) => {
// A relative reference resolves against the base URL's directory, which
// drops a trailing "." or ".." segment instead of stepping through it.
expect(
decodeURIComponent(
new URL("app.js", new URL(base, documentUrl)).pathname,
).replace(/^\/+/, ""),
).toBe(expected);
expect(resolve("app.js", base)).toMatchObject({
resolution_status: "resolved",
resolved_path: expected,
});
},
);
});
describe("HTML base href path syntax", () => {
it.each(["%2e%2e/secret/", "/a/%2e%2e/", "/a\\b/", "a\0b"])(
"rejects base href %j with the same admission rules a declared path gets",
(base) => {
const outcome = resolve("app.js", base);
expect(outcome.resolution_status).toBe("rejected");
expect(outcome.limitations[0]).toContain(
"not admitted for canonical artifact paths",
);
},
);
it("still confines a base href that escapes the artifact root", () => {
expect(resolve("app.js", "/a/../../secret/")).toMatchObject({
resolution_status: "rejected",
limitations: [
"The resolved candidate escapes the canonical artifact root.",
],
});
});
it("reports an external base href as external rather than malformed", () => {
expect(resolve("app.js", "https://external.test/%2e%2e/")).toMatchObject({
resolution_status: "external",
resolved_path: null,
});
});
});
@@ -0,0 +1,45 @@
import { compareCodePoints } from "../../domain/canonicalOrdering.js";
import type { JavaScriptJsonModuleObservation } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
/** Parse one approved JSON module without evaluating JavaScript or resolving imports. */
export const analyzeJavaScriptJsonModule = (
file: JavaScriptArtifactFile,
): JavaScriptJsonModuleObservation => {
if (!file.text.included)
return {
path: file.path,
sha256: file.sha256,
status: "unavailable",
top_level_keys: [],
omitted_top_level_keys: null,
limitation: `JSON module text was unavailable: ${file.text.reason}.`,
};
let value: unknown;
try {
value = JSON.parse(file.text.value);
} catch (cause: unknown) {
void cause;
return {
path: file.path,
sha256: file.sha256,
status: "invalid",
top_level_keys: [],
omitted_top_level_keys: 0,
limitation: "JSON module is not valid JSON.",
};
}
const keys =
typeof value === "object" && value !== null && !Array.isArray(value)
? Object.keys(value).sort(compareCodePoints)
: [];
const retained = keys;
return {
path: file.path,
sha256: file.sha256,
status: "included",
top_level_keys: retained,
omitted_top_level_keys: 0,
limitation: null,
};
};
@@ -0,0 +1,414 @@
import { posix } from "node:path";
import { compareCodePoints } from "../../domain/canonicalOrdering.js";
import type { ApplicationNode } from "../../domain/javascriptApplicationGraph.js";
import {
completeApplicationCoverage,
partialApplicationCoverage,
} from "../../domain/javascriptApplicationEvidenceSchemas.js";
import type {
JavaScriptModuleOrigin,
JavaScriptSemanticIr,
JavaScriptSemanticModuleLink,
} from "../../domain/javascriptSemanticIr.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import {
addAstContainsEdge,
artifactLocalIdentity,
type JavaScriptArtifactGraphContext,
type JavaScriptArtifactGraphCoverage,
} from "./JavaScriptArtifactGraphContext.js";
import {
astObservationEvidence,
staticInferenceEvidence,
} from "./JavaScriptArtifactGraphEvidence.js";
import {
resolveArtifactPathByContext,
type ArtifactPathResolution,
} from "./JavaScriptArtifactPathResolution.js";
import { projectJavaScriptExportReturnShapes } from "./JavaScriptReturnShapeProjection.js";
interface SemanticAnalysis {
readonly ir: JavaScriptSemanticIr;
}
interface RelationshipInput {
readonly context: JavaScriptArtifactGraphContext;
readonly file: JavaScriptArtifactFile;
readonly semantic: SemanticAnalysis;
readonly source: ApplicationNode;
readonly link: JavaScriptSemanticModuleLink;
}
type ResolvedModuleTarget = {
readonly node: ApplicationNode;
readonly limitations: readonly string[];
} & (
| {
readonly path: string;
readonly file: JavaScriptArtifactFile;
readonly status: "resolved";
}
| {
readonly path: null;
readonly file: null;
readonly status: Exclude<
ArtifactPathResolution["resolution_status"],
"resolved"
>;
}
);
/** Create one path-scoped source-module identity for every analyzed source file. */
export const addJavaScriptSourceModules = (
context: JavaScriptArtifactGraphContext,
): void => {
for (const analyzed of context.analysis.files) {
const { file, semantic } = analyzed;
const asset = context.assetNodes.get(file.path);
const program = semantic?.ir.scopes.find(({ kind }) => kind === "program");
if (asset === undefined || semantic === null || program === undefined)
continue;
const exports = semantic.ir.moduleLinks
.flatMap(({ exportedName }) =>
exportedName === null ? [] : [exportedName],
)
.sort(compareCodePoints);
const retainedExports = [...new Set(exports)];
const source = context.accumulator.addNode({
kind: "javascript-module",
identity: artifactLocalIdentity(
file.sha256,
"source-module",
`${file.container_sha256}:${file.path}`,
),
observations: [
{
label: file.path,
properties: {
semantic_role: "source-module",
path: file.path,
container_sha256: file.container_sha256,
logical_module_key: file.path,
module_format: moduleFormat(file.path, semantic.ir),
import_relationships: semantic.ir.moduleLinks.filter(
({ kind }) => kind === "import" || kind === "require",
).length,
export_relationships: exports.length,
export_names: retainedExports,
omitted_export_names: 0,
},
evidence: astObservationEvidence({
sha256: file.sha256,
path: file.path,
range: program.location,
operation: "recover-source-module",
coverage: semanticCoverage(semantic),
limitations: semantic.ir.limitations,
}),
},
],
});
context.sourceModuleNodes.set(file.path, source);
addAstContainsEdge(context, {
source: asset,
target: source,
file,
range: program.location,
coverage: semanticCoverage(semantic),
properties: { logical_module_key: file.path },
});
}
};
/** Compose bounded CommonJS and ESM binding relationships across artifact files. */
export const addJavaScriptModuleRelationships = (
context: JavaScriptArtifactGraphContext,
): void => {
for (const analyzed of context.analysis.files) {
const { file, semantic } = analyzed;
const source = context.sourceModuleNodes.get(file.path);
if (semantic === null || source === undefined) continue;
for (const link of semantic.ir.moduleLinks) {
const input = { context, file, semantic, source, link };
if (isExportLink(link)) addExportRelationship(input);
else if (link.specifier !== null)
addImportRelationship(input, source, {
specifier: link.specifier,
importedPath: link.importedName === null ? [] : [link.importedName],
});
}
}
};
const addExportRelationship = (input: RelationshipInput): void => {
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 exported = context.accumulator.addNode({
kind: "javascript-module",
identity: artifactLocalIdentity(
file.sha256,
"module-export",
`${file.container_sha256}:${file.path}:${link.exportedName}`,
),
observations: [
{
label: `${file.path}:${link.exportedName}`,
properties: {
semantic_role: "export-binding",
module_path: file.path,
relationship_kind: link.kind,
exported_name: link.exportedName,
local_name: link.localName,
imported_name: link.importedName,
declared_specifier: link.specifier,
},
evidence: astObservationEvidence({
sha256: file.sha256,
path: file.path,
range: link.location,
operation: "recover-module-export",
coverage: baseCoverage,
limitations: semantic.ir.limitations,
}),
},
...(returnShapes === null
? []
: [
{
label: `${file.path}:${link.exportedName}:return-shapes`,
properties: returnShapes.properties,
evidence: staticInferenceEvidence({
sha256: file.sha256,
path: file.path,
range: returnShapes.range,
operation: "recover-export-return-shapes",
coverage: returnShapes.coverage,
limitations: returnShapes.limitations,
}),
},
]),
],
});
context.accumulator.addEdge({
source_node_id: source.node_id,
target_node_id: exported.node_id,
relation: "exposes",
properties: relationshipProperties(link, null),
evidence: relationshipEvidence(input, "expose-module-export", []),
});
const origin = moduleOriginForExport(semantic.ir, link);
if (origin !== null) addImportRelationship(input, exported, origin);
};
const addImportRelationship = (
input: RelationshipInput,
source: ApplicationNode,
origin: JavaScriptModuleOrigin,
): void => {
const target = resolveModuleTarget(input, origin.specifier);
input.context.accumulator.addEdge({
source_node_id: source.node_id,
target_node_id: target.node.node_id,
relation: "imports",
properties: relationshipProperties(input.link, target, origin),
evidence: relationshipEvidence(
input,
"resolve-semantic-module-relationship",
target.limitations,
target.status === "resolved"
? "high"
: target.status === "external"
? "medium"
: "low",
),
});
};
const resolveModuleTarget = (
input: RelationshipInput,
specifier: string,
): ResolvedModuleTarget => {
const resolution = resolveArtifactPathByContext({
declaredPath: specifier,
sourcePath: input.file.path,
context: "module-specifier",
files: input.context.filesByPath,
moduleKind:
input.link.kind === "require" || input.link.kind === "commonjs-export"
? "require"
: "import",
});
if (resolution.resolution_status !== "resolved")
return {
node: unresolvedModuleNode(
input,
specifier,
resolution.resolution_status,
),
path: null,
file: null,
status: resolution.resolution_status,
limitations: resolution.limitations,
};
const path = resolution.resolved_path;
const file = input.context.filesByPath.get(path);
const node =
input.context.sourceModuleNodes.get(path) ??
input.context.fileNodes.get(path);
if (file === undefined || node === undefined)
throw new TypeError(
`Resolved artifact path ${path} is absent from its graph context.`,
);
return {
node,
path,
file,
status: "resolved",
limitations: resolution.limitations,
};
};
const unresolvedModuleNode = (
input: RelationshipInput,
specifier: string,
status: Exclude<ResolvedModuleTarget["status"], "resolved">,
): ApplicationNode =>
input.context.accumulator.addNode({
kind: "javascript-module",
identity: artifactLocalIdentity(
input.file.sha256,
"unresolved-semantic-module",
`${status}:${specifier}`,
),
observations: [
{
label: specifier,
properties: {
semantic_role: "module-reference",
declared_specifier: specifier,
resolution_status: status,
},
evidence: staticInferenceEvidence({
sha256: input.file.sha256,
path: input.file.path,
range: input.link.location,
operation: "retain-unresolved-semantic-module",
coverage: semanticCoverage(input.semantic),
confidence: "low",
limitations: input.semantic.ir.limitations,
}),
},
],
});
const moduleOriginForExport = (
ir: JavaScriptSemanticIr,
link: JavaScriptSemanticModuleLink,
): JavaScriptModuleOrigin | null => {
if (link.specifier !== null)
return {
specifier: link.specifier,
importedPath: link.importedName === null ? [] : [link.importedName],
};
if (link.localName === null) return null;
const program = ir.scopes.find(({ kind }) => kind === "program");
const binding = ir.bindings.find(
({ name, scopeId }) =>
name === link.localName && scopeId === program?.scopeId,
);
return binding?.provenance.status === "module" &&
binding.provenance.origins.length === 1
? (binding.provenance.origins[0] ?? null)
: null;
};
const relationshipProperties = (
link: JavaScriptSemanticModuleLink,
target: ResolvedModuleTarget | null,
origin?: JavaScriptModuleOrigin,
) => ({
module_link_kind: link.kind,
specifier: origin?.specifier ?? link.specifier,
imported_name: origin?.importedPath.at(-1) ?? link.importedName,
imported_path: origin?.importedPath ?? [],
local_name: link.localName,
exported_name: link.exportedName,
resolved_path: target?.path ?? null,
resolution_status: target?.status ?? null,
target_file_kind: target?.file?.kind ?? null,
target_json_status:
target?.file?.kind === "json" ? jsonStatus(target.file) : null,
});
const relationshipEvidence = (
input: RelationshipInput,
operation: string,
limitations: readonly string[],
confidence: "high" | "medium" | "low" = "high",
) =>
staticInferenceEvidence({
sha256: input.file.sha256,
path: input.file.path,
range: input.link.location,
operation,
coverage: semanticCoverage(input.semantic),
confidence,
limitations: [...input.semantic.ir.limitations, ...limitations],
});
const semanticCoverage = (
semantic: SemanticAnalysis,
): JavaScriptArtifactGraphCoverage => {
if (semantic.ir.coverage.status === "complete")
return completeApplicationCoverage();
return partialApplicationCoverage([], semantic.ir.coverage.omittedCount);
};
const moduleFormat = (
path: string,
ir: JavaScriptSemanticIr,
): "commonjs" | "esm" | "mixed" | "unknown" => {
const extension = posix.extname(path).toLowerCase();
if (extension === ".mjs") return "esm";
if (extension === ".cjs") return "commonjs";
const esm = ir.moduleLinks.some(({ kind }) =>
["import", "export", "re-export"].includes(kind),
);
const commonJs = ir.moduleLinks.some(({ kind }) =>
["require", "commonjs-export"].includes(kind),
);
return esm && commonJs
? "mixed"
: esm
? "esm"
: commonJs
? "commonjs"
: "unknown";
};
const jsonStatus = (
file: JavaScriptArtifactFile,
): "included" | "invalid" | "unavailable" => {
if (!file.text.included) return "unavailable";
try {
JSON.parse(file.text.value);
return "included";
} catch (cause: unknown) {
// Invalid JSON is classified without further detail.
void cause;
return "invalid";
}
};
const isExportLink = (link: JavaScriptSemanticModuleLink): boolean =>
link.kind === "export" ||
link.kind === "re-export" ||
link.kind === "commonjs-export";
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
const filesFor = (paths: readonly string[]) =>
new Map<string, JavaScriptArtifactFile>(
["app/main.js", ...paths].map((path) => [
path,
{
path,
container_sha256: "a".repeat(64),
sha256: "b".repeat(64),
bytes: 0,
inventory_artifact_id: `artifact-${path}`,
kind: "javascript",
unpacked: false,
text: { included: true, value: "" },
},
]),
);
const cases = ["#", "?"].flatMap((punctuation) =>
(["require", "import", undefined] as const).flatMap((moduleKind) =>
(["literal", "stripped", "both"] as const).map((layout) => ({
punctuation,
moduleKind,
layout,
})),
),
);
describe("module specifier punctuation", () => {
it.each(cases)(
"resolves $moduleKind with $punctuation and $layout files",
({ punctuation, moduleKind, layout }) => {
const literal = `app/dep.cjs${punctuation}literal.cjs`;
const stripped = "app/dep.cjs";
const files = filesFor([
...(layout !== "stripped" ? [literal] : []),
...(layout !== "literal" ? [stripped] : []),
]);
const expected = moduleKind === "require" ? literal : stripped;
const result = resolveArtifactPathByContext({
declaredPath: `./dep.cjs${punctuation}literal.cjs`,
sourcePath: "app/main.js",
context: "module-specifier",
files,
...(moduleKind === undefined ? {} : { moduleKind }),
});
expect(result).toMatchObject({
resolution_status: files.has(expected) ? "resolved" : "not-found",
resolved_path: files.has(expected) ? expected : null,
});
},
);
it.each(["#", "?"])(
"keeps HTML URL suffix handling for %s",
(punctuation) => {
expect(
resolveArtifactPathByContext({
declaredPath: `./dep.cjs${punctuation}literal.cjs`,
sourcePath: "app/main.js",
context: "html-reference",
files: filesFor([
"app/dep.cjs",
`app/dep.cjs${punctuation}literal.cjs`,
]),
}),
).toMatchObject({
resolution_status: "resolved",
resolved_path: "app/dep.cjs",
});
},
);
});
@@ -0,0 +1,15 @@
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { JavaScriptRecoveryInput } from "../../domain/javascriptRecovery.js";
import type { Result } from "../../domain/result.js";
import type {
AnalysisExecution,
ExecutionOptions,
} from "../AnalysisProvider.js";
/** Typed boundary for upstream source recovery without exposing its protocol. */
export interface JavaScriptRecoveryPort {
recover(
input: JavaScriptRecoveryInput,
options?: ExecutionOptions,
): Promise<Result<AnalysisExecution, AnalysisError>>;
}
@@ -0,0 +1,161 @@
import { describe, expect, it } from "vitest";
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
import { err, ok } from "../../domain/result.js";
import type {
JavaScriptRecoveryInput,
JavaScriptRecoveryResult,
} from "../../domain/javascriptRecovery.js";
import { createAnalysisExecution } from "../AnalysisProvider.js";
import { JavaScriptRecoveryService } from "./JavaScriptRecoveryService.js";
const artifact = { path: "/tmp/bundle.js", sha256: "a".repeat(64), bytes: 20 };
const recoveryResult = (): JavaScriptRecoveryResult => ({
source: {
...artifact,
snapshot_path: "/tmp/work/inputs/bundle.js",
published_copy: artifact,
},
engine: {
id: "fixture-recovery",
version: "1",
executable: artifact,
audited_source_revision: "pinned",
executed_source_revision: null,
},
options: { extraction_mode: "structural", rewrite_level: "standard" },
status: "complete",
detected_formats: [],
reported_safety: "normal",
reported_strategy: "structural",
reported_format: "unknown",
total: 0,
failed: 0,
modules: [],
warnings: [],
report: artifact,
provenance: artifact,
manifest: artifact,
analysis_input: null,
runtime_equivalence: "unknown",
limitations: ["Not executed"],
});
const providerIdentity = {
id: "fixture-recovery",
name: "Fixture recovery",
version: "1",
};
const input = { path: artifact.path, output_directory: "/tmp/output" };
describe("JavaScript recovery workflow", () => {
it("passes normalized requests to a typed port and returns derived Evidence", async () => {
const received: JavaScriptRecoveryInput[] = [];
const service = new JavaScriptRecoveryService({
recover: async (request) => {
received.push(request);
return ok(
createAnalysisExecution(recoveryResult(), providerIdentity, {
subject: { ...artifact, format: "javascript" },
limitations: ["Not executed"],
}),
);
},
});
const result = await service.recover(input);
if (!result.ok) throw result.error;
expect(received).toEqual([
{ ...input, extraction_mode: "structural", rewrite_level: "standard" },
]);
expect(result.value).toMatchObject({
operation: "recover_javascript_sources",
confidence: "derived",
authority: "shipped-artifact",
provider: providerIdentity,
});
});
it("preserves a missing provider reason", async () => {
const failure = new AnalysisCapabilityUnavailableError(
"fixture-recovery",
"recover_javascript_sources",
"fixture missing engine",
);
const result = await new JavaScriptRecoveryService({
recover: async () => err(failure),
}).recover(input);
expect(result).toEqual(err(failure));
});
it.each([
"invalid-result",
"missing-subject",
"subject-mismatch",
"engine-mismatch",
])("rejects %s returned across the port", async (mode) => {
const service = new JavaScriptRecoveryService({
recover: async () =>
ok(
createAnalysisExecution(
mode === "invalid-result" ? {} : recoveryResult(),
mode === "engine-mismatch"
? { ...providerIdentity, id: "another-engine" }
: providerIdentity,
mode === "missing-subject"
? {}
: {
subject: {
path:
mode === "subject-mismatch" ? "/wrong.js" : artifact.path,
sha256: artifact.sha256,
format: "javascript" as const,
},
},
),
),
});
const result = await service.recover(input);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected invalid port output");
expect(result.error._tag).toBe("AnalysisOutputError");
});
it.each(["path", "output_directory"])(
"rejects relative %s before acquiring the recovery engine",
async (field) => {
let invoked = false;
const service = new JavaScriptRecoveryService({
recover: async () => {
invoked = true;
return err(
new AnalysisCapabilityUnavailableError(
"fixture",
"recover_javascript_sources",
"engine missing",
),
);
},
});
const result = await service.recover({
...input,
[field]: "relative.js",
});
expect(invoked).toBe(false);
if (result.ok) throw new Error("expected invalid path");
expect(result.error._tag).toBe("AnalysisInputError");
expect(result.error).toMatchObject({
issues: [{ path: [field], reason: "invalid_format" }],
});
},
);
it("rejects malformed and pre-cancelled input before acquiring the port", async () => {
const service = new JavaScriptRecoveryService({
recover: async () => {
throw new Error("port must not execute");
},
});
const invalid = await service.recover({});
if (invalid.ok) throw new Error("expected malformed input");
expect(invalid.error._tag).toBe("AnalysisInputError");
const cancelled = await service.recover(input, {
signal: AbortSignal.abort(),
});
if (cancelled.ok) throw new Error("expected cancellation");
expect(cancelled.error._tag).toBe("AnalysisCancelledError");
});
});
@@ -0,0 +1,95 @@
import { isAbsolute } from "node:path";
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import {
javascriptRecoveryInputSchema,
javascriptRecoveryResultSchema,
} from "../../domain/javascriptRecovery.js";
import { jsonObjectSchema } from "../../domain/jsonValue.js";
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
import { err, ok, type Result } from "../../domain/result.js";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { JavaScriptRecoveryPort } from "./JavaScriptRecoveryPort.js";
const OPERATION = "recover_javascript_sources";
/** Shared CLI/MCP parsing and Evidence for derived JavaScript artifacts. */
export class JavaScriptRecoveryService {
constructor(readonly provider: JavaScriptRecoveryPort) {}
/** Recover sources and bind the result to its original immutable input. */
async recover(
input: unknown,
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
if (options?.signal?.aborted === true)
return err(new AnalysisCancelledError(OPERATION));
const parsed = javascriptRecoveryInputSchema.safeParse(input);
if (!parsed.success)
return err(
new AnalysisInputError(
OPERATION,
{ cause: parsed.error },
projectInputIssues(parsed.error.issues, input),
),
);
const invalidPaths = (["path", "output_directory"] as const).filter(
(field) => !isAbsolute(parsed.data[field]),
);
if (invalidPaths.length > 0)
return err(
new AnalysisInputError(
OPERATION,
undefined,
invalidPaths.map((field) => ({
path: [field],
reason: "invalid_format",
message: `${field} must be an absolute filesystem path on this host.`,
})),
),
);
const execution = await this.provider.recover(parsed.data, options);
if (!execution.ok) return execution;
const result = javascriptRecoveryResultSchema.safeParse(
execution.value.result,
);
if (!result.success || execution.value.subject === null)
return err(
new AnalysisOutputError(
OPERATION,
"Recovery did not return a valid result bound to the original source",
),
);
if (
result.data.source.path !== parsed.data.path ||
execution.value.subject.path !== result.data.source.path ||
execution.value.subject.sha256 !== result.data.source.sha256 ||
execution.value.provider.id !== result.data.engine.id ||
execution.value.provider.version !== result.data.engine.version
)
return err(
new AnalysisOutputError(
OPERATION,
"Recovery source or engine identity disagrees with its Evidence binding",
),
);
return ok(
createEvidence(execution.value.subject, execution.value.provider, {
operation: OPERATION,
parameters: jsonObjectSchema.parse(parsed.data),
result: result.data,
rawResult: execution.value.rawResult,
locations: execution.value.locations,
limitations: execution.value.limitations,
confidence: "derived",
authority: "shipped-artifact",
}),
);
}
}
@@ -0,0 +1,166 @@
import { describe, expect, it } from "vitest";
import { analyzeJavaScriptSemantics } from "../../domain/javascriptSemanticAnalysis.js";
import { completeApplicationCoverage } from "../../domain/javascriptApplicationEvidenceSchemas.js";
import { projectJavaScriptExportReturnShapes } from "./JavaScriptReturnShapeProjection.js";
const projectReturns = (body: string) => {
const ir = analyzeJavaScriptSemantics(`export function result() { ${body} }`);
const link = ir.moduleLinks.find(
({ exportedName }) => exportedName === "result",
);
if (link === undefined) throw new Error("Expected the result export");
const projected = projectJavaScriptExportReturnShapes({
ir,
link,
modulePath: "fixture.js",
baseCoverage: completeApplicationCoverage(),
});
if (projected === null) throw new Error("Expected exported return shapes");
return projected;
};
const unknownBranchField = {
path: "",
state: "unknown",
value: null,
reason: "Branches have incompatible values.",
};
describe("exported primitive union return shapes", () => {
const conditionalCases = [
"missing",
"unsupported()",
'({ key: "value" })',
'["value"]',
'+"invalid"',
].flatMap((other) => [
`condition ? (choice ? "one" : "two") : ${other}`,
`condition ? ${other} : (choice ? "one" : "two")`,
]);
const logicalCases = [
{ operator: "||", known: '(choice ? "" : "one")' },
{ operator: "&&", known: '(choice ? 0 : "one")' },
{ operator: "??", known: '(choice ? null : "one")' },
].flatMap(({ operator, known }) => [
`${known} ${operator} missing`,
`missing ${operator} ${known}`,
]);
it.each([...conditionalCases, ...logicalCases])(
"projects an unknown field instead of a definite union for %s",
(expression) => {
const projected = projectReturns(`return ${expression};`);
expect(projected.properties.static_return_shapes).toEqual([
{
source_range: expect.any(Object),
value_status: "ambiguous",
fields: [unknownBranchField],
property_coverage: [],
},
]);
// Complete return-site collection does not make its values known.
expect(projected.properties.return_shape_coverage).toEqual({
status: "complete",
retained_return_sites: 1,
omitted_return_sites: 0,
omitted_fields: 0,
omitted_property_coverage: 0,
projection_complete: true,
});
expect(projected.coverage).toEqual(completeApplicationCoverage());
expect(projected.limitations).toContain(
"Return shapes are inferred from inert syntax and do not prove runtime behavior.",
);
},
);
it("keeps an incompatible object field unknown beside a known literal", () => {
const projected = projectReturns(`
return {
kind: "result",
value: condition ? (choice ? "one" : "two") : missing,
};
`);
expect(projected.properties.static_return_shapes).toEqual([
{
source_range: expect.any(Object),
value_status: "object",
fields: [
{ path: "/kind", state: "literal", value: "result", reason: null },
{ ...unknownBranchField, path: "/value" },
],
property_coverage: [{ path: "", status: "complete", omitted: 0 }],
},
]);
});
it("retains valid nested unions, duplicates, and null alternatives", () => {
const projected = projectReturns(`
return condition
? (choice ? "two" : "one")
: (other ? "one" : null);
`);
expect(projected.properties.static_return_shapes).toEqual([
{
source_range: expect.any(Object),
value_status: "union",
fields: [
{
path: "",
state: "union",
value: [null, "one", "two"],
reason: null,
},
],
property_coverage: [],
},
]);
});
it("preserves three independent direct returns without merging their values", () => {
const projected = projectReturns(`
if (condition) return choice ? "one" : "two";
if (other) return missing;
return { kind: "object" };
`);
expect(projected.properties.static_return_shapes).toEqual([
{
source_range: expect.any(Object),
value_status: "union",
fields: [
{ path: "", state: "union", value: ["one", "two"], reason: null },
],
property_coverage: [],
},
{
source_range: expect.any(Object),
value_status: "unknown",
fields: [
{
path: "",
state: "unknown",
value: null,
reason: "Unbound identifier missing.",
},
],
property_coverage: [],
},
{
source_range: expect.any(Object),
value_status: "object",
fields: [
{ path: "/kind", state: "literal", value: "object", reason: null },
],
property_coverage: [{ path: "", status: "complete", omitted: 0 }],
},
]);
expect(projected.properties.return_shape_coverage).toMatchObject({
status: "complete",
retained_return_sites: 3,
omitted_return_sites: 0,
projection_complete: true,
});
});
});
@@ -0,0 +1,110 @@
import { jsonObjectSchema, jsonValueSchema } from "../../domain/jsonValue.js";
import {
partialApplicationCoverage,
truncatedApplicationCoverage,
} from "../../domain/javascriptApplicationEvidenceSchemas.js";
import type {
JavaScriptSemanticIr,
JavaScriptSemanticCallable,
JavaScriptSemanticModuleLink,
} from "../../domain/javascriptSemanticIr.js";
import { flattenSemanticReturnValue } from "../../domain/javascriptSemanticReturns.js";
import type { JavaScriptSourceRange } from "../../domain/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactGraphCoverage } from "./JavaScriptArtifactGraphContext.js";
export interface JavaScriptReturnShapeProjection {
readonly properties: ReturnType<typeof jsonObjectSchema.parse>;
readonly range: JavaScriptSourceRange;
readonly coverage: JavaScriptArtifactGraphCoverage;
readonly limitations: readonly string[];
}
/** Project one exact export/callable link into shallow bounded graph values. */
export const projectJavaScriptExportReturnShapes = (input: {
readonly ir: JavaScriptSemanticIr;
readonly link: JavaScriptSemanticModuleLink;
readonly modulePath: string;
readonly baseCoverage: JavaScriptArtifactGraphCoverage;
}): JavaScriptReturnShapeProjection | null => {
if (input.link.callableId === null || input.link.exportedName === null)
return null;
const callable = input.ir.callables.find(
({ callableId }) => callableId === input.link.callableId,
);
if (callable === undefined) return null;
const projection = projectReturnSites(callable);
const projectionOmitted =
projection.omittedSites +
projection.omittedFields +
projection.omittedCoverage;
const semanticOmitted = callable.returnCoverage.omittedCount;
const complete =
input.baseCoverage.status === "complete" &&
callable.returnCoverage.status === "complete" &&
projectionOmitted === 0;
const limits = input.baseCoverage.limits;
const omitted =
semanticOmitted === null ? null : semanticOmitted + projectionOmitted;
const coverage = complete
? input.baseCoverage
: projectionOmitted > 0
? truncatedApplicationCoverage(limits, omitted)
: partialApplicationCoverage(limits, omitted);
const limitations = [
...input.ir.limitations,
"Return shapes are inferred from inert syntax and do not prove runtime behavior.",
...(callable.returnSites.length === 0
? [
"No direct return value was retained; runtime return behavior remains unknown.",
]
: []),
];
return {
properties: jsonObjectSchema.parse({
semantic_role: "export-return-shapes",
module_path: input.modulePath,
exported_name: input.link.exportedName,
callable_id: callable.callableId,
callable_kind: callable.kind,
static_return_shapes: projection.shapes,
return_shape_coverage: {
status: callable.returnCoverage.status,
retained_return_sites: projection.shapes.length,
omitted_return_sites:
semanticOmitted === null
? null
: semanticOmitted + projection.omittedSites,
omitted_fields: projection.omittedFields,
omitted_property_coverage: projection.omittedCoverage,
projection_complete: projectionOmitted === 0,
},
}),
range: callable.location,
coverage,
limitations,
};
};
const projectReturnSites = (callable: JavaScriptSemanticCallable) => {
const retainedSites = callable.returnSites;
const shapes = retainedSites.map((site) => {
const flattened = flattenSemanticReturnValue(site.value);
const fields = flattened.fields.map((field) => ({
...field,
value: jsonValueSchema.parse(field.value),
}));
const propertyCoverage = flattened.propertyCoverage;
return {
source_range: site.location,
value_status: site.value.status,
fields,
property_coverage: propertyCoverage,
};
});
return {
shapes,
omittedSites: 0,
omittedFields: 0,
omittedCoverage: 0,
};
};
@@ -0,0 +1,62 @@
import type { ProviderIdentity } from "../AnalysisProvider.js";
import {
createEvidence,
type Evidence,
type EvidenceObservation,
} from "../../domain/evidence.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import type {
JavaScriptRuntimeObservation,
JavaScriptRuntimeTargetList,
ListJavaScriptRuntimeTargetsInput,
ObserveJavaScriptRuntimeInput,
} from "../../domain/javascriptRuntimeObservation.js";
type RuntimeObservationOperation =
| "list_javascript_runtime_targets"
| "observe_javascript_runtime";
/** Create deterministic Evidence for one passive Inspector operation. */
export const createJavaScriptRuntimeObservationEvidence = (
operation: RuntimeObservationOperation,
input: ListJavaScriptRuntimeTargetsInput | ObserveJavaScriptRuntimeInput,
result: JavaScriptRuntimeTargetList | JavaScriptRuntimeObservation,
provider: ProviderIdentity,
): Evidence =>
createEvidence(undefined, provider, {
predicateType:
operation === "list_javascript_runtime_targets"
? "rea.javascript-runtime-target-list"
: "rea.javascript-runtime-observation",
operation,
parameters: parameters(input),
result: jsonValueSchema.parse(result),
rawResult: null,
confidence: "observed",
authority: "external-service",
environment: {
id: `${result.runtime.product}@${result.runtime.protocol_version}`,
platform: "unknown",
architecture: "unknown",
isolation: "none",
},
limitations: [
...result.limitations,
"Runtime host platform and architecture were not observed.",
],
});
const parameters = (
input: ListJavaScriptRuntimeTargetsInput | ObserveJavaScriptRuntimeInput,
): EvidenceObservation["parameters"] => ({
inspector_endpoint: input.inspector_endpoint,
...("target_id" in input
? {
target_id: input.target_id,
...(input.runtime_kind === undefined
? {}
: { runtime_kind: input.runtime_kind }),
observation_ms: input.observation_ms,
}
: {}),
});
@@ -0,0 +1,25 @@
import type {
ExecutionOptions,
ProviderIdentity,
} from "../AnalysisProvider.js";
import type {
JavaScriptRuntimeObservation,
JavaScriptRuntimeTargetList,
ListJavaScriptRuntimeTargetsInput,
ObserveJavaScriptRuntimeInput,
} from "../../domain/javascriptRuntimeObservation.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { Result } from "../../domain/result.js";
/** Provider-neutral boundary for passive Node/Electron Inspector observation. */
export interface JavaScriptRuntimeObservationPort {
identity(): ProviderIdentity;
listTargets(
input: ListJavaScriptRuntimeTargetsInput,
options?: ExecutionOptions,
): Promise<Result<JavaScriptRuntimeTargetList, AnalysisError>>;
observe(
input: ObserveJavaScriptRuntimeInput,
options?: ExecutionOptions,
): Promise<Result<JavaScriptRuntimeObservation, AnalysisError>>;
}
@@ -0,0 +1,67 @@
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { JavaScriptRuntimeObservationPort } from "./JavaScriptRuntimeObservationPort.js";
import { createJavaScriptRuntimeObservationEvidence } from "./JavaScriptRuntimeObservationEvidence.js";
import type { Evidence } from "../../domain/evidence.js";
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
import type {
ListJavaScriptRuntimeTargetsInput,
ObserveJavaScriptRuntimeInput,
} from "../../domain/javascriptRuntimeObservation.js";
import { err, ok, type Result } from "../../domain/result.js";
/** List targets exposed by the explicitly selected loopback Inspector. */
export const listJavaScriptRuntimeTargets = async (
provider: JavaScriptRuntimeObservationPort | undefined,
input: ListJavaScriptRuntimeTargetsInput,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
const ready = requireProvider(provider, "list_javascript_runtime_targets");
if (!ready.ok) return ready;
const result = await ready.value.listTargets(input, options);
return result.ok
? ok(
createJavaScriptRuntimeObservationEvidence(
"list_javascript_runtime_targets",
input,
result.value,
ready.value.identity(),
),
)
: result;
};
/** Observe one attach-only Inspector target selected by the request. */
export const observeJavaScriptRuntime = async (
provider: JavaScriptRuntimeObservationPort | undefined,
input: ObserveJavaScriptRuntimeInput,
options: ExecutionOptions = {},
): Promise<Result<Evidence, AnalysisError>> => {
const ready = requireProvider(provider, "observe_javascript_runtime");
if (!ready.ok) return ready;
const result = await ready.value.observe(input, options);
return result.ok
? ok(
createJavaScriptRuntimeObservationEvidence(
"observe_javascript_runtime",
input,
result.value,
ready.value.identity(),
),
)
: result;
};
const requireProvider = (
provider: JavaScriptRuntimeObservationPort | undefined,
operation: "list_javascript_runtime_targets" | "observe_javascript_runtime",
): Result<JavaScriptRuntimeObservationPort, AnalysisError> =>
provider === undefined
? err(
new AnalysisCapabilityUnavailableError(
"rea-v8-inspector",
operation,
"V8 Inspector observation provider is not configured",
),
)
: ok(provider);
@@ -0,0 +1,42 @@
import {
createEvidence,
type Evidence,
type EvidenceObservation,
} from "../../domain/evidence.js";
import type {
JavaScriptRuntimeReconciliationResult,
ReconcileJavaScriptRuntimeInput,
} from "../../domain/javascriptRuntimeReconciliationSchemas.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import { JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER } from "../InvestigationProviders.js";
/** Create derived Evidence for one exact set of static/runtime inputs. */
export const createJavaScriptRuntimeReconciliationEvidence = (
input: ReconcileJavaScriptRuntimeInput,
result: JavaScriptRuntimeReconciliationResult,
): Evidence =>
createEvidence(undefined, JAVASCRIPT_RUNTIME_RECONCILIATION_PROVIDER, {
predicateType: "rea.javascript-runtime-reconciliation",
operation: "reconcile_javascript_runtime",
parameters: parameters(input),
result: jsonValueSchema.parse(result),
rawResult: null,
confidence: "inferred",
authority: "analyst-inference",
environment: null,
limitations: result.limitations,
evidenceLinks: result.evidence_links,
});
const parameters = (
input: ReconcileJavaScriptRuntimeInput,
): EvidenceObservation["parameters"] => ({
static_layers: input.static_layers.map((layer) => ({
role: layer.role,
evidence_id: layer.analysis.evidence_id,
runtime_mappings: layer.runtime_mappings,
})),
runtime_evidence_ids: input.runtime_observations.map(
({ evidence_id: id }) => id,
),
});
@@ -0,0 +1,114 @@
import { resolve } from "node:path";
import { z } from "zod";
import {
AnalysisInputError,
AnalysisProtocolError,
} from "../../domain/analysisErrorCore.js";
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
import type { Evidence } from "../../domain/evidence.js";
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
import { reconcileJavaScriptRuntime } from "../../domain/javascriptRuntimeReconciliation.js";
import { reconcileJavaScriptRuntimeInputSchema } from "../../domain/javascriptRuntimeReconciliationSchemas.js";
import { err, ok, type Result } from "../../domain/result.js";
import { createJavaScriptRuntimeReconciliationEvidence } from "./JavaScriptRuntimeReconciliationEvidence.js";
const OPERATION = "reconcile_javascript_runtime" as const;
/** Derive a combined JAG from verified static and passive-runtime Evidence. */
export const reconcileJavaScriptRuntimeEvidence = (
rawInput: unknown,
): Result<Evidence, AnalysisError> => {
const parsed = reconcileJavaScriptRuntimeInputSchema.safeParse(rawInput);
if (!parsed.success)
return err(
new AnalysisInputError(
OPERATION,
undefined,
projectInputIssues(parsed.error.issues, rawInput),
),
);
return reconcileJavaScriptRuntimeEvidenceValidated(parsed.data);
};
/** Reconcile input already parsed by a trusted adapter boundary. */
export const reconcileJavaScriptRuntimeEvidenceValidated = (
parsedInput: z.output<typeof reconcileJavaScriptRuntimeInputSchema>,
): Result<Evidence, AnalysisError> => {
try {
const input = reconcileJavaScriptRuntimeInputSchema.parse(
normalizeRuntimeMappingRoots(parsedInput),
);
const result = reconcileJavaScriptRuntime(input);
return ok(createJavaScriptRuntimeReconciliationEvidence(input, result));
} catch (cause: unknown) {
if (cause instanceof z.ZodError)
return err(
new AnalysisInputError(
OPERATION,
undefined,
projectInputIssues(cause.issues, parsedInput),
),
);
if (
cause instanceof TypeError &&
SAFE_RECONCILIATION_INPUT_CONSTRAINTS.has(cause.message)
)
return err(
new AnalysisInputError(OPERATION, undefined, [
{ path: [], reason: "invalid_value", message: cause.message },
]),
);
return err(
new AnalysisProtocolError(
"JavaScript runtime reconciliation produced an invalid result",
{ cause },
),
);
}
};
const normalizeRuntimeMappingRoots = (
input: z.output<typeof reconcileJavaScriptRuntimeInputSchema>,
): z.output<typeof reconcileJavaScriptRuntimeInputSchema> => ({
...input,
static_layers: input.static_layers.map((layer) => ({
...layer,
runtime_mappings: layer.runtime_mappings.map((mapping) =>
mapping.kind === "file-root"
? {
...mapping,
root: isPortableAbsolutePath(mapping.root)
? mapping.root
: resolve(mapping.root),
}
: mapping,
),
})),
});
const isPortableAbsolutePath = (path: string): boolean =>
path.startsWith("/") ||
/^[a-z]:[\\/]/iu.test(path) ||
/^(?:\\\\|\/\/)[^\\/]+[\\/][^\\/]+(?:[\\/]|$)/u.test(path);
const SAFE_RECONCILIATION_INPUT_CONSTRAINTS = new Set([
"Evidence semantic identifier does not match its record",
"JavaScript application Evidence subject disagrees with its result",
"Runtime reconciliation requires inspect_web_page, inspect_electron_page, observe_javascript_runtime, or capture_electron_scenario Evidence",
"Runtime Evidence target or declared role disagrees with its result",
"Runtime Evidence contains an invalid builtin location",
"Runtime Evidence target disagrees with its captured result",
"Browser Evidence target is outside its recorded origin scope",
"Runtime Evidence contains source without source-capture selection",
"Active Electron Evidence application path disagrees with its configured root",
...[
"inspect_web_page",
"inspect_electron_page",
"observe_javascript_runtime",
"capture_electron_scenario",
].map(
(operation) =>
`Evidence does not match the supported ${operation} contract`,
),
]);
@@ -0,0 +1,248 @@
import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascriptSemanticGraph.js";
import type { JavaScriptSemanticGraphNode } from "../../domain/javascriptSemanticGraph.js";
import type {
JavaScriptSemanticEventOperation,
JavaScriptSemanticTimerOperation,
} from "../../domain/javascriptSemanticIr.js";
import {
addSemanticGraphNode,
addSemanticGraphRelation,
addSemanticGraphUnknown,
constructSemanticGraphNode,
} from "./JavaScriptSemanticGraphConstruction.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import {
semanticCallSiteAt,
type SemanticFlowProjectionContext,
} from "./JavaScriptSemanticGraphFlowProjection.js";
/** Project literal EventEmitter registrations, removals, and dispatches. */
export const projectSemanticEvents = (
context: SemanticFlowProjectionContext,
): void => {
const eventNodes = new Map<string, JavaScriptSemanticGraphNode>();
for (const operation of context.ir.eventOperations) {
const eventKey =
operation.eventName === null
? operation.eventId
: `${operation.emitterKey}\u0000${operation.eventName}`;
const event =
eventNodes.get(eventKey) ?? addEventNode(context, operation, eventKey);
if (event === null) continue;
eventNodes.set(eventKey, event);
const relation = eventRelation(operation);
if (operation.kind === "dispatch")
addSemanticGraphRelation(context.state, {
source: eventEmitterNode(context, operation),
target: event,
relation,
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
else {
const listener = addListenerNode(context, operation);
addSemanticGraphRelation(context.state, {
source: event,
target: listener,
relation,
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
}
if (operation.resolution !== "complete")
addEventUnknown(context, operation, event, relation);
}
};
const addEventNode = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticEventOperation,
eventKey: string,
): JavaScriptSemanticGraphNode | null =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "event",
roleKey: `event:${eventKey}`,
location: operation.eventName === null ? operation.location : null,
label: operation.eventName,
functionNodeId: null,
properties: {
emitter_key: operation.emitterKey,
event_name: operation.eventName,
},
},
context.state,
),
);
const addListenerNode = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticEventOperation,
): JavaScriptSemanticGraphNode | null =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "listener",
roleKey: `listener:${operation.eventId}`,
location: operation.listenerLocation ?? operation.location,
label:
operation.eventName === null
? operation.method
: `${operation.method}:${operation.eventName}`,
functionNodeId:
operation.ownerCallableId === null
? null
: (context.callableNodes.get(operation.ownerCallableId)?.node_id ??
null),
properties: {
emitter_key: operation.emitterKey,
event_name: operation.eventName,
method: operation.method,
},
},
context.state,
),
);
const eventEmitterNode = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticEventOperation,
): JavaScriptSemanticGraphNode =>
(operation.emitterBindingId === null
? undefined
: context.bindingNodes.get(operation.emitterBindingId)) ??
(operation.ownerCallableId === null
? context.moduleNode
: (context.callableNodes.get(operation.ownerCallableId) ??
context.moduleNode));
const eventRelation = (
operation: JavaScriptSemanticEventOperation,
): "registers-listener" | "removes-listener" | "dispatches-candidate" =>
operation.kind === "register"
? "registers-listener"
: operation.kind === "remove"
? "removes-listener"
: "dispatches-candidate";
const addEventUnknown = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticEventOperation,
event: JavaScriptSemanticGraphNode,
relation: ReturnType<typeof eventRelation>,
): void => {
addSemanticGraphUnknown(
context.state,
createJavaScriptSemanticGraphUnknown({
node_id: event.node_id,
family: "event",
relation_kinds: [relation],
reason: "ambiguous-target",
detail:
operation.eventName === null
? `Dynamic ${operation.method} event name remains unresolved.`
: `Static ${operation.method} emitter identity is partial.`,
candidate_node_ids: [],
evidence: unknownSemanticEvidence(context.file, operation.location),
}),
);
};
/** Project Node timer scheduling and exact local handle cancellation. */
export const projectSemanticTimers = (
context: SemanticFlowProjectionContext,
): void => {
const timerNodes = new Map(
context.ir.timerOperations.flatMap((operation) => {
if (operation.kind !== "schedule") return [];
const owner =
operation.ownerCallableId === null
? undefined
: context.callableNodes.get(operation.ownerCallableId);
const timer = addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "timer",
roleKey: operation.timerId,
location: operation.location,
label: operation.method,
functionNodeId: owner?.node_id ?? null,
properties: {
delay_milliseconds: operation.delayMilliseconds,
method: operation.method,
},
},
context.state,
),
);
return timer === null ? [] : [[operation.timerId, timer] as const];
}),
);
for (const operation of context.ir.timerOperations)
if (operation.kind === "schedule")
projectTimerSchedule(context, operation, timerNodes);
else projectTimerCancellation(context, operation, timerNodes);
};
const projectTimerSchedule = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticTimerOperation,
timerNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): void => {
addSemanticGraphRelation(context.state, {
source: semanticCallSiteAt(context, operation.location),
target: timerNodes.get(operation.timerId),
relation: "schedules-timer",
resolution: "resolved",
});
for (const projected of operation.projectedResultBindings)
addSemanticGraphRelation(context.state, {
source: timerNodes.get(operation.timerId),
target: context.bindingNodes.get(projected.bindingId),
relation: "destructures",
resolution:
projected.resolution === "complete" ? "resolved" : "candidate",
properties: {
projection_path: [...projected.projectionPath],
projection_resolution: projected.resolution,
},
});
};
const projectTimerCancellation = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticTimerOperation,
timerNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): void => {
const callSite = semanticCallSiteAt(context, operation.location);
const target =
operation.linkedTimerId === null
? undefined
: timerNodes.get(operation.linkedTimerId);
addSemanticGraphRelation(context.state, {
source: callSite,
target,
relation: "cancels-timer",
resolution: operation.resolution === "complete" ? "resolved" : "candidate",
});
if (operation.resolution === "complete") return;
addSemanticGraphUnknown(
context.state,
createJavaScriptSemanticGraphUnknown({
node_id: callSite?.node_id ?? null,
family: "timer",
relation_kinds: ["cancels-timer"],
reason: "ambiguous-target",
detail: `Static ${operation.method} handle resolution was ${operation.resolution}.`,
candidate_node_ids: [],
evidence: unknownSemanticEvidence(context.file, operation.location),
}),
);
};
@@ -0,0 +1,430 @@
import { expect, it } from "vitest";
import { buildJavaScriptSemanticGraph } from "./JavaScriptSemanticGraphBuilder.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import { queryJavaScriptSemanticGraph } from "../../domain/javascriptSemanticQuery.js";
import { analyzeJavaScriptSemantics } from "../../domain/javascriptSemanticAnalysis.js";
const SHA256 = "a".repeat(64);
const GRAPH_ID = `jag_${"b".repeat(64)}`;
it("projects closure and direct interprocedural flow without execution", () => {
const graph = graphFor(`
const outer = 2;
function add(value) { return value + outer; }
const input = 40;
const answer = add(input);
`);
const relations = new Set(graph.relations.map(({ relation }) => relation));
for (const expected of [
"argument-to-parameter",
"calls",
"captures",
"defines",
"reads",
"returns-to-call",
] as const)
expect(relations.has(expected)).toBe(true);
expect(graph.limitations).toContain(
"The semantic graph contains static syntax observations and conservative relationship candidates; it does not claim runtime execution.",
);
const parameter = graph.nodes.find(
({ kind, label }) => kind === "parameter" && label === "value",
);
if (parameter === undefined) throw new Error("Expected parameter node");
const provenance = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "semantic-node", node_id: parameter.node_id },
direction: "backward-provenance",
include_ambiguous_dynamic_edges: true,
});
expect(provenance.relations.map(({ relation }) => relation)).toEqual(
expect.arrayContaining([
"aliases",
"argument-to-parameter",
"defines",
"reads",
]),
);
});
it("keeps dynamic calls explicit and returns complete deterministic results", () => {
const graph = graphFor(`
const handlers = { ready() { return 1; } };
const key = process.argv[2];
handlers[key]();
`);
expect(graph.unknowns.some(({ reason }) => reason === "dynamic-call")).toBe(
true,
);
const seedNodeId = graph.relations.find(
({ relation }) => relation === "defines",
)?.source_node_id;
expect(seedNodeId).toBeDefined();
if (seedNodeId === undefined) throw new Error("Expected a definition seed");
const first = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "semantic-node", node_id: seedNodeId },
direction: "forward-influence",
include_ambiguous_dynamic_edges: true,
});
const repeated = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "semantic-node", node_id: seedNodeId },
direction: "forward-influence",
include_ambiguous_dynamic_edges: true,
});
expect(repeated).toEqual(first);
});
it("keeps ambiguous interprocedural flow out of the default traversal", () => {
const graph = graphFor(`
function left(value) { return value; }
function right(value) { return value; }
const input = 1;
const selected = Math.random() ? left : right;
selected(input);
`);
const input = graph.nodes.find(
({ kind, label }) => kind === "binding" && label === "input",
);
if (input === undefined) throw new Error("Expected input binding");
const query = {
seed: { kind: "semantic-node" as const, node_id: input.node_id },
direction: "forward-influence" as const,
allowed_relations: [
"reads" as const,
"aliases" as const,
"argument-to-parameter" as const,
],
};
const strict = queryJavaScriptSemanticGraph(graph, query);
expect(
strict.relations.some(
({ relation }) => relation === "argument-to-parameter",
),
).toBe(false);
expect(strict.status).toBe("ambiguous");
const admitted = queryJavaScriptSemanticGraph(graph, {
...query,
include_ambiguous_dynamic_edges: true,
});
expect(
admitted.relations.filter(
({ relation }) => relation === "argument-to-parameter",
),
).toHaveLength(2);
expect(admitted.status).toBe("ambiguous");
});
it("connects caller results, direct returns, parameters, and captures", () => {
const graph = graphFor(`
const outer = 2;
function identity(value) { return value; }
function readOuter() { return outer; }
const input = 40;
const output = identity(input);
`);
const binding = (label: string) =>
graph.nodes.find(
({ kind, label: nodeLabel }) =>
["binding", "parameter"].includes(kind) && nodeLabel === label,
);
const output = binding("output");
const input = binding("input");
const outer = binding("outer");
const readOuter = graph.nodes.find(
({ kind, label }) => kind === "function" && label === "readOuter",
);
if (
output === undefined ||
input === undefined ||
outer === undefined ||
readOuter === undefined
)
throw new Error("Expected semantic flow nodes");
const provenance = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "semantic-node", node_id: output.node_id },
direction: "backward-provenance",
});
expect(provenance.nodes.map(({ node_id }) => node_id)).toContain(
input.node_id,
);
const influence = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "semantic-node", node_id: outer.node_id },
direction: "forward-influence",
allowed_relations: ["captures"],
});
expect(influence.nodes.map(({ node_id }) => node_id)).toContain(
readOuter.node_id,
);
const capture = graph.relations.find(
({ relation }) => relation === "captures",
);
expect(capture?.evidence.location).toMatchObject({
available: true,
value: { kind: "source-range", source: "app.js" },
});
expect(capture?.evidence.location).not.toEqual(readOuter.evidence.location);
});
it("reports complete graph coverage for deeply nested static values", () => {
const nesting = 40;
const nestedValue =
Array.from({ length: nesting }, () => "{ next: ").join("") +
'"value"' +
" }".repeat(nesting);
const graph = graphFor(`const nested = ${nestedValue};`);
expect(graph.coverage).toMatchObject({
truncated: false,
omitted_nodes: 0,
omitted_relations: 0,
limits: [],
});
});
it("projects bounded Promise ownership and unresolved sources", () => {
const graph = graphFor(`
async function run(value) {
const owned = new Promise((resolve) => resolve(value));
await Promise.resolve(owned);
Promise.resolve(value).then(work).finally(cleanup);
value.then(work);
return Promise.all([owned, Promise.resolve(value)]);
}
`);
const relations = new Set(graph.relations.map(({ relation }) => relation));
for (const expected of [
"aggregates",
"awaits",
"chains",
"creates-promise",
"detaches-task",
"owns",
"returns-task",
] as const)
expect(relations.has(expected)).toBe(true);
expect(graph.nodes.filter(({ kind }) => kind === "promise")).not.toHaveLength(
0,
);
expect(
graph.nodes.find(
({ kind, properties }) =>
kind === "promise" &&
properties.method === "all" &&
properties.ownership === "returned",
),
).toBeDefined();
expect(
graph.unknowns.some(
({ family, reason }) =>
family === "promise-ownership" && reason === "ambiguous-target",
),
).toBe(true);
expect(
graph.coverage.families.find(
({ family }) => family === "promise-ownership",
),
).toMatchObject({ status: "partial" });
});
it("keeps duplicate function fingerprints ambiguous", () => {
const graph = graphFor(`
function first(value) { return value + 1; }
function second(input) { return input + 1; }
`);
expect(graph.fingerprints).toHaveLength(2);
const digest = graph.fingerprints[0]?.fingerprint_sha256;
expect(digest).toBeDefined();
expect(graph.fingerprints[1]?.fingerprint_sha256).toBe(digest);
if (digest === undefined) throw new Error("Expected fingerprint");
const query = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "function", fingerprint_sha256: digest },
direction: "forward-influence",
});
expect(query.status).toBe("ambiguous");
expect(query.summary.total_seed_matches).toBe(2);
});
const graphFor = (source: string) => {
const file: JavaScriptArtifactFile = {
path: "app.js",
container_sha256: SHA256,
sha256: SHA256,
bytes: Buffer.byteLength(source),
inventory_artifact_id: `art_${SHA256}`,
kind: "javascript",
unpacked: false,
text: { included: true, value: source },
};
const analysis: JavaScriptArtifactAnalysis = {
files: [
{
file,
javascript: null,
semantic: {
ir: analyzeJavaScriptSemantics(source),
},
},
],
packages: [],
json_modules: [],
html_scripts: [],
source_maps: [],
visited_ast_nodes: 0,
findings: 0,
modules: 0,
parse_failures: 0,
truncated_scopes: 0,
limitations: [],
};
return buildJavaScriptSemanticGraph({
rootArtifactSha256: SHA256,
applicationGraph: { graph_id: GRAPH_ID, nodes: [] },
analysis,
});
};
it.each([
"const value = consume(Promise.resolve(1));",
"async function run() { await consume(Promise.resolve(1)); }",
"function run() { return consume(Promise.resolve(1)); }",
"const value = Promise.all([{ promise: Promise.resolve(1) }]);",
])(
"does not claim direct Promise ownership through an enclosing expression: %s",
(source) => {
const graph = graphFor(source);
const promise = graph.nodes.find(
({ kind, properties }) =>
kind === "promise" && properties.method === "resolve",
);
expect(promise?.properties.ownership).toBe("unknown");
expect(
graph.relations.filter(
({ target_node_id, relation }) =>
target_node_id === promise?.node_id &&
["owns", "awaits", "returns-task", "aggregates"].includes(relation),
),
).toEqual([]);
},
);
it.each([
"const routes = {'': 'HOME'}; const root = routes[''];",
"const routes = {'': 'HOME'}; const {'': root} = routes;",
])(
"preserves empty property identities with valid display labels: %s",
(source) => {
const graph = graphFor(source);
expect(graph.nodes).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "property-slot",
label: '""',
properties: expect.objectContaining({ name: "" }),
}),
]),
);
const root = graph.nodes.find(
({ kind, label }) => kind === "binding" && label === "root",
);
const literal = graph.nodes.find(
({ kind, identity, properties }) =>
kind === "literal" &&
properties.value === "HOME" &&
identity.role_key.includes(":binding:root:"),
);
expect(literal).toBeDefined();
expect(graph.relations).toEqual(
expect.arrayContaining([
expect.objectContaining({
source_node_id: literal?.node_id,
target_node_id: root?.node_id,
relation: "defines",
resolution: "resolved",
}),
]),
);
},
);
it("keeps exact empty environment keys distinct from dynamic keys", () => {
const graph = graphFor(`
const empty = process.env[''];
const named = process.env.HOME;
const dynamic = process.env[name];
`);
const sources = graph.nodes
.filter(({ kind }) => kind === "config-source")
.map(({ label, properties }) => ({ label, key: properties.key }));
expect(sources).toEqual(
expect.arrayContaining([
{ label: '""', key: "" },
{ label: "HOME", key: "HOME" },
{ label: "environment", key: null },
]),
);
expect(sources).toHaveLength(3);
});
it("labels exact empty method names without inventing computed names", () => {
const graph = graphFor(`
const target = {
['']() { return 42; },
['""']() { return 2; },
[key]() { return 1; },
};
`);
const functions = graph.nodes
.filter(({ kind }) => kind === "function")
.map(({ label, properties }) => ({ label, name: properties.name }));
// Both exact names display as `""`; only `properties.name` tells them apart.
expect(functions).toEqual(
expect.arrayContaining([
{ label: '""', name: "" },
{ label: '""', name: '""' },
]),
);
expect(
functions.filter(({ label }) => label?.startsWith("[computed@")),
).toHaveLength(1);
});
it.each([
"Promise.resolve(1)",
"(Promise.resolve(1) as Promise<number>)",
"(Promise.resolve(1) satisfies Promise<number>)",
"Promise.resolve(1)!",
"((Promise.resolve(1) as Promise<number>)!)",
"(new Promise(resolve => resolve(1)) as Promise<number>)",
"(Promise.all([Promise.resolve(1)]) as Promise<number[]>)",
"(Promise.resolve(1).then(value => value) as Promise<number>)",
])("projects a traversable returned-task link for %s", (expression) => {
const graph = graphFor(`const run = () => ${expression};`);
const promise = graph.nodes.find(
({ kind, properties }) =>
kind === "promise" && properties.ownership === "returned",
);
const relation = graph.relations.find(
({ relation: kind }) => kind === "returns-task",
);
expect(promise).toBeDefined();
expect(relation).toMatchObject({
target_node_id: promise?.node_id,
resolution: "resolved",
});
const returnNode = graph.nodes.find(
({ node_id }) => node_id === relation?.source_node_id,
);
expect(returnNode?.kind).toBe("return-site");
if (returnNode === undefined)
throw new Error("Expected the linked return site");
const query = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "semantic-node", node_id: returnNode.node_id },
direction: "forward-influence",
allowed_relations: ["returns-task"],
});
expect(query.relations).toContainEqual(relation);
expect(query.nodes.map(({ node_id }) => node_id)).toContain(promise?.node_id);
});
@@ -0,0 +1,464 @@
import {
createJavaScriptSemanticGraph,
type JavaScriptSemanticGraph,
type JavaScriptSemanticGraphNode,
} from "../../domain/javascriptSemanticGraph.js";
import {
JAVASCRIPT_SEMANTIC_RELATION_FAMILIES,
JAVASCRIPT_SEMANTIC_RELATION_FAMILY,
} from "../../domain/javascriptSemanticGraphSchemas.js";
import type {
JavaScriptSemanticCallArgument,
JavaScriptSemanticIr,
} from "../../domain/javascriptSemanticIr.js";
import { sourceRangesEqual as rangesEqual } from "../../domain/javascriptStaticAnalysisHelpers.js";
import type { JavaScriptApplicationGraph } from "../../domain/javascriptApplicationGraph.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import {
projectSemanticEvents,
projectSemanticTimers,
} from "./JavaScriptSemanticGraphAsyncProjection.js";
import { projectSemanticChildProcesses } from "./JavaScriptSemanticGraphChildProcessProjection.js";
import {
projectSemanticBoundaries,
projectSemanticConfiguration,
projectSemanticRequests,
} from "./JavaScriptSemanticGraphDataProjection.js";
import { projectSemanticResources } from "./JavaScriptSemanticGraphResourceProjection.js";
import { projectSemanticObjects } from "./JavaScriptSemanticGraphObjectProjection.js";
import { projectSemanticValues } from "./JavaScriptSemanticGraphValueProjection.js";
import { inferredSemanticEvidenceAt } from "./JavaScriptSemanticGraphEvidence.js";
import { projectSemanticFunctionFingerprints } from "./JavaScriptSemanticGraphFingerprintProjection.js";
import {
projectSemanticClosureCaptures,
projectSemanticFrontiers,
projectSemanticPromises,
projectSemanticReturnValues,
type SemanticFlowProjectionContext,
} from "./JavaScriptSemanticGraphFlowProjection.js";
import {
owningSemanticCallableNode,
semanticFamilyStatus,
semanticNodesWithinRange,
} from "./JavaScriptSemanticGraphProjection.js";
import {
addSemanticFallbackRoot as addFallbackRoot,
addSemanticGraphNode as addNode,
addSemanticGraphRelation as addRelation,
constructSemanticGraphNode as semanticNode,
createSemanticGraphProjectionState as emptyState,
type SemanticGraphProjectionState as BuilderState,
} from "./JavaScriptSemanticGraphConstruction.js";
interface BuilderInput {
readonly rootArtifactSha256: string;
readonly applicationGraph: Pick<
JavaScriptApplicationGraph,
"graph_id" | "nodes"
>;
readonly analysis: JavaScriptArtifactAnalysis;
}
interface FileContext extends SemanticFlowProjectionContext {
readonly file: JavaScriptArtifactFile;
readonly ir: JavaScriptSemanticIr;
readonly state: BuilderState;
readonly moduleNode: JavaScriptSemanticGraphNode;
readonly bindingNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
readonly callableNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
readonly callSiteNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
readonly returnSiteNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
readonly argumentNodes: Map<string, JavaScriptSemanticGraphNode>;
readonly referenceNodes: JavaScriptSemanticGraphNode[];
readonly callResolutions: ReadonlyMap<string, "candidate" | "resolved">;
}
/** Project per-file semantic IR into an artifact-bound companion graph. */
export const buildJavaScriptSemanticGraph = ({
rootArtifactSha256,
applicationGraph,
analysis,
}: BuilderInput): JavaScriptSemanticGraph => {
const state = emptyState(applicationGraph);
const fingerprints: JavaScriptSemanticGraph["fingerprints"][number][] = [];
for (const analyzed of analysis.files) {
if (analyzed.semantic === null) continue;
fingerprints.push(
...projectFile(analyzed.file, analyzed.semantic.ir, state),
);
}
if (state.roots.size === 0) addFallbackRoot(rootArtifactSha256, state);
const unknowns = [...state.unknowns.values()];
return createJavaScriptSemanticGraph({
schema: "JavaScriptSemanticRelationGraph",
root_artifact_sha256: rootArtifactSha256,
application_graph_id: applicationGraph.graph_id,
root_node_ids: [...state.roots],
nodes: [...state.nodes.values()],
relations: [...state.relations.values()],
fingerprints,
unknowns,
coverage: {
status: "unknown",
truncated: false,
omitted_nodes: 0,
omitted_relations: 0,
limits: [],
families: JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.map((family) => ({
family,
status: semanticFamilyStatus(family, analysis),
retained_relations: [...state.relations.values()].filter(
(relation) =>
JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation.relation] === family,
).length,
omitted_relations: 0,
unknown_ids: unknowns
.filter((unknown) => unknown.family === family)
.map(({ unknown_id: identifier }) => identifier),
})),
},
limitations: [
"The semantic graph contains static syntax observations and conservative relationship candidates; it does not claim runtime execution.",
"Local data flow does not claim control-flow-sensitive reaching definitions or arbitrary dynamic property resolution.",
"Promise ownership covers explicit unshadowed Promise construction, static factories, aggregation, chaining, and await syntax only.",
"Function fingerprints are static candidates; equal digests can remain ambiguous and do not prove behavioral equivalence.",
"Event extraction covers EventEmitter-style literal registrations, removals, and dispatch candidates; dynamic names remain unknown.",
"Timer extraction covers global or node:timers scheduling and exact local-handle cancellation.",
"Child-process extraction covers asynchronous node:child_process creation, literal argv/env/stdio options, exit/error listeners, and kill signals.",
"Configuration extraction covers process.env, process.argv, node:fs reads, and direct logical defaults.",
"Request extraction covers fetch, WebSocket, node:http/node:https construction, direct option fields, and exact local response consumers.",
"Boundary extraction covers unshadowed JSON/global coercions plus parse and validation method candidates.",
"Resource extraction covers built-in filesystem/network acquisition and exact local close/destroy/end handles.",
],
});
};
const projectFile = (
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
state: BuilderState,
): JavaScriptSemanticGraph["fingerprints"][number][] => {
const moduleNode = addNode(
state,
semanticNode(
file,
{
kind: "module",
roleKey: "module",
location: null,
label: file.path,
functionNodeId: null,
},
state,
),
);
if (moduleNode === null) return [];
state.roots.add(moduleNode.node_id);
const callableNodes = new Map(
ir.callables.flatMap((callable) => {
const node = addNode(
state,
semanticNode(
file,
{
kind: "function",
roleKey: `callable:${callable.callableId}`,
location: callable.location,
label: callable.name,
functionNodeId: null,
// The label is display text; keep the exact name, which may be "".
properties: { name: callable.name },
},
state,
),
);
return node === null ? [] : [[callable.callableId, node] as const];
}),
);
const bindingNodes = new Map(
ir.bindings.flatMap((binding) => {
const location = binding.definitions[0]?.location ?? null;
const kind = binding.kind === "parameter" ? "parameter" : "binding";
const owner = owningSemanticCallableNode(location, ir, callableNodes);
const node = addNode(
state,
semanticNode(
file,
{
kind,
roleKey: `binding:${binding.bindingId}`,
location,
label: binding.name,
functionNodeId: owner?.node_id ?? null,
},
state,
),
);
return node === null ? [] : [[binding.bindingId, node] as const];
}),
);
const returnSiteNodes = createReturnSiteNodes(file, ir, callableNodes, state);
const callSiteNodes = createCallSiteNodes(file, ir, callableNodes, state);
const context: FileContext = {
file,
ir,
state,
moduleNode,
bindingNodes,
callableNodes,
callSiteNodes,
returnSiteNodes,
argumentNodes: new Map(),
referenceNodes: [],
callResolutions: new Map(
ir.callSites.map((call) => [
call.callSiteId,
call.resolution === "exact" ? "resolved" : "candidate",
]),
),
};
projectDefinitionsAndReferences(context);
projectSemanticValues(context);
projectSemanticObjects(context);
projectCalls(context);
projectSemanticPromises(context);
projectSemanticEvents(context);
projectSemanticTimers(context);
projectSemanticChildProcesses(context);
projectSemanticConfiguration(context);
projectSemanticRequests(context);
projectSemanticBoundaries(context);
projectSemanticResources(context);
projectSemanticClosureCaptures(context);
projectSemanticFrontiers(context);
return projectSemanticFunctionFingerprints(file, ir, callableNodes);
};
const createReturnSiteNodes = (
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
callables: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
state: BuilderState,
): Map<string, JavaScriptSemanticGraphNode> => {
const result = new Map<string, JavaScriptSemanticGraphNode>();
for (const callable of ir.callables) {
const owner = callables.get(callable.callableId);
if (owner === undefined) continue;
for (const site of callable.returnSites) {
const identifier = site.returnSiteId;
const node = addNode(
state,
semanticNode(
file,
{
kind: "return-site",
roleKey: identifier,
location: site.location,
label: "return",
functionNodeId: owner.node_id,
},
state,
),
);
if (node !== null) result.set(identifier, node);
}
}
return result;
};
const createCallSiteNodes = (
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
callables: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
state: BuilderState,
): Map<string, JavaScriptSemanticGraphNode> => {
const result = new Map<string, JavaScriptSemanticGraphNode>();
for (const call of ir.callSites) {
const owner =
call.callerCallableId === null
? null
: (callables.get(call.callerCallableId)?.node_id ?? null);
const node = addNode(
state,
semanticNode(
file,
{
kind: "call-site",
roleKey: `call:${call.callSiteId}`,
location: call.location,
label: call.kind,
functionNodeId: owner,
},
state,
),
);
if (node !== null) result.set(call.callSiteId, node);
}
return result;
};
const projectDefinitionsAndReferences = (context: FileContext): void => {
for (const binding of context.ir.bindings) {
const bindingNode = context.bindingNodes.get(binding.bindingId);
if (bindingNode === undefined) continue;
for (const [index, definition] of binding.definitions.entries()) {
const definitionNode = addNode(
context.state,
semanticNode(
context.file,
{
kind: "expression",
roleKey: `definition:${binding.bindingId}:${String(index)}`,
location: definition.location,
label: definition.kind,
functionNodeId: bindingNode.function_node_id,
},
context.state,
),
);
addRelation(context.state, {
source: definitionNode,
target: bindingNode,
relation: "defines",
resolution: "resolved",
});
}
}
for (const [index, reference] of context.ir.references.entries()) {
const expression = addNode(
context.state,
semanticNode(
context.file,
{
kind: "expression",
roleKey: `reference:${String(index)}:${reference.role}:${reference.name}`,
location: reference.location,
label: reference.name,
functionNodeId:
owningSemanticCallableNode(
reference.location,
context.ir,
context.callableNodes,
)?.node_id ?? null,
},
context.state,
),
);
const binding =
reference.bindingId === null
? undefined
: context.bindingNodes.get(reference.bindingId);
if (expression !== null) context.referenceNodes.push(expression);
if (binding === undefined || expression === null) continue;
if (reference.role === "read")
addRelation(context.state, {
source: binding,
target: expression,
relation: "reads",
resolution: "resolved",
evidence: inferredSemanticEvidenceAt(context.file, reference.location),
});
else
addRelation(context.state, {
source: expression,
target: binding,
relation: "writes",
resolution: "resolved",
});
}
};
const projectCalls = (context: FileContext): void => {
for (const call of context.ir.callSites) {
const callNode = context.callSiteNodes.get(call.callSiteId);
if (callNode === undefined) continue;
for (const callableId of call.calleeCallableIds) {
const callable = context.callableNodes.get(callableId);
addRelation(context.state, {
source: callNode,
target: callable,
relation: "calls",
resolution: call.resolution === "exact" ? "resolved" : "candidate",
});
}
for (const argument of call.arguments) {
const argumentNode = createArgumentNode(
context,
callNode,
call.callSiteId,
argument,
);
if (argumentNode !== null)
context.argumentNodes.set(
`${call.callSiteId}\u0000${String(argument.index)}`,
argumentNode,
);
const references = semanticNodesWithinRange(
context.referenceNodes,
argument.location,
);
for (const reference of references)
addRelation(context.state, {
source: reference,
target: argumentNode,
relation: "aliases",
resolution:
references.length === 1 &&
rangesEqual(reference.identity.source_range, argument.location)
? "resolved"
: "candidate",
});
}
}
for (const flow of context.ir.argumentFlows) {
const argument = context.argumentNodes.get(
`${flow.callSiteId}\u0000${String(flow.argumentIndex)}`,
);
const parameter = context.bindingNodes.get(flow.parameterBindingId);
addRelation(context.state, {
source: argument,
target: parameter,
relation: "argument-to-parameter",
resolution: context.callResolutions.get(flow.callSiteId) ?? "candidate",
});
}
for (const flow of context.ir.callReturnFlows)
addRelation(context.state, {
source: context.returnSiteNodes.get(flow.returnSiteId),
target: context.callSiteNodes.get(flow.callSiteId),
relation: "returns-to-call",
resolution: context.callResolutions.get(flow.callSiteId) ?? "candidate",
});
for (const flow of context.ir.callResultFlows)
addRelation(context.state, {
source: context.callSiteNodes.get(flow.callSiteId),
target: context.bindingNodes.get(flow.bindingId),
relation: "aliases",
resolution: context.callResolutions.get(flow.callSiteId) ?? "candidate",
});
projectSemanticReturnValues(context);
};
const createArgumentNode = (
context: FileContext,
callNode: JavaScriptSemanticGraphNode,
callSiteId: string,
argument: JavaScriptSemanticCallArgument,
): JavaScriptSemanticGraphNode | null =>
addNode(
context.state,
semanticNode(
context.file,
{
kind: "expression",
roleKey: `argument:${callSiteId}:${String(argument.index)}`,
location: argument.location,
label: argument.spread
? "spread argument"
: `argument ${String(argument.index)}`,
functionNodeId: callNode.function_node_id,
},
context.state,
),
);
@@ -0,0 +1,269 @@
import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascriptSemanticGraph.js";
import type { JavaScriptSemanticGraphNode } from "../../domain/javascriptSemanticGraph.js";
import type {
JavaScriptSemanticChildProcessInteraction,
JavaScriptSemanticChildProcessSpawn,
} from "../../domain/javascriptSemanticIr.js";
import {
addSemanticGraphNode,
addSemanticGraphRelation,
addSemanticGraphUnknown,
constructSemanticGraphNode,
} from "./JavaScriptSemanticGraphConstruction.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import {
semanticCallSiteAt,
type SemanticFlowProjectionContext,
} from "./JavaScriptSemanticGraphFlowProjection.js";
interface ChildUnknownInput {
readonly context: SemanticFlowProjectionContext;
readonly node: JavaScriptSemanticGraphNode | null;
readonly location: JavaScriptSemanticChildProcessSpawn["location"];
readonly relationKinds: readonly (
| "spawns"
| "forwards-signal"
| "listens-exit"
| "listens-error"
)[];
readonly detail: string;
readonly candidateNodeIds: readonly string[];
}
/** Project child creation, argv/env/stdio, listeners, and signals. */
export const projectSemanticChildProcesses = (
context: SemanticFlowProjectionContext,
): void => {
const processNodes = new Map(
context.ir.childProcessSpawns.flatMap((spawn) => {
const node = addChildProcessNode(context, spawn);
return node === null ? [] : [[spawn.processId, node] as const];
}),
);
for (const spawn of context.ir.childProcessSpawns) {
const child = processNodes.get(spawn.processId);
if (child === undefined) continue;
projectSpawn(context, spawn, child);
}
for (const interaction of context.ir.childProcessInteractions)
projectInteraction(context, interaction, processNodes);
};
const addChildProcessNode = (
context: SemanticFlowProjectionContext,
spawn: JavaScriptSemanticChildProcessSpawn,
): JavaScriptSemanticGraphNode | null =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "child-process",
roleKey: spawn.processId,
location: spawn.location,
label: spawn.command ?? spawn.method,
functionNodeId:
spawn.ownerCallableId === null
? null
: (context.callableNodes.get(spawn.ownerCallableId)?.node_id ??
null),
properties: {
argv_count: spawn.argvCount,
command: spawn.command,
environment_supplied: spawn.environmentSupplied,
method: spawn.method,
stdio_mode: spawn.stdioMode,
},
},
context.state,
),
);
const projectSpawn = (
context: SemanticFlowProjectionContext,
spawn: JavaScriptSemanticChildProcessSpawn,
child: JavaScriptSemanticGraphNode,
): void => {
const callSite = semanticCallSiteAt(context, spawn.location);
addSemanticGraphRelation(context.state, {
source: callSite,
target: child,
relation: "spawns",
resolution: spawn.resolution === "complete" ? "resolved" : "candidate",
});
for (const projected of spawn.projectedResultBindings)
addSemanticGraphRelation(context.state, {
source: child,
target: context.bindingNodes.get(projected.bindingId),
relation: "destructures",
resolution:
projected.resolution === "complete" ? "resolved" : "candidate",
properties: {
projection_path: [...projected.projectionPath],
projection_resolution: projected.resolution,
},
});
if (spawn.argvCount !== null)
addSemanticGraphRelation(context.state, {
source: callSite,
target: child,
relation: "supplies-argv",
resolution: "resolved",
properties: { argument_count: spawn.argvCount },
});
if (spawn.environmentSupplied)
addSemanticGraphRelation(context.state, {
source: callSite,
target: child,
relation: "supplies-env",
resolution: "resolved",
});
const stdio = addStdioNode(context, spawn, child);
addSemanticGraphRelation(context.state, {
source: child,
target: stdio,
relation: "connects-stdio",
resolution: spawn.stdioMode === "dynamic" ? "candidate" : "resolved",
});
if (spawn.resolution !== "complete")
addChildUnknown({
context,
node: child,
location: spawn.location,
relationKinds: ["spawns"],
detail: `Static ${spawn.method} command remains unresolved.`,
candidateNodeIds: [],
});
};
const addStdioNode = (
context: SemanticFlowProjectionContext,
spawn: JavaScriptSemanticChildProcessSpawn,
child: JavaScriptSemanticGraphNode,
): JavaScriptSemanticGraphNode | null =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "stdio",
roleKey: `stdio:${spawn.processId}`,
location: spawn.location,
label: spawn.stdioMode,
functionNodeId: child.function_node_id,
properties: { mode: spawn.stdioMode },
},
context.state,
),
);
const projectInteraction = (
context: SemanticFlowProjectionContext,
interaction: JavaScriptSemanticChildProcessInteraction,
processNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): void => {
const children = interaction.linkedProcessIds.flatMap((identifier) => {
const child = processNodes.get(identifier);
return child === undefined ? [] : [child];
});
const effect =
interaction.kind === "listener"
? addChildListenerNode(context, interaction)
: addSignalNode(context, interaction);
for (const child of children)
addSemanticGraphRelation(context.state, {
source: interaction.kind === "listener" ? child : effect,
target: interaction.kind === "listener" ? effect : child,
relation:
interaction.kind === "signal"
? "forwards-signal"
: interaction.eventName === "exit"
? "listens-exit"
: "listens-error",
resolution:
interaction.resolution === "complete" ? "resolved" : "candidate",
});
if (interaction.resolution !== "complete")
addChildUnknown({
context,
node: effect,
location: interaction.location,
relationKinds: [
interaction.kind === "signal"
? "forwards-signal"
: interaction.eventName === "exit"
? "listens-exit"
: "listens-error",
],
detail: `Static child ${interaction.method} ownership is ambiguous.`,
candidateNodeIds: children.map(({ node_id: identifier }) => identifier),
});
};
const addChildListenerNode = (
context: SemanticFlowProjectionContext,
interaction: JavaScriptSemanticChildProcessInteraction,
): JavaScriptSemanticGraphNode | null =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "listener",
roleKey: interaction.interactionId,
location: interaction.listenerLocation ?? interaction.location,
label: interaction.eventName,
functionNodeId:
interaction.ownerCallableId === null
? null
: (context.callableNodes.get(interaction.ownerCallableId)
?.node_id ?? null),
properties: {
event_name: interaction.eventName,
method: interaction.method,
},
},
context.state,
),
);
const addSignalNode = (
context: SemanticFlowProjectionContext,
interaction: JavaScriptSemanticChildProcessInteraction,
): JavaScriptSemanticGraphNode | null =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "signal",
roleKey: interaction.interactionId,
location: interaction.location,
label: interaction.signalName,
functionNodeId:
interaction.ownerCallableId === null
? null
: (context.callableNodes.get(interaction.ownerCallableId)
?.node_id ?? null),
properties: { signal_name: interaction.signalName },
},
context.state,
),
);
const addChildUnknown = (input: ChildUnknownInput): void => {
const { context, node, location, relationKinds, detail, candidateNodeIds } =
input;
addSemanticGraphUnknown(
context.state,
createJavaScriptSemanticGraphUnknown({
node_id: node?.node_id ?? null,
family: "child-process",
relation_kinds: relationKinds,
reason: "ambiguous-target",
detail,
candidate_node_ids: candidateNodeIds,
evidence: unknownSemanticEvidence(context.file, location),
}),
);
};
@@ -0,0 +1,225 @@
import type { JavaScriptApplicationGraph } from "../../domain/javascriptApplicationGraph.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { javascriptDisplayText } from "../../domain/javascriptAstValues.js";
import {
createJavaScriptSemanticGraphNode,
createJavaScriptSemanticGraphRelation,
type JavaScriptSemanticGraphNode,
} from "../../domain/javascriptSemanticGraph.js";
import type { ApplicationGraphEvidence } from "../../domain/javascriptApplicationEvidenceSchemas.js";
import type {
JavaScriptSemanticGraphRelation,
JavaScriptSemanticGraphUnknown,
} from "../../domain/javascriptSemanticGraphSchemas.js";
import type { JavaScriptSourceRange } from "../../domain/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import {
inferredSemanticEvidence,
observedSemanticEvidence,
unavailableSemanticRootEvidence,
} from "./JavaScriptSemanticGraphEvidence.js";
/** Mutable local projection state hidden from graph callers. */
export interface SemanticGraphProjectionState {
readonly nodes: Map<string, JavaScriptSemanticGraphNode>;
readonly relations: Map<string, JavaScriptSemanticGraphRelation>;
readonly unknowns: Map<string, JavaScriptSemanticGraphUnknown>;
readonly roots: Set<string>;
readonly applicationNodeIdsByLocation: ReadonlyMap<string, readonly string[]>;
}
/** Input for one exact artifact-version semantic node. */
export interface SemanticNodeConstructionInput {
readonly kind: JavaScriptSemanticGraphNode["kind"];
readonly roleKey: string;
readonly location: JavaScriptSourceRange | null;
readonly label: string | null;
readonly functionNodeId: string | null;
readonly properties?: Readonly<Record<string, JsonValue>>;
}
/** Input for one directed static semantic relationship. */
export interface SemanticRelationConstructionInput {
readonly source: JavaScriptSemanticGraphNode | undefined | null;
readonly target: JavaScriptSemanticGraphNode | undefined | null;
readonly relation: JavaScriptSemanticGraphRelation["relation"];
readonly resolution?: JavaScriptSemanticGraphRelation["resolution"];
readonly evidence?: ApplicationGraphEvidence;
readonly properties?: Readonly<Record<string, JsonValue>>;
}
/** Create empty projection state with structural application-node mappings. */
export const createSemanticGraphProjectionState = (
applicationGraph: Pick<JavaScriptApplicationGraph, "nodes">,
): SemanticGraphProjectionState => {
const index = indexApplicationNodes(applicationGraph.nodes);
return {
nodes: new Map(),
relations: new Map(),
unknowns: new Map(),
roots: new Set(),
applicationNodeIdsByLocation: index.identifiers,
};
};
/** Construct one canonical semantic node backed by an exact artifact file. */
export const constructSemanticGraphNode = (
file: JavaScriptArtifactFile,
input: SemanticNodeConstructionInput,
state: SemanticGraphProjectionState,
): JavaScriptSemanticGraphNode =>
createJavaScriptSemanticGraphNode({
kind: input.kind,
identity: {
artifact_sha256: file.sha256,
module_path: file.path,
source_range: input.location,
role_key: input.roleKey,
},
function_node_id: input.functionNodeId,
application_node_ids: matchingApplicationNodeIds(file, input, state),
label: input.label === null ? null : javascriptDisplayText(input.label),
properties: input.properties ?? {},
evidence: observedSemanticEvidence(file, input.location),
});
/** Retain one canonical semantic node. */
export const addSemanticGraphNode = (
state: SemanticGraphProjectionState,
node: JavaScriptSemanticGraphNode,
): JavaScriptSemanticGraphNode | null => {
const existing = state.nodes.get(node.node_id);
if (existing !== undefined) return existing;
state.nodes.set(node.node_id, node);
return node;
};
/** Retain one non-self semantic relationship. */
export const addSemanticGraphRelation = (
state: SemanticGraphProjectionState,
input: SemanticRelationConstructionInput,
): void => {
if (
input.source === undefined ||
input.source === null ||
input.target === undefined ||
input.target === null ||
input.source.node_id === input.target.node_id
)
return;
const value = createJavaScriptSemanticGraphRelation({
source_node_id: input.source.node_id,
target_node_id: input.target.node_id,
relation: input.relation,
resolution: input.resolution ?? "candidate",
properties: input.properties ?? {},
evidence: input.evidence ?? inferredSemanticEvidence(input.source),
});
if (state.relations.has(value.relation_id)) return;
state.relations.set(value.relation_id, value);
};
/** Retain one unresolved frontier. */
export const addSemanticGraphUnknown = (
state: SemanticGraphProjectionState,
unknown: JavaScriptSemanticGraphUnknown,
): void => {
if (state.unknowns.has(unknown.unknown_id)) return;
state.unknowns.set(unknown.unknown_id, unknown);
};
/** Add a truthful root when no source file could produce semantic IR. */
export const addSemanticFallbackRoot = (
rootArtifactSha256: string,
state: SemanticGraphProjectionState,
): void => {
const node = addSemanticGraphNode(
state,
createJavaScriptSemanticGraphNode({
kind: "module",
identity: {
artifact_sha256: rootArtifactSha256,
module_path: "unknown-semantic-root",
source_range: null,
role_key: "artifact-root",
},
function_node_id: null,
application_node_ids: [],
label: "unavailable semantic root",
properties: {},
evidence: unavailableSemanticRootEvidence(rootArtifactSha256),
}),
);
if (node !== null) state.roots.add(node.node_id);
};
const indexApplicationNodes = (
nodes: JavaScriptApplicationGraph["nodes"],
): {
readonly identifiers: ReadonlyMap<string, readonly string[]>;
} => {
const identifiers = new Map<string, Set<string>>();
for (const node of nodes) {
for (const observation of node.observations) {
const { artifact, location } = observation.evidence;
if (!artifact.available || !location.available) continue;
if (location.value.kind === "artifact-path") {
addApplicationNodeIdentifier(
identifiers,
applicationLocationKey(artifact.sha256, location.value.path, null),
node.node_id,
);
}
if (location.value.kind === "source-range") {
addApplicationNodeIdentifier(
identifiers,
applicationLocationKey(artifact.sha256, location.value.source, {
start: location.value.start,
end: location.value.end,
}),
node.node_id,
);
}
}
}
const indexed = new Map(
[...identifiers].map(([key, values]) => {
const sorted = [...values].sort();
return [key, sorted];
}),
);
return { identifiers: indexed };
};
const matchingApplicationNodeIds = (
file: JavaScriptArtifactFile,
input: SemanticNodeConstructionInput,
state: SemanticGraphProjectionState,
): string[] => [
...(state.applicationNodeIdsByLocation.get(
applicationLocationKey(file.sha256, file.path, input.location),
) ?? []),
];
const addApplicationNodeIdentifier = (
identifiers: Map<string, Set<string>>,
key: string,
nodeId: string,
): void => {
const values = identifiers.get(key) ?? new Set<string>();
values.add(nodeId);
identifiers.set(key, values);
};
const applicationLocationKey = (
artifactSha256: string,
path: string,
range: JavaScriptSourceRange | null,
): string =>
[
artifactSha256,
path,
range === null
? "artifact-path"
: `${range.start.line}:${range.start.column}-${range.end.line}:${range.end.column}`,
].join("\u0000");
@@ -0,0 +1,345 @@
import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascriptSemanticGraph.js";
import type { JavaScriptSemanticGraphNode } from "../../domain/javascriptSemanticGraph.js";
import type {
JavaScriptSemanticBoundaryOperation,
JavaScriptSemanticConfigurationOperation,
JavaScriptSemanticRequestOperation,
} from "../../domain/javascriptSemanticIr.js";
import {
addSemanticGraphNode,
addSemanticGraphRelation,
addSemanticGraphUnknown,
constructSemanticGraphNode,
} from "./JavaScriptSemanticGraphConstruction.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import {
semanticCallSiteAt,
type SemanticFlowProjectionContext,
} from "./JavaScriptSemanticGraphFlowProjection.js";
/** Project environment, argv, file, default, and precedence candidates. */
export const projectSemanticConfiguration = (
context: SemanticFlowProjectionContext,
): void => {
const configNodes = new Map(
context.ir.configurationOperations.flatMap((operation) => {
const node = addConfigurationNode(context, operation);
return node === null ? [] : [[operation.configId, node] as const];
}),
);
for (const operation of context.ir.configurationOperations) {
const config = configNodes.get(operation.configId);
if (config === undefined) continue;
const target = bindingOrOwner(
context,
operation.resultBindingId,
operation.ownerCallableId,
);
addSemanticGraphRelation(context.state, {
source: config,
target,
relation: configurationRelation(operation),
resolution:
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:
operation.resolution === "complete" ? "resolved" : "candidate",
});
if (operation.resolution !== "complete")
addDataUnknown({
context,
node: config,
family: "configuration",
relationKinds: [
configurationRelation(operation),
...(operation.kind === "default" ? (["overrides"] as const) : []),
],
location: operation.location,
detail: `Static ${operation.kind} configuration identity is partial.`,
candidateNodeIds: [],
});
}
};
const addConfigurationNode = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticConfigurationOperation,
): JavaScriptSemanticGraphNode | null =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "config-source",
roleKey: operation.configId,
location: operation.location,
label: operation.key ?? operation.kind,
functionNodeId:
operation.ownerCallableId === null
? null
: (context.callableNodes.get(operation.ownerCallableId)?.node_id ??
null),
properties: {
key: operation.key,
source_kind: operation.kind,
value: operation.value,
},
},
context.state,
),
);
const configurationRelation = (
operation: JavaScriptSemanticConfigurationOperation,
): "reads-environment" | "reads-argv" | "reads-config" | "defaults" =>
operation.kind === "environment"
? "reads-environment"
: operation.kind === "argv"
? "reads-argv"
: operation.kind === "file"
? "reads-config"
: "defaults";
/** Project request construction, field provenance, and response consumers. */
export const projectSemanticRequests = (
context: SemanticFlowProjectionContext,
): void => {
const requestNodes = new Map(
context.ir.requestOperations.flatMap((operation) => {
const node = addRequestNode(context, operation);
return node === null ? [] : [[operation.requestId, node] as const];
}),
);
for (const operation of context.ir.requestOperations) {
const request = requestNodes.get(operation.requestId);
if (request === undefined) continue;
if (operation.kind === "request")
projectRequestConstruction(context, operation, request);
else projectResponseConsumer(context, operation, request);
if (operation.resolution !== "complete")
addDataUnknown({
context,
node: request,
family: "request",
relationKinds:
operation.kind === "request"
? ["constructs-request", "supplies-request-field"]
: ["consumed-by"],
location: operation.location,
detail: `Static ${operation.method} request resolution was ${operation.resolution}.`,
candidateNodeIds: operation.linkedRequestIds.flatMap((identifier) => {
const candidate = requestNodes.get(identifier);
return candidate === undefined ? [] : [candidate.node_id];
}),
});
}
};
const addRequestNode = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticRequestOperation,
): JavaScriptSemanticGraphNode | null =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: operation.kind === "request" ? "request" : "response",
roleKey: operation.requestId,
location: operation.location,
label: operation.endpoint ?? operation.method,
functionNodeId:
operation.ownerCallableId === null
? null
: (context.callableNodes.get(operation.ownerCallableId)?.node_id ??
null),
properties: {
endpoint: operation.endpoint,
method: operation.method,
operation_kind: operation.kind,
},
},
context.state,
),
);
const projectRequestConstruction = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticRequestOperation,
request: JavaScriptSemanticGraphNode,
): void => {
const callSite = semanticCallSiteAt(context, operation.location);
addSemanticGraphRelation(context.state, {
source: callSite,
target: request,
relation: "constructs-request",
resolution: operation.resolution === "complete" ? "resolved" : "candidate",
});
for (const projected of operation.projectedResultBindings)
addSemanticGraphRelation(context.state, {
source: request,
target: context.bindingNodes.get(projected.bindingId),
relation: "destructures",
resolution:
projected.resolution === "complete" ? "resolved" : "candidate",
properties: {
projection_path: [...projected.projectionPath],
projection_resolution: projected.resolution,
},
});
for (const field of operation.fields)
addSemanticGraphRelation(context.state, {
source:
field.sourceBindingId === null
? callSite
: context.bindingNodes.get(field.sourceBindingId),
target: request,
relation: "supplies-request-field",
resolution: field.sourceBindingId === null ? "candidate" : "resolved",
properties: { field: field.name },
});
};
const projectResponseConsumer = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticRequestOperation,
response: JavaScriptSemanticGraphNode,
): void => {
addSemanticGraphRelation(context.state, {
source: response,
target: semanticCallSiteAt(context, operation.location),
relation: "consumed-by",
resolution: operation.resolution === "complete" ? "resolved" : "candidate",
properties: { method: operation.method },
});
};
/** Project parse, coercion, and validation consumers as static boundaries. */
export const projectSemanticBoundaries = (
context: SemanticFlowProjectionContext,
): void => {
for (const operation of context.ir.boundaryOperations) {
const boundary = addBoundaryNode(context, operation);
if (boundary === null) continue;
addSemanticGraphRelation(context.state, {
source: bindingOrOwner(
context,
operation.sourceBindingId,
operation.ownerCallableId,
),
target: boundary,
relation:
operation.kind === "parse"
? "parses"
: operation.kind === "coerce"
? "coerces"
: "validates",
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
if (operation.resolution !== "complete")
addDataUnknown({
context,
node: boundary,
family: "boundary",
relationKinds: [
operation.kind === "parse"
? "parses"
: operation.kind === "coerce"
? "coerces"
: "validates",
],
location: operation.location,
detail: `Static ${operation.method} boundary kind is a candidate.`,
candidateNodeIds: [],
});
}
};
const addBoundaryNode = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticBoundaryOperation,
): JavaScriptSemanticGraphNode | null =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "boundary",
roleKey: operation.boundaryId,
location: operation.location,
label: operation.method,
functionNodeId:
operation.ownerCallableId === null
? null
: (context.callableNodes.get(operation.ownerCallableId)?.node_id ??
null),
properties: {
boundary_kind: operation.kind,
method: operation.method,
},
},
context.state,
),
);
const bindingOrOwner = (
context: SemanticFlowProjectionContext,
bindingId: string | null,
callableId: string | null,
): JavaScriptSemanticGraphNode =>
(bindingId === null ? undefined : context.bindingNodes.get(bindingId)) ??
(callableId === null
? context.moduleNode
: (context.callableNodes.get(callableId) ?? context.moduleNode));
interface DataUnknownInput {
readonly context: SemanticFlowProjectionContext;
readonly node: JavaScriptSemanticGraphNode;
readonly family: "boundary" | "configuration" | "request";
readonly relationKinds: readonly (
| "coerces"
| "constructs-request"
| "consumed-by"
| "defaults"
| "overrides"
| "parses"
| "reads-argv"
| "reads-config"
| "reads-environment"
| "supplies-request-field"
| "validates"
)[];
readonly location: JavaScriptSemanticBoundaryOperation["location"];
readonly detail: string;
readonly candidateNodeIds: readonly string[];
}
const addDataUnknown = (input: DataUnknownInput): void => {
const {
context,
node,
family,
relationKinds,
location,
detail,
candidateNodeIds,
} = input;
addSemanticGraphUnknown(
context.state,
createJavaScriptSemanticGraphUnknown({
node_id: node.node_id,
family,
relation_kinds: relationKinds,
reason: "ambiguous-target",
detail,
candidate_node_ids: candidateNodeIds,
evidence: unknownSemanticEvidence(context.file, location),
}),
);
};
@@ -0,0 +1,111 @@
import type { ApplicationGraphEvidence } from "../../domain/javascriptApplicationEvidenceSchemas.js";
import type { JavaScriptSemanticGraphNode } from "../../domain/javascriptSemanticGraph.js";
import type { JavaScriptSourceRange } from "../../domain/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
const INFERENCE_LIMITATION =
"This relationship is a conservative static candidate; it does not prove runtime execution or causal flow.";
/** Exact artifact-backed syntax evidence for one semantic graph node. */
export const observedSemanticEvidence = (
file: JavaScriptArtifactFile,
location: JavaScriptSourceRange | null,
): ApplicationGraphEvidence => ({
authority: "ast-static-analysis",
state: "observed",
confidence: "exact",
artifact: {
available: true,
artifact_id: `art_${file.sha256}`,
sha256: file.sha256,
},
location:
location === null
? { available: true, value: { kind: "artifact-path", path: file.path } }
: {
available: true,
value: { kind: "source-range", source: file.path, ...location },
},
extractor: {
name: "rea-javascript-semantic-analysis",
version: "1",
operation: "analyze_javascript_application",
executable_sha256: null,
},
coverage: {
status: "complete",
truncated: false,
omitted_count: 0,
limits: [],
},
limitations: [],
evidence_ids: [],
});
/** Conservative static relationship evidence derived from one syntax node. */
export const inferredSemanticEvidence = (
source: JavaScriptSemanticGraphNode,
): ApplicationGraphEvidence => ({
...source.evidence,
authority: "static-relationship-inference",
state: "inferred",
confidence: "high",
limitations: [INFERENCE_LIMITATION],
});
/** Static relationship evidence anchored at the syntax that proves the link. */
export const inferredSemanticEvidenceAt = (
file: JavaScriptArtifactFile,
location: JavaScriptSourceRange,
): ApplicationGraphEvidence => ({
...observedSemanticEvidence(file, location),
authority: "static-relationship-inference",
state: "inferred",
confidence: "high",
limitations: [INFERENCE_LIMITATION],
});
/** Explicit absence of semantic syntax for an artifact-level fallback root. */
export const unavailableSemanticRootEvidence = (
rootArtifactSha256: string,
): ApplicationGraphEvidence => ({
authority: "unknown",
state: "unavailable",
confidence: "unknown",
artifact: {
available: true,
artifact_id: `art_${rootArtifactSha256}`,
sha256: rootArtifactSha256,
},
location: {
available: false,
reason: "not-observed",
detail: "No admitted JavaScript source produced semantic IR.",
},
extractor: {
name: "rea-javascript-semantic-analysis",
version: "1",
operation: "analyze_javascript_application",
executable_sha256: null,
},
coverage: {
status: "unavailable",
truncated: false,
omitted_count: null,
limits: [],
},
limitations: ["This synthetic graph root does not claim a source location."],
evidence_ids: [],
});
/** Explicit unknown evidence at one unresolved dynamic syntax location. */
export const unknownSemanticEvidence = (
file: JavaScriptArtifactFile,
location: JavaScriptSourceRange,
): ApplicationGraphEvidence => ({
...observedSemanticEvidence(file, location),
authority: "unknown",
state: "unknown",
confidence: "unknown",
limitations: ["Dynamic syntax prevents an exact static relationship."],
});
@@ -0,0 +1,39 @@
import {
createJavaScriptSemanticFingerprint,
type JavaScriptSemanticGraph,
type JavaScriptSemanticGraphNode,
} from "../../domain/javascriptSemanticGraph.js";
import type { JavaScriptSemanticIr } from "../../domain/javascriptSemanticIr.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import { observedSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
/** Bind domain fingerprint components to exact semantic function nodes. */
export const projectSemanticFunctionFingerprints = (
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
callableNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): JavaScriptSemanticGraph["fingerprints"][number][] =>
ir.functionFingerprints.flatMap((fingerprint) => {
const callable = callableNodes.get(fingerprint.callableId);
if (callable === undefined) return [];
return [
createJavaScriptSemanticFingerprint({
function_node_id: callable.node_id,
algorithm: "rea.javascript-semantic-function",
status: fingerprint.status,
components: {
parameter_arity: fingerprint.components.parameterArity,
normalized_ast_sha256: fingerprint.components.normalizedAstSha256,
control_flow_sha256: fingerprint.components.controlFlowSha256,
relation_shape_sha256: fingerprint.components.relationShapeSha256,
literal_set_sha256: fingerprint.components.literalSetSha256,
effects: fingerprint.components.effects,
},
limitations: fingerprint.limitations,
evidence: observedSemanticEvidence(
file,
callable.identity.source_range,
),
}),
];
});
@@ -0,0 +1,291 @@
import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascriptSemanticGraph.js";
import type { JavaScriptSemanticGraphNode } from "../../domain/javascriptSemanticGraph.js";
import type { JavaScriptSemanticIr } from "../../domain/javascriptSemanticIr.js";
import { sourceRangesEqual } from "../../domain/javascriptStaticAnalysisHelpers.js";
import type { JavaScriptSourceRange } from "../../domain/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import {
addSemanticGraphNode,
addSemanticGraphRelation,
addSemanticGraphUnknown,
constructSemanticGraphNode,
type SemanticGraphProjectionState,
} from "./JavaScriptSemanticGraphConstruction.js";
import {
inferredSemanticEvidenceAt,
unknownSemanticEvidence,
} from "./JavaScriptSemanticGraphEvidence.js";
import { semanticNodesWithinRange } from "./JavaScriptSemanticGraphProjection.js";
/** File-local graph state needed by return, capture, and frontier projection. */
export interface SemanticFlowProjectionContext {
readonly file: JavaScriptArtifactFile;
readonly ir: JavaScriptSemanticIr;
readonly state: SemanticGraphProjectionState;
readonly moduleNode: JavaScriptSemanticGraphNode;
readonly bindingNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
readonly callableNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
readonly callSiteNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
readonly returnSiteNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
readonly referenceNodes: readonly JavaScriptSemanticGraphNode[];
}
/** Find the retained call-site node at one exact semantic source range. */
export const semanticCallSiteAt = (
context: SemanticFlowProjectionContext,
location: JavaScriptSourceRange,
): JavaScriptSemanticGraphNode | undefined => {
const call = context.ir.callSites.find(({ location: candidate }) =>
sourceRangesEqual(candidate, location),
);
return call === undefined
? undefined
: context.callSiteNodes.get(call.callSiteId);
};
/** Project explicit static Promise/task ownership without runtime claims. */
export const projectSemanticPromises = (
context: SemanticFlowProjectionContext,
): void => {
const promiseNodes = new Map(
context.ir.promiseOperations.flatMap((operation) => {
const owner =
operation.ownerCallableId === null
? undefined
: context.callableNodes.get(operation.ownerCallableId);
const node = addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: operation.kind === "awaited-expression" ? "task" : "promise",
roleKey: operation.promiseId,
location: operation.location,
label: promiseLabel(operation),
functionNodeId: owner?.node_id ?? null,
properties: {
method: operation.method,
operation_kind: operation.kind,
ownership: operation.ownership,
source_resolution: operation.sourceResolution,
},
},
context.state,
),
);
return node === null ? [] : [[operation.promiseId, node] as const];
}),
);
for (const operation of context.ir.promiseOperations) {
const promise = promiseNodes.get(operation.promiseId);
if (promise === undefined) continue;
projectPromiseCreation(context, operation, promise);
projectPromiseSources(context, operation, promise, promiseNodes);
projectPromiseOwnership(context, operation, promise);
if (
operation.sourceResolution !== "complete" &&
["chain", "aggregate", "awaited-expression"].includes(operation.kind)
)
addPromiseUnknown(context, operation, promise, promiseNodes);
}
};
const promiseLabel = (
operation: JavaScriptSemanticIr["promiseOperations"][number],
): string =>
operation.kind === "awaited-expression"
? "awaited expression"
: `Promise.${operation.method}`;
const projectPromiseCreation = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticIr["promiseOperations"][number],
promise: JavaScriptSemanticGraphNode,
): void => {
if (operation.kind === "awaited-expression") return;
addSemanticGraphRelation(context.state, {
source: semanticCallSiteAt(context, operation.location),
target: promise,
relation: "creates-promise",
resolution:
operation.kind === "chain" || operation.sourceResolution !== "complete"
? "candidate"
: "resolved",
});
};
const projectPromiseSources = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticIr["promiseOperations"][number],
promise: JavaScriptSemanticGraphNode,
promiseNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): void => {
for (const sourceId of operation.sourcePromiseIds) {
const source = promiseNodes.get(sourceId);
if (operation.kind === "aggregate")
addSemanticGraphRelation(context.state, {
source: promise,
target: source,
relation: "aggregates",
resolution:
operation.sourceResolution === "complete" ? "resolved" : "candidate",
});
else
addSemanticGraphRelation(context.state, {
source,
target: promise,
relation: "chains",
resolution:
operation.sourceResolution === "complete" ? "resolved" : "candidate",
});
}
};
const projectPromiseOwnership = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticIr["promiseOperations"][number],
promise: JavaScriptSemanticGraphNode,
): void => {
const owner =
operation.ownerCallableId === null
? context.moduleNode
: context.callableNodes.get(operation.ownerCallableId);
if (operation.ownership === "awaited")
addSemanticGraphRelation(context.state, {
source: owner,
target: promise,
relation: "awaits",
resolution: "resolved",
});
else if (operation.ownership === "assigned")
addSemanticGraphRelation(context.state, {
source:
operation.ownerBindingId === null
? undefined
: context.bindingNodes.get(operation.ownerBindingId),
target: promise,
relation: "owns",
resolution: "resolved",
});
else if (operation.ownership === "returned")
addSemanticGraphRelation(context.state, {
source:
operation.returnSiteId === null
? undefined
: context.returnSiteNodes.get(operation.returnSiteId),
target: promise,
relation: "returns-task",
resolution: "resolved",
});
else if (operation.ownership === "detached")
addSemanticGraphRelation(context.state, {
source: owner,
target: promise,
relation: "detaches-task",
resolution: "resolved",
});
};
const addPromiseUnknown = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticIr["promiseOperations"][number],
promise: JavaScriptSemanticGraphNode,
promiseNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): void => {
const relation = operation.kind === "aggregate" ? "aggregates" : "chains";
addSemanticGraphUnknown(
context.state,
createJavaScriptSemanticGraphUnknown({
node_id: promise.node_id,
family: "promise-ownership",
relation_kinds: [relation],
reason: "ambiguous-target",
detail: `Static ${operation.method} source resolution was ${operation.sourceResolution}.`,
candidate_node_ids: operation.sourcePromiseIds.flatMap((identifier) => {
const candidate = promiseNodes.get(identifier);
return candidate === undefined ? [] : [candidate.node_id];
}),
evidence: unknownSemanticEvidence(context.file, operation.location),
}),
);
};
/** Link direct return expressions to their retained semantic references. */
export const projectSemanticReturnValues = (
context: SemanticFlowProjectionContext,
): void => {
for (const callable of context.ir.callables)
for (const site of callable.returnSites) {
const returnNode = context.returnSiteNodes.get(site.returnSiteId);
const references = semanticNodesWithinRange(
context.referenceNodes,
site.location,
);
for (const reference of references)
addSemanticGraphRelation(context.state, {
source: reference,
target: returnNode,
relation: "aliases",
resolution:
site.identityReferenceLocation !== null &&
sourceRangesEqual(
reference.identity.source_range,
site.identityReferenceLocation,
)
? "resolved"
: "candidate",
});
}
};
/** Link each captured binding to the callable at the exact reference site. */
export const projectSemanticClosureCaptures = (
context: SemanticFlowProjectionContext,
): void => {
for (const capture of context.ir.closureCaptures)
addSemanticGraphRelation(context.state, {
source: context.bindingNodes.get(capture.bindingId),
target: context.callableNodes.get(capture.callableId),
relation: "captures",
resolution: "resolved",
evidence: inferredSemanticEvidenceAt(
context.file,
capture.referenceLocation,
),
});
};
/** Retain bounded unresolved dynamic-call, dynamic-property and dynamic-scope frontiers. */
export const projectSemanticFrontiers = (
context: SemanticFlowProjectionContext,
): void => {
for (const frontier of context.ir.frontiers) {
// Dynamic environments leave identifier reads and writes unresolved.
const family =
frontier.kind === "dynamic-call"
? "call-flow"
: frontier.kind === "dynamic-scope"
? "data-flow"
: "object-flow";
const relationKinds =
frontier.kind === "dynamic-call"
? (["calls"] as const)
: frontier.kind === "dynamic-scope"
? (["reads", "writes"] as const)
: (["reads-property", "writes-property"] as const);
const unknown = createJavaScriptSemanticGraphUnknown({
node_id:
frontier.callableId === null
? context.moduleNode.node_id
: (context.callableNodes.get(frontier.callableId)?.node_id ??
context.moduleNode.node_id),
family,
relation_kinds: [...relationKinds],
reason: frontier.kind,
detail: frontier.reason,
candidate_node_ids: [],
evidence: unknownSemanticEvidence(context.file, frontier.location),
});
addSemanticGraphUnknown(context.state, unknown);
}
};
@@ -0,0 +1,126 @@
import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascriptSemanticGraph.js";
import type { JavaScriptSemanticGraphNode } from "../../domain/javascriptSemanticGraph.js";
import type { JavaScriptSemanticObjectOperation } from "../../domain/javascriptSemanticIr.js";
import {
addSemanticGraphNode,
addSemanticGraphRelation,
addSemanticGraphUnknown,
constructSemanticGraphNode,
} from "./JavaScriptSemanticGraphConstruction.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
import { semanticPropertySlot } from "./JavaScriptSemanticGraphValueProjection.js";
/** Project static object reads, writes, spreads, and destructuring. */
export const projectSemanticObjects = (
context: SemanticFlowProjectionContext,
): void => {
for (const operation of context.ir.objectOperations) {
const occurrence = addObjectOccurrence(context, operation);
const slot =
operation.objectBindingId === null || operation.propertyName === null
? null
: semanticPropertySlot(
context,
operation.objectBindingId,
operation.propertyName,
);
if (operation.kind === "read")
addSemanticGraphRelation(context.state, {
source: slot,
target: occurrence,
relation: "reads-property",
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
else if (operation.kind === "write")
addSemanticGraphRelation(context.state, {
source: occurrence,
target: slot,
relation: "writes-property",
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
else if (operation.kind === "destructure")
addSemanticGraphRelation(context.state, {
source: slot,
target:
operation.targetBindingId === null
? undefined
: context.bindingNodes.get(operation.targetBindingId),
relation: "destructures",
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
else
addSemanticGraphRelation(context.state, {
source:
operation.objectBindingId === null
? undefined
: context.bindingNodes.get(operation.objectBindingId),
target: occurrence,
relation: "spreads",
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
if (operation.resolution !== "complete")
addObjectUnknown(context, operation, slot ?? occurrence);
}
};
const addObjectOccurrence = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticObjectOperation,
): JavaScriptSemanticGraphNode | null =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "expression",
roleKey: operation.objectOperationId,
location: operation.location,
label:
operation.propertyName === null
? operation.kind
: `${operation.kind}:${operation.propertyName}`,
functionNodeId:
operation.ownerCallableId === null
? null
: (context.callableNodes.get(operation.ownerCallableId)?.node_id ??
null),
properties: {
operation_kind: operation.kind,
property_name: operation.propertyName,
},
},
context.state,
),
);
const addObjectUnknown = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticObjectOperation,
node: JavaScriptSemanticGraphNode | null,
): void => {
const relation =
operation.kind === "read"
? "reads-property"
: operation.kind === "write"
? "writes-property"
: operation.kind === "spread"
? "spreads"
: "destructures";
addSemanticGraphUnknown(
context.state,
createJavaScriptSemanticGraphUnknown({
node_id: node?.node_id ?? null,
family: "object-flow",
relation_kinds: [relation],
reason: "ambiguous-target",
detail: `Static ${operation.kind} object identity is partial.`,
candidate_node_ids: [],
evidence: unknownSemanticEvidence(context.file, operation.location),
}),
);
};
@@ -0,0 +1,76 @@
import { compareCodePoints } from "../../domain/canonicalOrdering.js";
import type { JavaScriptSemanticGraphNode } from "../../domain/javascriptSemanticGraph.js";
import { JAVASCRIPT_SEMANTIC_RELATION_FAMILIES } from "../../domain/javascriptSemanticGraphSchemas.js";
import type { JavaScriptSemanticIr } from "../../domain/javascriptSemanticIr.js";
import type { JavaScriptSourceRange } from "../../domain/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
/** Find the innermost callable that contains one exact source range. */
export const owningSemanticCallableNode = (
location: JavaScriptSourceRange | null,
ir: JavaScriptSemanticIr,
nodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): JavaScriptSemanticGraphNode | undefined => {
if (location === null) return undefined;
return ir.callables
.filter((callable) => contains(callable.location, location))
.sort((left, right) =>
compareCodePoints(
positionKey(right.location),
positionKey(left.location),
),
)
.map(({ callableId }) => nodes.get(callableId))
.find((node) => node !== undefined);
};
/** Find retained semantic nodes whose exact ranges are within one expression. */
export const semanticNodesWithinRange = (
nodes: readonly JavaScriptSemanticGraphNode[],
range: JavaScriptSourceRange,
): JavaScriptSemanticGraphNode[] =>
nodes.filter(
(node) =>
node.identity.source_range !== null &&
contains(range, node.identity.source_range),
);
/** Report extractor support without treating missing families as absence. */
export const semanticFamilyStatus = (
family: (typeof JAVASCRIPT_SEMANTIC_RELATION_FAMILIES)[number],
analysis: JavaScriptArtifactAnalysis,
): "complete" | "partial" | "unknown" | "unsupported" => {
if (
![
"call-flow",
"boundary",
"child-process",
"closure",
"configuration",
"data-flow",
"event",
"object-flow",
"promise-ownership",
"request",
"resource-lifecycle",
"timer",
].includes(family)
)
return "unsupported";
return analysis.truncated_scopes === 0 ? "partial" : "unknown";
};
const contains = (
outer: JavaScriptSourceRange,
inner: JavaScriptSourceRange,
): boolean =>
pointCompare(outer.start, inner.start) <= 0 &&
pointCompare(outer.end, inner.end) >= 0;
const pointCompare = (
left: JavaScriptSourceRange["start"],
right: JavaScriptSourceRange["start"],
): number => left.line - right.line || left.column - right.column;
const positionKey = (range: JavaScriptSourceRange): string =>
`${String(range.start.line).padStart(12, "0")}:${String(range.start.column).padStart(12, "0")}`;
@@ -0,0 +1,95 @@
import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascriptSemanticGraph.js";
import type { JavaScriptSemanticGraphNode } from "../../domain/javascriptSemanticGraph.js";
import type { JavaScriptSemanticResourceOperation } from "../../domain/javascriptSemanticIr.js";
import {
addSemanticGraphNode,
addSemanticGraphRelation,
addSemanticGraphUnknown,
constructSemanticGraphNode,
} from "./JavaScriptSemanticGraphConstruction.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import {
semanticCallSiteAt,
type SemanticFlowProjectionContext,
} from "./JavaScriptSemanticGraphFlowProjection.js";
/** Project built-in resource acquisition and exact local release handles. */
export const projectSemanticResources = (
context: SemanticFlowProjectionContext,
): void => {
const resourceNodes = new Map(
context.ir.resourceOperations.flatMap((operation) => {
if (operation.kind !== "acquire") return [];
const node = addResourceNode(context, operation);
return node === null ? [] : [[operation.resourceId, node] as const];
}),
);
for (const operation of context.ir.resourceOperations)
if (operation.kind === "acquire")
addSemanticGraphRelation(context.state, {
source: semanticCallSiteAt(context, operation.location),
target: resourceNodes.get(operation.resourceId),
relation: "acquires",
resolution: "resolved",
});
else projectRelease(context, operation, resourceNodes);
};
const addResourceNode = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticResourceOperation,
): JavaScriptSemanticGraphNode | null =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "resource",
roleKey: operation.resourceId,
location: operation.location,
label: operation.method,
functionNodeId:
operation.ownerCallableId === null
? null
: (context.callableNodes.get(operation.ownerCallableId)?.node_id ??
null),
properties: { method: operation.method },
},
context.state,
),
);
const projectRelease = (
context: SemanticFlowProjectionContext,
operation: JavaScriptSemanticResourceOperation,
resourceNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): void => {
const callSite = semanticCallSiteAt(context, operation.location);
const resources = operation.linkedResourceIds.flatMap((identifier) => {
const resource = resourceNodes.get(identifier);
return resource === undefined ? [] : [resource];
});
for (const resource of resources)
addSemanticGraphRelation(context.state, {
source: callSite,
target: resource,
relation: "releases",
resolution:
operation.resolution === "complete" ? "resolved" : "candidate",
});
if (operation.resolution === "complete") return;
addSemanticGraphUnknown(
context.state,
createJavaScriptSemanticGraphUnknown({
node_id: callSite?.node_id ?? null,
family: "resource-lifecycle",
relation_kinds: ["releases"],
reason: "ambiguous-target",
detail: `Static ${operation.method} resource ownership is ambiguous.`,
candidate_node_ids: resources.map(
({ node_id: identifier }) => identifier,
),
evidence: unknownSemanticEvidence(context.file, operation.location),
}),
);
};
@@ -0,0 +1,396 @@
import { expect, it } from "vitest";
import { buildJavaScriptSemanticGraph } from "./JavaScriptSemanticGraphBuilder.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import { queryJavaScriptSemanticGraph } from "../../domain/javascriptSemanticQuery.js";
import { analyzeJavaScriptSemantics } from "../../domain/javascriptSemanticAnalysis.js";
const SHA256 = "a".repeat(64);
const GRAPH_ID = `jag_${"b".repeat(64)}`;
it("projects literal events and exact timer cancellation", () => {
const graph = graphFor(`
function run(bus, dynamicName) {
const handle = setInterval(tick, 10);
bus.on("ready", handler);
bus.once(dynamicName, handler);
bus.emit("ready");
bus.removeListener("ready", handler);
clearInterval(handle);
}
`);
const relations = new Set(graph.relations.map(({ relation }) => relation));
for (const expected of [
"cancels-timer",
"dispatches-candidate",
"registers-listener",
"removes-listener",
"schedules-timer",
] as const)
expect(relations.has(expected)).toBe(true);
expect(
graph.nodes.find(
({ kind, properties }) =>
kind === "event" && properties.event_name === "ready",
),
).toBeDefined();
expect(
graph.unknowns.some(
({ family, detail }) =>
family === "event" && detail.includes("Dynamic once event name"),
),
).toBe(true);
expect(
graph.coverage.families.find(({ family }) => family === "event"),
).toMatchObject({ status: "partial" });
expect(
graph.coverage.families.find(({ family }) => family === "timer"),
).toMatchObject({ status: "partial" });
});
it("projects child-process creation, I/O, listeners, and signals", () => {
const graph = graphFor(`
import { spawn } from "node:child_process";
function run(command) {
const child = spawn(
command,
["--mode", "fast"],
{ env: process.env, stdio: "pipe" },
);
child.on("exit", onExit);
child.once("error", onError);
child.kill("SIGTERM");
}
`);
const relations = new Set(graph.relations.map(({ relation }) => relation));
for (const expected of [
"connects-stdio",
"forwards-signal",
"listens-error",
"listens-exit",
"spawns",
"supplies-argv",
"supplies-env",
] as const)
expect(relations.has(expected)).toBe(true);
expect(
graph.nodes.find(
({ kind, properties }) =>
kind === "child-process" &&
properties.method === "spawn" &&
properties.command === null,
),
).toBeDefined();
expect(
graph.unknowns.some(
({ family, detail }) =>
family === "child-process" &&
detail.includes("command remains unresolved"),
),
).toBe(true);
expect(
graph.coverage.families.find(({ family }) => family === "child-process"),
).toMatchObject({ status: "partial" });
});
it("keeps destructured request, spawn, and timer results as projections", () => {
const graph = graphFor(`
import { spawn } from "node:child_process";
async function run() {
const { data } = await fetch("https://example.test");
const [ child ] = spawn("worker", []);
child.on("exit", done);
const { handle } = setTimeout(done, 5);
clearTimeout(handle);
return data.json();
}
`);
const projections = graph.relations.filter(
({ relation }) => relation === "destructures",
);
expect(
projections.map(({ properties }) => properties.projection_path),
).toEqual(expect.arrayContaining([["data"], [0], ["handle"]]));
expect(
projections.every(
({ properties }) => properties.projection_resolution === "complete",
),
).toBe(true);
expect(
graph.relations.find(
({ relation, properties }) =>
relation === "consumed-by" && properties.method === "json",
),
).toMatchObject({ resolution: "candidate" });
expect(
graph.relations.find(({ relation }) => relation === "listens-exit"),
).toMatchObject({ resolution: "candidate" });
expect(
graph.relations.find(({ relation }) => relation === "cancels-timer"),
).toMatchObject({ resolution: "candidate" });
expect(
graph.relations.some(
({ relation, resolution }) =>
["consumed-by", "listens-exit", "cancels-timer"].includes(relation) &&
resolution === "resolved",
),
).toBe(false);
});
it("does not alias nested call arguments to an outer assigned result", () => {
const graph = graphFor(`
import { spawn } from "node:child_process";
function run() {
const child = wrap(spawn("worker", []));
child.on("exit", done);
const handle = String(setTimeout(done, 5));
clearTimeout(handle);
}
`);
expect(
graph.relations.some(({ relation }) =>
["listens-exit", "cancels-timer", "destructures"].includes(relation),
),
).toBe(false);
});
it("projects config precedence, request fields, and boundaries", () => {
const graph = graphFor(`
import { readFileSync } from "node:fs";
async function run(schema) {
const endpoint = process.env.API_URL ?? "https://fallback.test";
const raw = readFileSync("./config.json", "utf8");
const parsed = JSON.parse(raw);
const port = Number(process.argv[2]);
const validated = schema.parse(parsed);
const response = await fetch(endpoint, {
method: "POST",
body: validated,
});
return response.json();
}
`);
const relations = new Set(graph.relations.map(({ relation }) => relation));
for (const expected of [
"coerces",
"constructs-request",
"consumed-by",
"defaults",
"overrides",
"parses",
"reads-argv",
"reads-config",
"reads-environment",
"supplies-request-field",
] as const)
expect(relations.has(expected)).toBe(true);
expect(
graph.nodes.find(
({ kind, properties }) =>
kind === "request" && properties.method === "fetch",
),
).toBeDefined();
expect(
graph.nodes.filter(({ kind }) => kind === "boundary"),
).not.toHaveLength(0);
expect(
graph.coverage.families.find(({ family }) => family === "configuration"),
).toMatchObject({ status: "partial" });
expect(
graph.coverage.families.find(({ family }) => family === "request"),
).toMatchObject({ status: "partial" });
expect(
graph.coverage.families.find(({ family }) => family === "boundary"),
).toMatchObject({ status: "partial" });
});
it("projects built-in resource acquisition and release", () => {
const graph = graphFor(`
import { open } from "node:fs/promises";
import { connect } from "node:net";
async function run(path) {
const file = await open(path);
const socket = connect({ port: 9000 });
await file.close();
socket.end();
}
`);
const relations = new Set(graph.relations.map(({ relation }) => relation));
expect(relations.has("acquires")).toBe(true);
expect(relations.has("releases")).toBe(true);
expect(graph.nodes.filter(({ kind }) => kind === "resource")).toHaveLength(2);
expect(
graph.coverage.families.find(
({ family }) => family === "resource-lifecycle",
),
).toMatchObject({ status: "partial" });
});
it.each(["+= 1", "++"])(
"projects both property dependencies for %s",
(operator) => {
const graph = graphFor(`
const source = { count: 1 };
source.count ${operator};
`);
const relations = graph.relations.map(({ relation }) => relation);
expect(relations).toContain("reads-property");
expect(relations).toContain("writes-property");
},
);
it("does not project a literal property slot for dynamic destructuring", () => {
const graph = graphFor(`
const source = { token: "TOKEN" };
const key = getKey();
const { [key]: value } = source;
`);
expect(
graph.relations.filter(({ relation }) => relation === "destructures"),
).toEqual([]);
expect(
graph.nodes.find(
({ kind, properties }) =>
kind === "property-slot" && properties.name === "key",
),
).toBeUndefined();
});
it("projects literal seeds and static object flow", () => {
const graph = graphFor(`
const source = { token: "TOKEN", count: 1 };
const { token } = source;
const copy = { ...source };
source.count = 2;
const read = source.token;
`);
const relations = new Set(graph.relations.map(({ relation }) => relation));
for (const expected of [
"destructures",
"reads-property",
"spreads",
"writes-property",
] as const)
expect(relations.has(expected)).toBe(true);
expect(
graph.nodes.find(
({ kind, properties }) =>
kind === "property-slot" && properties.name === "token",
),
).toBeDefined();
const literal = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "literal", value: "TOKEN" },
direction: "forward-influence",
});
expect(literal.status).toBe("ambiguous");
expect(literal.summary.total_seed_matches).toBe(3);
const property = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "property", name: "token" },
direction: "forward-influence",
});
expect(property.status).toBe("found");
});
it("does not promote transformed return expressions to resolved aliases", () => {
const graph = graphFor(`
function increment(value) { return value + 1; }
const input = 1;
const output = increment(input);
`);
const returnNode = graph.nodes.find(({ kind }) => kind === "return-site");
if (returnNode === undefined) throw new Error("Expected return site");
expect(
graph.relations.filter(
({ relation, target_node_id }) =>
relation === "aliases" && target_node_id === returnNode.node_id,
),
).toEqual([expect.objectContaining({ resolution: "candidate" })]);
const output = graph.nodes.find(
({ kind, label }) => kind === "binding" && label === "output",
);
const input = graph.nodes.find(
({ kind, label }) => kind === "binding" && label === "input",
);
if (output === undefined || input === undefined)
throw new Error("Expected caller bindings");
const strict = queryJavaScriptSemanticGraph(graph, {
seed: { kind: "semantic-node", node_id: output.node_id },
direction: "backward-provenance",
});
expect(strict.nodes.map(({ node_id }) => node_id)).not.toContain(
input.node_id,
);
expect(strict.status).toBe("ambiguous");
});
it("uses unavailable evidence when no source produced semantic IR", () => {
const graph = buildJavaScriptSemanticGraph({
rootArtifactSha256: SHA256,
applicationGraph: { graph_id: GRAPH_ID, nodes: [] },
analysis: emptyAnalysis(),
});
expect(graph.nodes[0]?.evidence).toMatchObject({
authority: "unknown",
state: "unavailable",
location: { available: false, reason: "not-observed" },
});
});
const emptyAnalysis = (): JavaScriptArtifactAnalysis => ({
files: [],
packages: [],
json_modules: [],
html_scripts: [],
source_maps: [],
visited_ast_nodes: 0,
findings: 0,
modules: 0,
parse_failures: 0,
truncated_scopes: 0,
limitations: [],
});
const graphFor = (source: string) => {
const file: JavaScriptArtifactFile = {
path: "app.js",
container_sha256: SHA256,
sha256: SHA256,
bytes: Buffer.byteLength(source),
inventory_artifact_id: `art_${SHA256}`,
kind: "javascript",
unpacked: false,
text: { included: true, value: source },
};
const analysis: JavaScriptArtifactAnalysis = {
files: [
{
file,
javascript: null,
semantic: {
ir: analyzeJavaScriptSemantics(source),
},
},
],
packages: [],
json_modules: [],
html_scripts: [],
source_maps: [],
visited_ast_nodes: 0,
findings: 0,
modules: 0,
parse_failures: 0,
truncated_scopes: 0,
limitations: [],
};
return buildJavaScriptSemanticGraph({
rootArtifactSha256: SHA256,
applicationGraph: { graph_id: GRAPH_ID, nodes: [] },
analysis,
});
};
@@ -0,0 +1,147 @@
import { expect, it } from "vitest";
import { buildJavaScriptSemanticGraph } from "./JavaScriptSemanticGraphBuilder.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "./JavaScriptArtifactFiles.js";
import { analyzeJavaScriptSemantics } from "../../domain/javascriptSemanticAnalysis.js";
import { topLevelBinding } from "../../domain/javascriptSemanticAnalysis.fixture.js";
const SHA256 = "a".repeat(64);
const GRAPH_ID = `jag_${"b".repeat(64)}`;
const graphFor = (source: string) => {
const file: JavaScriptArtifactFile = {
path: "app.js",
container_sha256: SHA256,
sha256: SHA256,
bytes: Buffer.byteLength(source),
inventory_artifact_id: `art_${SHA256}`,
kind: "javascript",
unpacked: false,
text: { included: true, value: source },
};
const analysis: JavaScriptArtifactAnalysis = {
files: [
{
file,
javascript: null,
semantic: { ir: analyzeJavaScriptSemantics(source) },
},
],
packages: [],
json_modules: [],
html_scripts: [],
source_maps: [],
visited_ast_nodes: 0,
findings: 0,
modules: 0,
parse_failures: 0,
truncated_scopes: 0,
limitations: [],
};
return buildJavaScriptSemanticGraph({
rootArtifactSha256: SHA256,
applicationGraph: { graph_id: GRAPH_ID, nodes: [] },
analysis,
});
};
const dynamicScopeUnknowns = (source: string) =>
graphFor(source).unknowns.filter(({ reason }) => reason === "dynamic-scope");
it("exposes direct eval and with as dynamic-scope unknowns in the public graph", () => {
const unknowns = dynamicScopeUnknowns(`
function inspect(host, source) {
with (host) { consume(target); }
eval(source);
}
`);
expect(unknowns).toHaveLength(2);
expect(unknowns.map(({ detail }) => detail).join("\n")).toEqual(
expect.stringContaining("with"),
);
expect(unknowns.map(({ detail }) => detail).join("\n")).toEqual(
expect.stringContaining("eval"),
);
});
it("does not mark shadowed, optional, or indirect eval as dynamic scope", () => {
const unknowns = dynamicScopeUnknowns(`
function shadowed(eval, source) { eval(source); }
function optional(source) { eval?.(source); }
function indirect(source) { (0, eval)(source); }
`);
expect(unknowns).toEqual([]);
});
it("keeps switch discriminants outside the shared case lexical scope", () => {
const ir = analyzeJavaScriptSemantics(`
const selected = choose();
switch (selected) {
case 1: let selected = 1; let local = 1; break;
case 2: local = 2; break;
}
`);
const selectedBinding = topLevelBinding(ir, "selected");
const localBindings = ir.bindings.filter(({ name }) => name === "local");
expect(ir.references.find(({ name }) => name === "selected")?.bindingId).toBe(
selectedBinding.bindingId,
);
expect(localBindings).toHaveLength(1);
expect(localBindings[0]?.scopeId).not.toBe(selectedBinding?.scopeId);
expect(
ir.references.filter(
({ name, role }) => name === "selected" && role === "read",
),
).toHaveLength(1);
expect(
ir.references.filter(
({ name, role }) => name === "local" && role === "write",
),
).toHaveLength(1);
});
it("leaves with-object resolution intact and marks only body name resolution unknown", () => {
const ir = analyzeJavaScriptSemantics(`
const host = getHost();
const outer = 3;
function inspect() {
with (host) {
const local = 1;
consume(local, outer);
}
}
`);
const host = topLevelBinding(ir, "host");
const hostRead = ir.references.find(
({ name, role }) => name === "host" && role === "read",
);
const outerRead = ir.references.find(
({ name, role }) => name === "outer" && role === "read",
);
const localRead = ir.references.find(
({ name, role }) => name === "local" && role === "read",
);
expect(hostRead?.bindingId).toBe(host.bindingId);
expect(hostRead?.resolution).toBe("resolved");
expect(outerRead?.resolution).toBe("unknown");
expect(localRead?.resolution).toBe("resolved");
expect(outerRead?.bindingId).toBeNull();
});
it("keeps var declarations in each static block from leaking to another block", () => {
const ir = analyzeJavaScriptSemantics(`
class Example {
static { var first = 1; }
static { var second = 2; consume(first); }
}
`);
const first = ir.bindings.filter(({ name }) => name === "first");
const second = ir.bindings.filter(({ name }) => name === "second");
expect(ir.references.find(({ name }) => name === "first")?.resolution).toBe(
"unbound",
);
expect(first).toHaveLength(1);
expect(second).toHaveLength(1);
expect(first[0]?.scopeId).not.toBe(second[0]?.scopeId);
});
@@ -0,0 +1,129 @@
import type { JsonValue } from "../../domain/jsonValue.js";
import type {
JavaScriptSemanticBinding,
JavaScriptSemanticValue,
} from "../../domain/javascriptSemanticIr.js";
import {
addSemanticGraphNode,
addSemanticGraphRelation,
constructSemanticGraphNode,
} from "./JavaScriptSemanticGraphConstruction.js";
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
/** Project bounded literal values and object slots for exact query seeds. */
export const projectSemanticValues = (
context: SemanticFlowProjectionContext,
): void => {
for (const binding of context.ir.bindings) {
const bindingNode = context.bindingNodes.get(binding.bindingId);
if (bindingNode === undefined) continue;
projectValue({
context,
binding,
value: binding.value,
target: bindingNode,
role: "binding",
});
}
};
interface ValueProjectionInput {
readonly context: SemanticFlowProjectionContext;
readonly binding: JavaScriptSemanticBinding;
readonly value: JavaScriptSemanticValue;
readonly target: ReturnType<typeof semanticPropertySlot>;
readonly role: string;
}
const projectValue = (input: ValueProjectionInput): void => {
const { context, binding, value, target, role } = input;
if (target === null) return;
if (value.status === "literal") {
const literal = addLiteralNode(context, binding, value.value, role);
addSemanticGraphRelation(context.state, {
source: literal,
target,
relation: "defines",
resolution: "resolved",
});
} else if (value.status === "union")
for (const primitive of value.values) {
const literal = addLiteralNode(context, binding, primitive, role);
addSemanticGraphRelation(context.state, {
source: literal,
target,
relation: "defines",
resolution: "candidate",
});
}
else if (value.status === "object")
for (const property of value.properties) {
const slot = semanticPropertySlot(
context,
binding.bindingId,
property.name,
);
addSemanticGraphRelation(context.state, {
source: target,
target: slot,
relation: "writes-property",
resolution: value.unknownProperties ? "candidate" : "resolved",
});
projectValue({
context,
binding,
value: property.value,
target: slot,
role: `property:${property.name}`,
});
}
};
const addLiteralNode = (
context: SemanticFlowProjectionContext,
binding: JavaScriptSemanticBinding,
value: string | number | boolean | null,
role: string,
) =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "literal",
roleKey: `literal:${binding.bindingId}:${role}:${JSON.stringify(value)}`,
location: binding.definitions[0]?.location ?? null,
label: JSON.stringify(value),
functionNodeId:
context.bindingNodes.get(binding.bindingId)?.function_node_id ?? null,
properties: { value },
},
context.state,
),
);
/** Create one canonical property slot for a binding/property identity. */
export const semanticPropertySlot = (
context: SemanticFlowProjectionContext,
objectBindingId: string,
name: string,
) =>
addSemanticGraphNode(
context.state,
constructSemanticGraphNode(
context.file,
{
kind: "property-slot",
roleKey: `property:${objectBindingId}:${name}`,
location: null,
label: name,
functionNodeId:
context.bindingNodes.get(objectBindingId)?.function_node_id ?? null,
properties: {
name,
object_binding_id: objectBindingId,
} satisfies Readonly<Record<string, JsonValue>>,
},
context.state,
),
);
@@ -0,0 +1,61 @@
import { z } from "zod";
import {
AnalysisInputError,
AnalysisProtocolError,
} from "../../domain/analysisErrorCore.js";
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
import type { Evidence } from "../../domain/evidence.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import { queryJavaScriptSemanticGraph } from "../../domain/javascriptSemanticQuery.js";
import {
javaScriptSemanticTraceResultSchema,
traceJavaScriptSemanticsInputSchema,
} from "../../domain/javascriptSemanticTraceSchemas.js";
import { err, ok, type Result } from "../../domain/result.js";
import { parseApplicationGraphEvidence } from "./JavaScriptApplicationEvidenceGraph.js";
import { createJavaScriptSemanticTraceEvidence } from "./JavaScriptApplicationWorkflowEvidence.js";
const OPERATION = "trace_javascript_semantics" as const;
/** Trace semantic relations from input already parsed by a trusted adapter. */
export const traceJavaScriptSemanticsEvidenceValidated = (
input: z.output<typeof traceJavaScriptSemanticsInputSchema>,
): Result<Evidence, AnalysisError> => {
try {
const source = parseApplicationGraphEvidence(input.application);
if (source.semanticGraph === null)
return err(
new AnalysisInputError(OPERATION, undefined, [
{
path: ["application"],
reason: "invalid_value",
expected:
"inline Evidence from analyze_javascript_application with a semantic graph",
},
]),
);
const query = queryJavaScriptSemanticGraph(
source.semanticGraph,
input.query,
);
const result = javaScriptSemanticTraceResultSchema.parse({
...query,
source_evidence_id: source.evidence.evidence_id,
evidence_links: [source.evidence.evidence_id],
});
return ok(
createJavaScriptSemanticTraceEvidence(
{
application_evidence_id: source.evidence.evidence_id,
query: jsonValueSchema.parse(input.query),
},
result,
),
);
} catch (cause: unknown) {
return err(
new AnalysisProtocolError("JavaScript semantic trace failed", { cause }),
);
}
};