mirror of
https://github.com/morluto/rea
synced 2026-10-27 06:33:32 -04:00
refactor(javascript): group application workflows and verification (#896)
This commit is contained in:
@@ -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 }),
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user