fix(javascript): complete large-ASAR analysis with file-local projection (#1055)

This commit is contained in:
Ysopwnable 2026-10-08 16:15:35 +08:00 committed by GitHub
parent 7af7bc88ce
commit b9fb438835
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1231 additions and 631 deletions

View File

@ -74,6 +74,18 @@ for the tool-result contract.
## Large results
Application analysis projects semantic relationships one source file at a time.
After projection, it retains only the lexical module facts and exported return
shapes needed to build application relationships. Full source IR does not stay
resident for every file together. Callable ownership and contained-reference
queries use per-file range indexes, while call sites use exact-range lookups.
Existing node budgets and coverage reporting remain in effect.
Progress identifies the source file being parsed and projected. Cancellation is
checked between files, with event-loop yields that allow notifications and
completed file-local allocations to be released. A single file's synchronous
parser and semantic extraction still need to finish before that boundary.
Graph and Evidence identifiers hash canonical JSON incrementally, without
assembling a single string for the whole graph. The canonical bytes and existing
identifiers remain unchanged. The opt-in regression check is
@ -93,6 +105,9 @@ assemble whole strings. Field selection remains useful when the caller needs a
smaller view, for example `--format json --filter-output
evidence_id,normalized_result.statistics`. Streaming output does not bound the
memory needed to construct the analysis graph itself.
The separate large-response transport work is tracked in
[#1053](https://github.com/morluto/rea/issues/1053) and
[#1050](https://github.com/morluto/rea/issues/1050).
Client framing limits also apply: the pinned Node MCP SDK's stdio transport
defaults to a 10 MiB buffer. Its caller-selected `maxBufferSize` must accommodate
the complete response, including text and structured Evidence projections.

View File

@ -1,7 +1,7 @@
import { compareCodePoints } from "../../domain/canonicalOrdering.js";
import type { ElectronIpcFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js";
import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptModuleArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
@ -27,7 +27,7 @@ export interface ElectronIpcPairingState {
/** Flatten all per-file IPC facts into deterministic application records. */
export const collectElectronIpcRecords = (
analysis: JavaScriptArtifactAnalysis,
analysis: JavaScriptModuleArtifactAnalysis,
): readonly ElectronIpcRecord[] =>
analysis.files
.flatMap(({ file, javascript }) =>
@ -79,7 +79,7 @@ export const unambiguousElectronIpcPairings = (
/** Summarize static Electron findings without treating gaps as absence. */
export const summarizeElectronBoundaries = (
analysis: JavaScriptArtifactAnalysis,
analysis: JavaScriptModuleArtifactAnalysis,
): ElectronBoundarySummary => {
const files = new Map(analysis.files.map(({ file }) => [file.path, file]));
const javascript = analysis.files.flatMap(({ javascript: value }) =>

View File

@ -7,7 +7,6 @@ import type {
AnalyzeJavaScriptApplicationInput,
JavaScriptApplicationAnalysisResult,
} from "../../domain/javascript/javascriptApplicationAnalysis.js";
import { jsonValueSchema } from "../../domain/jsonValue.js";
import { JAVASCRIPT_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
/** Create Evidence for one deterministic local JavaScript application graph. */
@ -26,7 +25,7 @@ export const createJavaScriptApplicationEvidence = (
predicateType: "rea.javascript-application-analysis",
operation: "analyze_javascript_application",
parameters: parameters(input),
result: jsonValueSchema.parse(result),
result,
rawResult: null,
confidence: "derived",
authority: "shipped-artifact",

View File

@ -1,7 +1,9 @@
import { createHash } from "node:crypto";
import { setImmediate } from "node:timers/promises";
import { analyzeParsedJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js";
import { analyzeParsedJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js";
import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js";
import { parseJavaScriptSource } from "../../domain/javascript/javascriptSourceParser.js";
import { hasValidSourceMapContents } from "../../domain/sourceMapContents.js";
import { flattenSourceMapLeaves } from "../../domain/sourceMapEnvelope.js";
@ -18,6 +20,8 @@ import type {
import type {
AnalyzedJavaScriptArtifactFile,
JavaScriptArtifactAnalysis,
JavaScriptModuleArtifactAnalysis,
JavaScriptModuleSemanticIr,
JavaScriptHtmlScriptObservation,
JavaScriptJsonModuleObservation,
JavaScriptPackageObservation,
@ -27,8 +31,10 @@ import type {
import { analyzeJavaScriptJsonModule } from "./JavaScriptJsonModules.js";
import { htmlArtifactReferences } from "../../domain/javascript/htmlArtifactReferences.js";
interface MutableArtifactAnalysis {
readonly files: AnalyzedJavaScriptArtifactFile[];
interface MutableArtifactAnalysis<
SemanticIr extends JavaScriptModuleSemanticIr,
> {
readonly files: AnalyzedJavaScriptArtifactFile<SemanticIr>[];
readonly packages: JavaScriptPackageObservation[];
readonly jsonModules: JavaScriptJsonModuleObservation[];
readonly htmlScripts: JavaScriptHtmlScriptObservation[];
@ -40,23 +46,88 @@ interface MutableArtifactAnalysis {
truncatedScopes: number;
}
interface ArtifactAnalysisContext {
readonly state: MutableArtifactAnalysis;
interface ArtifactAnalysisContext<
SemanticIr extends JavaScriptModuleSemanticIr,
> {
readonly state: MutableArtifactAnalysis<SemanticIr>;
readonly projectSemantics: (
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
) => SemanticIr;
}
/** Analyze every text source from the artifact without a result quota. */
export const analyzeJavaScriptArtifactFiles = (
fileSet: JavaScriptArtifactFileSet,
): JavaScriptArtifactAnalysis => {
const state = emptyArtifactAnalysis();
const context = { state };
): JavaScriptArtifactAnalysis =>
analyzeArtifactFiles(fileSet, (_file, ir) => ir);
/** Project each file's full IR immediately, retaining only later module facts. */
export const analyzeAndProjectJavaScriptArtifactFiles = async (
fileSet: JavaScriptArtifactFileSet,
projectSemantics: (
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
) => void,
beforeFile?: (
file: JavaScriptArtifactFile,
completed: number,
total: number,
) => Promise<void>,
): Promise<JavaScriptModuleArtifactAnalysis> => {
const state = emptyArtifactAnalysis<JavaScriptModuleSemanticIr>();
const projectFileSemantics = (
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
): JavaScriptModuleSemanticIr => {
projectSemantics(file, ir);
const programScopes = ir.scopes.filter(({ kind }) => kind === "program");
const programScopeId = programScopes[0]?.scopeId;
const localNames = new Set(
ir.moduleLinks.map(({ localName }) => localName),
);
const callableIds = new Set(
ir.moduleLinks.map(({ callableId }) => callableId),
);
return {
scopes: programScopes,
bindings: ir.bindings.filter(
({ name, scopeId }) =>
scopeId === programScopeId && localNames.has(name),
),
callables: ir.callables.filter(({ callableId }) =>
callableIds.has(callableId),
),
moduleLinks: ir.moduleLinks,
coverage: ir.coverage,
limitations: ir.limitations,
};
};
const context = { state, projectSemantics: projectFileSemantics };
for (const [index, file] of fileSet.files.entries()) {
// Let progress, cancellation and garbage collection run between files.
await setImmediate();
await beforeFile?.(file, index, fileSet.files.length);
analyzeArtifactFile(file, context);
}
return finalizeArtifactAnalysis(state);
};
const analyzeArtifactFiles = <SemanticIr extends JavaScriptModuleSemanticIr>(
fileSet: JavaScriptArtifactFileSet,
projectSemantics: ArtifactAnalysisContext<SemanticIr>["projectSemantics"],
): JavaScriptArtifactAnalysis<SemanticIr> => {
const state = emptyArtifactAnalysis<SemanticIr>();
const context = { state, projectSemantics };
for (const file of fileSet.files) analyzeArtifactFile(file, context);
return finalizeArtifactAnalysis(state);
};
const finalizeArtifactAnalysis = (
state: MutableArtifactAnalysis,
): JavaScriptArtifactAnalysis => {
const finalizeArtifactAnalysis = <
SemanticIr extends JavaScriptModuleSemanticIr,
>(
state: MutableArtifactAnalysis<SemanticIr>,
): JavaScriptArtifactAnalysis<SemanticIr> => {
const truncatedScopes = state.truncatedScopes;
return {
files: state.files,
@ -79,9 +150,9 @@ const finalizeArtifactAnalysis = (
};
};
const analyzeArtifactFile = (
const analyzeArtifactFile = <SemanticIr extends JavaScriptModuleSemanticIr>(
file: JavaScriptArtifactFile,
context: ArtifactAnalysisContext,
context: ArtifactAnalysisContext<SemanticIr>,
): void => {
const { state } = context;
addStructuredObservations(file, state);
@ -108,7 +179,10 @@ const analyzeArtifactFile = (
state.files.push({
file,
javascript: analysis,
semantic: semantics === null ? null : { ir: semantics },
semantic:
semantics === null
? null
: { ir: context.projectSemantics(file, semantics) },
});
state.visitedNodes += analysis.visited_ast_nodes;
state.findings += staticFindings + (semantics?.moduleLinks.length ?? 0);
@ -121,13 +195,15 @@ const analyzeArtifactFile = (
const addSourceMap = (
file: JavaScriptArtifactFile,
context: ArtifactAnalysisContext,
context: ArtifactAnalysisContext<JavaScriptModuleSemanticIr>,
): void => {
const sourceMap = parseSourceMap(file);
context.state.sourceMaps.push(sourceMap);
};
const emptyArtifactAnalysis = (): MutableArtifactAnalysis => ({
const emptyArtifactAnalysis = <
SemanticIr extends JavaScriptModuleSemanticIr,
>(): MutableArtifactAnalysis<SemanticIr> => ({
files: [],
packages: [],
jsonModules: [],
@ -142,7 +218,7 @@ const emptyArtifactAnalysis = (): MutableArtifactAnalysis => ({
const addStructuredObservations = (
file: JavaScriptArtifactFile,
state: MutableArtifactAnalysis,
state: MutableArtifactAnalysis<JavaScriptModuleSemanticIr>,
): void => {
if (file.kind === "package-json") state.packages.push(parsePackage(file));
if (file.kind !== "json") return;

View File

@ -87,18 +87,33 @@ export type JavaScriptJsonModuleObservation = JavaScriptStructuredObservation &
}
);
/** Module facts needed after file-local semantic projection has finished. */
export type JavaScriptModuleSemanticIr = Pick<
JavaScriptSemanticIr,
| "scopes"
| "bindings"
| "callables"
| "moduleLinks"
| "coverage"
| "limitations"
>;
/** One relevant file plus optional AST-only JavaScript facts. */
export interface AnalyzedJavaScriptArtifactFile {
export interface AnalyzedJavaScriptArtifactFile<
SemanticIr extends JavaScriptModuleSemanticIr = JavaScriptSemanticIr,
> {
readonly file: JavaScriptArtifactFile;
readonly javascript: JavaScriptStaticAnalysis | null;
readonly semantic: {
readonly ir: JavaScriptSemanticIr;
readonly ir: SemanticIr;
} | null;
}
/** Complete static-analysis projection before graph construction. */
export interface JavaScriptArtifactAnalysis {
readonly files: readonly AnalyzedJavaScriptArtifactFile[];
export interface JavaScriptArtifactAnalysis<
SemanticIr extends JavaScriptModuleSemanticIr = JavaScriptSemanticIr,
> {
readonly files: readonly AnalyzedJavaScriptArtifactFile<SemanticIr>[];
readonly packages: readonly JavaScriptPackageObservation[];
readonly json_modules: readonly JavaScriptJsonModuleObservation[];
readonly html_scripts: readonly JavaScriptHtmlScriptObservation[];
@ -110,3 +125,7 @@ export interface JavaScriptArtifactAnalysis {
readonly truncated_scopes: number;
readonly limitations: readonly string[];
}
/** Artifact facts retaining only semantics used by application relationships. */
export type JavaScriptModuleArtifactAnalysis =
JavaScriptArtifactAnalysis<JavaScriptModuleSemanticIr>;

View File

@ -3,7 +3,7 @@ import {
createJavaScriptApplicationGraph,
type JavaScriptApplicationGraph,
} from "../../domain/javascript/javascriptApplicationGraph.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptModuleArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptArtifactFileSet } from "../../domain/javascript/javascriptArtifactFiles.js";
import { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGraphAccumulator.js";
import type { JavaScriptArtifactGraphContext } from "./JavaScriptArtifactGraphContext.js";
@ -37,7 +37,7 @@ import {
export const buildJavaScriptArtifactGraph = (
snapshot: ArtifactInventorySnapshot,
fileSet: JavaScriptArtifactFileSet,
analysis: JavaScriptArtifactAnalysis,
analysis: JavaScriptModuleArtifactAnalysis,
): JavaScriptApplicationGraph => {
const accumulator = new JavaScriptArtifactGraphAccumulator();
const root = createJavaScriptArtifactRootNode(accumulator, snapshot);

View File

@ -6,7 +6,7 @@ import {
} from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraph.js";
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptModuleArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type {
JavaScriptArtifactFile,
JavaScriptArtifactFileSet,
@ -29,7 +29,7 @@ export interface JavaScriptArtifactGraphContext {
readonly accumulator: JavaScriptArtifactGraphAccumulator;
readonly snapshot: ArtifactInventorySnapshot;
readonly fileSet: JavaScriptArtifactFileSet;
readonly analysis: JavaScriptArtifactAnalysis;
readonly analysis: JavaScriptModuleArtifactAnalysis;
readonly root: ApplicationNode;
readonly filesByPath: ReadonlyMap<string, JavaScriptArtifactFile>;
readonly fileNodes: Map<string, ApplicationNode>;
@ -209,7 +209,7 @@ export const addUnavailableStaticParseScope = (
/** Coverage for one bounded JavaScript AST analysis. */
export const javascriptAnalysisCoverage = (
analysis: NonNullable<
JavaScriptArtifactAnalysis["files"][number]["javascript"]
JavaScriptModuleArtifactAnalysis["files"][number]["javascript"]
>,
): JavaScriptArtifactGraphCoverage =>
analysis.parse_status === "complete"

View File

@ -1,7 +1,7 @@
import type { ArtifactInventorySnapshot } from "../../artifacts/inventory/ArtifactInventory.js";
import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraph.js";
import { completeApplicationCoverage } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type { JavaScriptModuleArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
import type {
JavaScriptArtifactContainer,
JavaScriptArtifactFile,
@ -270,7 +270,7 @@ export const addJavaScriptPackageNodes = (
const createFileTarget = (
context: JavaScriptArtifactGraphContext,
file: JavaScriptArtifactFile,
javascript: JavaScriptArtifactAnalysis["files"][number]["javascript"],
javascript: JavaScriptModuleArtifactAnalysis["files"][number]["javascript"],
): ApplicationNode => {
const kind = artifactFileNodeKind(file.kind);
const json = context.analysis.json_modules.find(

View File

@ -5,7 +5,7 @@ import { createJavaScriptArtifactReader as createReader } from "../../artifacts/
import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js";
import type { JavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticGraph.js";
import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js";
import { analyzeJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js";
import { analyzeAndProjectJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js";
import { readJavaScriptArtifactFiles } from "../../artifacts/javascript/JavaScriptArtifactFiles.js";
import { buildJavaScriptArtifactGraph } from "./JavaScriptArtifactGraphBuilder.js";
import {
@ -14,7 +14,7 @@ import {
} from "./JavaScriptArtifactReconstructionInput.js";
import { scanCanonicalArtifactInventory } from "../../artifacts/inventory/ArtifactInventory.js";
import { summarizeElectronBoundaries } from "./ElectronBoundaryAnalysis.js";
import { buildJavaScriptSemanticGraph } from "./JavaScriptSemanticGraphBuilder.js";
import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js";
import type { ProgressReporter } from "../ProgressReporter.js";
/** Application-layer result retaining local diagnostics outside the canonical graph. */
@ -70,9 +70,23 @@ export const reconstructJavaScriptArtifact = async (
const files = await readJavaScriptArtifactFiles(reader, snapshot, signal);
await reportPhase(
"parse_javascript_sources",
`Parsing ${String(files.files.length)} application source files`,
`Parsing and projecting ${String(files.files.length)} application source files`,
);
const semanticProjection = createJavaScriptSemanticGraphProjection();
const analysis = await analyzeAndProjectJavaScriptArtifactFiles(
files,
semanticProjection.projectFile,
async (file, completed, total) => {
abortIfNeeded(signal);
await progress?.report({
phase: "parse_javascript_source",
completed: 0,
total: 1,
message: `Parsing and projecting ${file.path} (${String(completed + 1)}/${String(total)})`,
});
abortIfNeeded(signal);
},
);
const analysis = analyzeJavaScriptArtifactFiles(files);
abortIfNeeded(signal);
await reportPhase(
"build_javascript_application_graph",
@ -81,13 +95,13 @@ export const reconstructJavaScriptArtifact = async (
const graph = buildJavaScriptArtifactGraph(snapshot, files, analysis);
await reportPhase(
"build_javascript_semantic_graph",
"Constructing static semantic relationships",
"Binding and validating static semantic relationships",
);
const semanticGraph = buildJavaScriptSemanticGraph({
rootArtifactSha256: snapshot.manifest.root_sha256,
applicationGraph: graph,
const semanticGraph = semanticProjection.finish(
snapshot.manifest.root_sha256,
graph,
analysis,
});
);
return {
input_path: path,
format,

View File

@ -8,7 +8,6 @@ import {
} from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
import type {
JavaScriptModuleOrigin,
JavaScriptSemanticIr,
JavaScriptSemanticModuleLink,
} from "../../domain/javascript/javascriptSemanticIr.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
@ -28,8 +27,10 @@ import {
} from "./JavaScriptArtifactPathResolution.js";
import { projectJavaScriptExportReturnShapes } from "./JavaScriptReturnShapeProjection.js";
import type { JavaScriptModuleSemanticIr } from "./JavaScriptArtifactAnalysisTypes.js";
interface SemanticAnalysis {
readonly ir: JavaScriptSemanticIr;
readonly ir: JavaScriptModuleSemanticIr;
}
interface RelationshipInput {
@ -310,7 +311,7 @@ const unresolvedModuleNode = (
});
const moduleOriginForExport = (
ir: JavaScriptSemanticIr,
ir: JavaScriptModuleSemanticIr,
link: JavaScriptSemanticModuleLink,
): JavaScriptModuleOrigin | null => {
if (link.specifier !== null)
@ -374,7 +375,7 @@ const semanticCoverage = (
const moduleFormat = (
path: string,
ir: JavaScriptSemanticIr,
ir: JavaScriptModuleSemanticIr,
): "commonjs" | "esm" | "mixed" | "unknown" => {
const extension = posix.extname(path).toLowerCase();
if (extension === ".mjs") return "esm";

View File

@ -4,13 +4,13 @@ import {
truncatedApplicationCoverage,
} from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
import type {
JavaScriptSemanticIr,
JavaScriptSemanticCallable,
JavaScriptSemanticModuleLink,
} from "../../domain/javascript/javascriptSemanticIr.js";
import { flattenSemanticReturnValue } from "../../domain/javascript/javascriptSemanticReturns.js";
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactGraphCoverage } from "./JavaScriptArtifactGraphContext.js";
import type { JavaScriptModuleSemanticIr } from "./JavaScriptArtifactAnalysisTypes.js";
export interface JavaScriptReturnShapeProjection {
readonly properties: ReturnType<typeof jsonObjectSchema.parse>;
@ -21,7 +21,7 @@ export interface JavaScriptReturnShapeProjection {
/** Project one exact export/callable link into shallow bounded graph values. */
export const projectJavaScriptExportReturnShapes = (input: {
readonly ir: JavaScriptSemanticIr;
readonly ir: JavaScriptModuleSemanticIr;
readonly link: JavaScriptSemanticModuleLink;
readonly modulePath: string;
readonly baseCoverage: JavaScriptArtifactGraphCoverage;

View File

@ -5,10 +5,9 @@ import type {
JavaScriptSemanticTimerOperation,
} from "../../domain/javascript/javascriptSemanticIr.js";
import {
addSemanticGraphNode,
retainSemanticGraphNode,
addSemanticGraphRelation,
addSemanticGraphUnknown,
constructSemanticGraphNode,
} from "./JavaScriptSemanticGraphConstruction.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import {
@ -59,55 +58,41 @@ const addEventNode = (
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,
),
);
retainSemanticGraphNode(context.state, 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,
},
});
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,
),
);
retainSemanticGraphNode(context.state, 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,
},
});
const eventEmitterNode = (
context: SemanticFlowProjectionContext,
@ -164,24 +149,17 @@ export const projectSemanticTimers = (
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,
),
);
const timer = retainSemanticGraphNode(context.state, 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,
},
});
return timer === null ? [] : [[operation.timerId, timer] as const];
}),
);

View File

@ -38,16 +38,17 @@ import {
type SemanticFlowProjectionContext,
} from "./JavaScriptSemanticGraphFlowProjection.js";
import {
owningSemanticCallableNode,
createSemanticCallableOwnerLookup,
createSemanticCallSiteLookup,
createSemanticNodeRangeLookup,
semanticFamilyStatus,
semanticNodesWithinRange,
} from "./JavaScriptSemanticGraphProjection.js";
import {
addSemanticFallbackRoot as addFallbackRoot,
addSemanticGraphNode as addNode,
retainSemanticGraphNode as retainNode,
addSemanticGraphRelation as addRelation,
constructSemanticGraphNode as semanticNode,
createSemanticGraphProjectionState as emptyState,
bindSemanticGraphApplicationNodes,
type SemanticGraphProjectionState as BuilderState,
} from "./JavaScriptSemanticGraphConstruction.js";
@ -72,6 +73,9 @@ interface FileContext extends SemanticFlowProjectionContext {
readonly argumentNodes: Map<string, JavaScriptSemanticGraphNode>;
readonly referenceNodes: JavaScriptSemanticGraphNode[];
readonly callResolutions: ReadonlyMap<string, "candidate" | "resolved">;
readonly callableOwnerAt: ReturnType<
typeof createSemanticCallableOwnerLookup
>;
}
/**
@ -108,152 +112,170 @@ export const buildJavaScriptSemanticGraph = ({
applicationGraph,
analysis,
}: BuilderInput): JavaScriptSemanticGraph => {
const state = emptyState(applicationGraph);
const fingerprints: JavaScriptSemanticGraph["fingerprints"][number][] = [];
let truncatedFiles = 0;
const projection = createJavaScriptSemanticGraphProjection();
for (const analyzed of analysis.files) {
if (analyzed.semantic === null) continue;
if (state.nodes.size >= SEMANTIC_GRAPH_NODE_CEILING) {
truncatedFiles += 1;
continue;
}
const remainingTreeBudget = SEMANTIC_GRAPH_NODE_CEILING - state.nodes.size;
state.fileNodeBudget = Math.min(
SEMANTIC_GRAPH_FILE_NODE_CEILING,
remainingTreeBudget,
);
state.fileNodesDropped = false;
fingerprints.push(
...projectFile(analyzed.file, analyzed.semantic.ir, state),
);
// Nodes were dropped only if the budget actually blocked creation. A file
// that exactly fills its share ends with a zero budget without dropping
// anything, so the remaining budget alone cannot decide truncation.
if (state.fileNodesDropped) truncatedFiles += 1;
state.fileNodeBudget = null;
if (analyzed.semantic !== null)
projection.projectFile(analyzed.file, analyzed.semantic.ir);
}
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: truncatedFiles > 0 ? "partial" : "unknown",
truncated: truncatedFiles > 0,
omitted_nodes: truncatedFiles > 0 ? null : 0,
omitted_relations: truncatedFiles > 0 ? null : 0,
limits:
truncatedFiles > 0
? [
{
name: "semantic_graph_node_ceiling",
value: SEMANTIC_GRAPH_NODE_CEILING,
unit: "items" as const,
},
]
: [],
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: truncatedFiles > 0 ? null : 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.",
],
});
return projection.finish(rootArtifactSha256, applicationGraph, analysis);
};
/** File-local semantic projection that does not retain consumed source IR. */
export interface JavaScriptSemanticGraphProjection {
readonly projectFile: (
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
) => void;
readonly finish: (
rootArtifactSha256: string,
applicationGraph: BuilderInput["applicationGraph"],
analysis: Pick<JavaScriptArtifactAnalysis, "truncated_scopes">,
) => JavaScriptSemanticGraph;
}
/** Accumulate semantic nodes while each source IR is still file-local. */
export const createJavaScriptSemanticGraphProjection =
(): JavaScriptSemanticGraphProjection => {
const state = emptyState({ nodes: [] });
const fingerprints: JavaScriptSemanticGraph["fingerprints"][number][] = [];
let truncatedFiles = 0;
const projectSource = (
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
): void => {
if (state.nodes.size >= SEMANTIC_GRAPH_NODE_CEILING) {
truncatedFiles += 1;
return;
}
const remainingTreeBudget =
SEMANTIC_GRAPH_NODE_CEILING - state.nodes.size;
state.fileNodeBudget = Math.min(
SEMANTIC_GRAPH_FILE_NODE_CEILING,
remainingTreeBudget,
);
state.fileNodesDropped = false;
fingerprints.push(...projectFile(file, ir, state));
// Nodes were dropped only if the budget actually blocked creation. A file
// that exactly fills its share ends with a zero budget without dropping
// anything, so the remaining budget alone cannot decide truncation.
if (state.fileNodesDropped) truncatedFiles += 1;
state.fileNodeBudget = null;
};
return {
projectFile: projectSource,
finish: (rootArtifactSha256, applicationGraph, analysis) => {
bindSemanticGraphApplicationNodes(state, applicationGraph);
if (state.roots.size === 0) addFallbackRoot(rootArtifactSha256, state);
const unknowns = [...state.unknowns.values()];
const graph = 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: truncatedFiles > 0 ? "partial" : "unknown",
truncated: truncatedFiles > 0,
omitted_nodes: truncatedFiles > 0 ? null : 0,
omitted_relations: truncatedFiles > 0 ? null : 0,
limits:
truncatedFiles > 0
? [
{
name: "semantic_graph_node_ceiling",
value: SEMANTIC_GRAPH_NODE_CEILING,
unit: "items" as const,
},
]
: [],
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: truncatedFiles > 0 ? null : 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.",
],
});
state.nodes.clear();
state.relations.clear();
state.unknowns.clear();
state.roots.clear();
fingerprints.length = 0;
return graph;
},
};
};
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,
),
);
const moduleNode = retainNode(state, file, {
kind: "module",
roleKey: "module",
location: null,
label: file.path,
functionNodeId: null,
});
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,
),
);
const node = retainNode(state, 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 },
});
return node === null ? [] : [[callable.callableId, node] as const];
}),
);
const callableOwnerAt = createSemanticCallableOwnerLookup(ir, callableNodes);
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,
),
);
const owner = callableOwnerAt(location);
const node = retainNode(state, file, {
kind,
roleKey: `binding:${binding.bindingId}`,
location,
label: binding.name,
functionNodeId: owner?.node_id ?? null,
});
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 = {
const definitions: Omit<FileContext, "referenceNodesWithin"> = {
file,
ir,
state,
@ -270,8 +292,16 @@ const projectFile = (
call.resolution === "exact" ? "resolved" : "candidate",
]),
),
callableOwnerAt,
callSiteAt: createSemanticCallSiteLookup(ir, callSiteNodes),
};
projectDefinitionsAndReferences(definitions);
const context: FileContext = {
...definitions,
referenceNodesWithin: createSemanticNodeRangeLookup(
definitions.referenceNodes,
),
};
projectDefinitionsAndReferences(context);
projectSemanticValues(context);
projectSemanticObjects(context);
projectCalls(context);
@ -300,20 +330,13 @@ const createReturnSiteNodes = (
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,
),
);
const node = retainNode(state, file, {
kind: "return-site",
roleKey: identifier,
location: site.location,
label: "return",
functionNodeId: owner.node_id,
});
if (node !== null) result.set(identifier, node);
}
}
@ -332,44 +355,32 @@ const createCallSiteNodes = (
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,
),
);
const node = retainNode(state, file, {
kind: "call-site",
roleKey: `call:${call.callSiteId}`,
location: call.location,
label: call.kind,
functionNodeId: owner,
});
if (node !== null) result.set(call.callSiteId, node);
}
return result;
};
const projectDefinitionsAndReferences = (context: FileContext): void => {
const projectDefinitionsAndReferences = (
context: Omit<FileContext, "referenceNodesWithin">,
): 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,
),
);
const definitionNode = retainNode(context.state, context.file, {
kind: "expression",
roleKey: `definition:${binding.bindingId}:${String(index)}`,
location: definition.location,
label: definition.kind,
functionNodeId: bindingNode.function_node_id,
});
addRelation(context.state, {
source: definitionNode,
target: bindingNode,
@ -379,25 +390,14 @@ const projectDefinitionsAndReferences = (context: FileContext): void => {
}
}
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 expression = retainNode(context.state, context.file, {
kind: "expression",
roleKey: `reference:${String(index)}:${reference.role}:${reference.name}`,
location: reference.location,
label: reference.name,
functionNodeId:
context.callableOwnerAt(reference.location)?.node_id ?? null,
});
const binding =
reference.bindingId === null
? undefined
@ -447,10 +447,7 @@ const projectCalls = (context: FileContext): void => {
`${call.callSiteId}\u0000${String(argument.index)}`,
argumentNode,
);
const references = semanticNodesWithinRange(
context.referenceNodes,
argument.location,
);
const references = context.referenceNodesWithin(argument.location);
for (const reference of references)
addRelation(context.state, {
source: reference,
@ -499,19 +496,12 @@ const createArgumentNode = (
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,
),
);
retainNode(context.state, 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,
});

View File

@ -5,10 +5,9 @@ import type {
JavaScriptSemanticChildProcessSpawn,
} from "../../domain/javascript/javascriptSemanticIr.js";
import {
addSemanticGraphNode,
retainSemanticGraphNode,
addSemanticGraphRelation,
addSemanticGraphUnknown,
constructSemanticGraphNode,
} from "./JavaScriptSemanticGraphConstruction.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import {
@ -53,31 +52,23 @@ 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,
),
);
retainSemanticGraphNode(context.state, 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,
},
});
const projectSpawn = (
context: SemanticFlowProjectionContext,
@ -141,21 +132,14 @@ const addStdioNode = (
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,
),
);
retainSemanticGraphNode(context.state, context.file, {
kind: "stdio",
roleKey: `stdio:${spawn.processId}`,
location: spawn.location,
label: spawn.stdioMode,
functionNodeId: child.function_node_id,
properties: { mode: spawn.stdioMode },
});
const projectInteraction = (
context: SemanticFlowProjectionContext,
@ -204,52 +188,38 @@ 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,
),
);
retainSemanticGraphNode(context.state, 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,
},
});
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,
),
);
retainSemanticGraphNode(context.state, 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 },
});
const addChildUnknown = (input: ChildUnknownInput): void => {
const { context, node, location, relationKinds, detail, candidateNodeIds } =

View File

@ -4,6 +4,7 @@ import { javascriptDisplayText } from "../../domain/javascript/javascriptAstValu
import {
createJavaScriptSemanticGraphNode,
createJavaScriptSemanticGraphRelation,
javaScriptSemanticNodeId,
type JavaScriptSemanticGraphNode,
} from "../../domain/javascript/javascriptSemanticGraph.js";
import type { ApplicationGraphEvidence } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
@ -80,6 +81,28 @@ export const createSemanticGraphProjectionState = (
};
};
/** Bind retained semantic identities after all application observations exist. */
export const bindSemanticGraphApplicationNodes = (
state: SemanticGraphProjectionState,
applicationGraph: Pick<JavaScriptApplicationGraph, "nodes">,
): void => {
const index = indexApplicationNodes(applicationGraph.nodes).identifiers;
for (const [identifier, node] of state.nodes) {
state.nodes.set(identifier, {
...node,
application_node_ids: [
...(index.get(
applicationLocationKey(
node.identity.artifact_sha256,
node.identity.module_path,
node.identity.source_range,
),
) ?? []),
],
});
}
};
/** Construct one canonical semantic node backed by an exact artifact file. */
export const constructSemanticGraphNode = (
file: JavaScriptArtifactFile,
@ -101,6 +124,33 @@ export const constructSemanticGraphNode = (
evidence: observedSemanticEvidence(file, input.location),
});
/** Reuse retained identities and reject exhausted budgets before allocation. */
export const retainSemanticGraphNode = (
state: SemanticGraphProjectionState,
file: JavaScriptArtifactFile,
input: SemanticNodeConstructionInput,
): JavaScriptSemanticGraphNode | null => {
const identifier = javaScriptSemanticNodeId({
kind: input.kind,
identity: {
artifact_sha256: file.sha256,
module_path: file.path,
source_range: input.location,
role_key: input.roleKey,
},
});
const existing = state.nodes.get(identifier);
if (existing !== undefined) return existing;
if (typeof state.fileNodeBudget === "number" && state.fileNodeBudget <= 0) {
state.fileNodesDropped = true;
return null;
}
return addSemanticGraphNode(
state,
constructSemanticGraphNode(file, input, state),
);
};
/** Retain one canonical semantic node. */
export const addSemanticGraphNode = (
state: SemanticGraphProjectionState,

View File

@ -6,10 +6,9 @@ import type {
JavaScriptSemanticRequestOperation,
} from "../../domain/javascript/javascriptSemanticIr.js";
import {
addSemanticGraphNode,
retainSemanticGraphNode,
addSemanticGraphRelation,
addSemanticGraphUnknown,
constructSemanticGraphNode,
} from "./JavaScriptSemanticGraphConstruction.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import {
@ -70,29 +69,22 @@ 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,
),
);
retainSemanticGraphNode(context.state, 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,
},
});
const configurationRelation = (
operation: JavaScriptSemanticConfigurationOperation,
@ -144,29 +136,22 @@ 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,
),
);
retainSemanticGraphNode(context.state, 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,
},
});
const projectRequestConstruction = (
context: SemanticFlowProjectionContext,
@ -265,28 +250,21 @@ 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,
),
);
retainSemanticGraphNode(context.state, 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,
},
});
const bindingOrOwner = (
context: SemanticFlowProjectionContext,

View File

@ -5,17 +5,15 @@ import { sourceRangesEqual } from "../../domain/javascript/javascriptStaticAnaly
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import {
addSemanticGraphNode,
retainSemanticGraphNode,
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 {
@ -28,6 +26,12 @@ export interface SemanticFlowProjectionContext {
readonly callSiteNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
readonly returnSiteNodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>;
readonly referenceNodes: readonly JavaScriptSemanticGraphNode[];
readonly referenceNodesWithin: (
range: JavaScriptSourceRange,
) => JavaScriptSemanticGraphNode[];
readonly callSiteAt: (
range: JavaScriptSourceRange,
) => JavaScriptSemanticGraphNode | undefined;
}
/** Find the retained call-site node at one exact semantic source range. */
@ -35,12 +39,7 @@ 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);
return context.callSiteAt(location);
};
/** Project explicit static Promise/task ownership without runtime claims. */
@ -53,26 +52,19 @@ export const projectSemanticPromises = (
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,
),
);
const node = retainSemanticGraphNode(context.state, 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,
},
});
return node === null ? [] : [[operation.promiseId, node] as const];
}),
);
@ -217,10 +209,7 @@ export const projectSemanticReturnValues = (
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,
);
const references = context.referenceNodesWithin(site.location);
for (const reference of references)
addSemanticGraphRelation(context.state, {
source: reference,

View File

@ -2,10 +2,9 @@ import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascript/ja
import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraph.js";
import type { JavaScriptSemanticObjectOperation } from "../../domain/javascript/javascriptSemanticIr.js";
import {
addSemanticGraphNode,
retainSemanticGraphNode,
addSemanticGraphRelation,
addSemanticGraphUnknown,
constructSemanticGraphNode,
} from "./JavaScriptSemanticGraphConstruction.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
@ -72,31 +71,24 @@ 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,
),
);
retainSemanticGraphNode(context.state, 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,
},
});
const addObjectUnknown = (
context: SemanticFlowProjectionContext,

View File

@ -0,0 +1,259 @@
import { expect, it } from "vitest";
import { compareCodePoints } from "../../domain/canonicalOrdering.js";
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js";
import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraph.js";
import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js";
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import {
constructSemanticGraphNode,
createSemanticGraphProjectionState,
} from "./JavaScriptSemanticGraphConstruction.js";
import {
createSemanticCallableOwnerLookup,
createSemanticNodeRangeLookup,
createSemanticCallSiteLookup,
} from "./JavaScriptSemanticGraphProjection.js";
import { sourceRangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js";
const fixtureFile: JavaScriptArtifactFile = {
path: "app.js",
container_sha256: "a".repeat(64),
sha256: "b".repeat(64),
bytes: 0,
inventory_artifact_id: "fixture-app",
kind: "javascript",
unpacked: false,
text: { included: true, value: "" },
};
const nodesFor = (ir: JavaScriptSemanticIr) => {
const state = createSemanticGraphProjectionState({ nodes: [] });
return new Map(
ir.callables.map((callable) => [
callable.callableId,
constructSemanticGraphNode(
fixtureFile,
{
kind: "function",
roleKey: callable.callableId,
label: callable.name,
location: callable.location,
functionNodeId: null,
},
state,
),
]),
);
};
// Reference behavior before the optimization, including stable ties and
// fallback when the most deeply nested callable has no retained graph node.
const previousOwner = (
location: JavaScriptSourceRange | null,
ir: JavaScriptSemanticIr,
nodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
) => {
if (location === null) return undefined;
const compare = (
left: JavaScriptSourceRange["start"],
right: JavaScriptSourceRange["start"],
) => left.line - right.line || left.column - right.column;
const key = (range: JavaScriptSourceRange) =>
`${String(range.start.line).padStart(12, "0")}:${String(range.start.column).padStart(12, "0")}`;
return ir.callables
.filter(
(callable) =>
compare(callable.location.start, location.start) <= 0 &&
compare(callable.location.end, location.end) >= 0,
)
.sort((left, right) =>
compareCodePoints(key(right.location), key(left.location)),
)
.map((callable) => nodes.get(callable.callableId))
.find((node) => node !== undefined);
};
const rangesFor = (ir: JavaScriptSemanticIr) => [
null,
...ir.callables.map((callable) => callable.location),
...ir.bindings.flatMap((binding) =>
binding.definitions.map((definition) => definition.location),
),
...ir.references.map((reference) => reference.location),
{ start: { line: 999, column: 0 }, end: { line: 999, column: 1 } },
];
it("matches the prior owner selection across nested functions, arrows and methods", () => {
const ir = analyzeJavaScriptSemantics(`
const outside = 1;
function outer(parameter) {
function inner(value) { return value + parameter; }
const callback = argument => inner(argument);
return callback(outside);
}
class Example { method(value) { return () => outer(value); } }
`);
const nodes = nodesFor(ir);
const lookup = createSemanticCallableOwnerLookup(ir, nodes);
for (const range of rangesFor(ir))
expect(lookup(range)).toBe(previousOwner(range, ir, nodes));
});
it("reads retained node membership at query time and keeps file indexes independent", () => {
const ir = analyzeJavaScriptSemantics(
"function outer() { function inner() { return 1; } return inner(); }",
);
const inner = ir.callables.find((callable) => callable.name === "inner");
if (inner === undefined) throw new Error("Missing inner fixture callable");
const nodes = nodesFor(ir);
const otherNodes = nodesFor(ir);
const lookup = createSemanticCallableOwnerLookup(ir, nodes);
const otherLookup = createSemanticCallableOwnerLookup(ir, otherNodes);
const before = lookup(inner.location);
expect(before?.label).toBe("inner");
nodes.delete(inner.callableId);
expect(lookup(inner.location)?.label).toBe("outer");
expect(otherLookup(inner.location)?.label).toBe("inner");
if (before === undefined) throw new Error("Missing retained fixture node");
nodes.set(inner.callableId, before);
expect(lookup(inner.location)).toBe(before);
});
it("preserves stable ties and non-nested overlapping ranges", () => {
const original = analyzeJavaScriptSemantics(
"function first() { return 1; } function second() { return 2; } function third() { return 3; }",
);
const range = (start: number, end: number): JavaScriptSourceRange => ({
start: { line: 1, column: start },
end: { line: 1, column: end },
});
const ir = {
...original,
callables: original.callables.map((callable, index) => ({
...callable,
location: index === 0 ? range(0, 90) : range(20, index === 1 ? 60 : 80),
})),
};
const nodes = nodesFor(ir);
const lookup = createSemanticCallableOwnerLookup(ir, nodes);
for (const query of [
range(25, 30),
range(25, 70),
range(0, 80),
range(80, 90),
range(91, 92),
])
expect(lookup(query)).toBe(previousOwner(query, ir, nodes));
});
it("matches the prior result across a compiled-style collection of sibling callables", () => {
const ir = analyzeJavaScriptSemantics(
Array.from(
{ length: 300 },
(_, index) =>
`function f${String(index)}(value) { return value + ${String(index)}; }`,
).join(""),
);
const nodes = nodesFor(ir);
const lookup = createSemanticCallableOwnerLookup(ir, nodes);
for (const range of rangesFor(ir))
expect(lookup(range)).toBe(previousOwner(range, ir, nodes));
});
it("returns contained nodes in original order, including repeated and overlapping entries", () => {
const ir = analyzeJavaScriptSemantics(`
function outer(value) { function inner() { return value; } return inner(); }
const sibling = () => outer(1);
`);
const nodes = [...nodesFor(ir).values()].reverse();
const repeated = nodes[0];
if (repeated === undefined) throw new Error("Missing range fixture node");
nodes.push(
repeated,
constructSemanticGraphNode(
fixtureFile,
{
kind: "module",
roleKey: "module",
location: null,
label: "module",
functionNodeId: null,
},
createSemanticGraphProjectionState({ nodes: [] }),
),
);
const lookup = createSemanticNodeRangeLookup(nodes);
const compare = (
left: JavaScriptSourceRange["start"],
right: JavaScriptSourceRange["start"],
) => left.line - right.line || left.column - right.column;
for (const range of rangesFor(ir)) {
if (range === null) continue;
const expected = nodes.filter(
({ identity }) =>
identity.source_range !== null &&
compare(range.start, identity.source_range.start) <= 0 &&
compare(range.end, identity.source_range.end) >= 0,
);
expect(lookup(range)).toEqual(expected);
}
expect(
createSemanticNodeRangeLookup([])(
repeated.identity.source_range ?? {
start: { line: 1, column: 0 },
end: { line: 1, column: 1 },
},
),
).toEqual([]);
});
it("keeps first-call matching and current retained membership for exact locations", () => {
const original = analyzeJavaScriptSemantics(
"function target(value) { return value; } target(1); target(2);",
);
const first = original.callSites[0];
const node = [...nodesFor(original).values()][0];
if (first === undefined || node === undefined)
throw new Error("Missing call fixture");
const duplicate = { ...first, callSiteId: `${first.callSiteId}:duplicate` };
const ir = {
...original,
callSites: [first, duplicate, ...original.callSites.slice(1)],
};
const nodes = new Map(
ir.callSites.map((call) => [
call.callSiteId,
constructSemanticGraphNode(
fixtureFile,
{
kind: "call-site",
roleKey: call.callSiteId,
location: call.location,
label: call.callSiteId,
functionNodeId: null,
},
createSemanticGraphProjectionState({ nodes: [] }),
),
]),
);
const lookup = createSemanticCallSiteLookup(ir, nodes);
const previous = (range: JavaScriptSourceRange) => {
const call = ir.callSites.find((candidate) =>
sourceRangesEqual(candidate.location, range),
);
return call === undefined ? undefined : nodes.get(call.callSiteId);
};
for (const call of ir.callSites)
expect(lookup(call.location)).toBe(previous(call.location));
nodes.delete(first.callSiteId);
expect(lookup(first.location)).toBeUndefined();
expect(nodes.has(duplicate.callSiteId)).toBe(true);
nodes.set(first.callSiteId, node);
expect(lookup(first.location)).toBe(node);
expect(
lookup({ start: { line: 99, column: 0 }, end: { line: 99, column: 1 } }),
).toBeUndefined();
});

View File

@ -5,40 +5,173 @@ import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSem
import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js";
import type { JavaScriptArtifactAnalysis } from "./JavaScriptArtifactAnalysisTypes.js";
/** Find the innermost callable that contains one exact source range. */
export const owningSemanticCallableNode = (
location: JavaScriptSourceRange | null,
interface SourceInterval {
readonly range: JavaScriptSourceRange;
readonly order: number;
}
interface CallableInterval extends SourceInterval {
readonly callableId: string;
readonly priority: string;
}
interface SourceIntervalTree<Interval extends SourceInterval> {
readonly entry: Interval;
readonly minimumStart: JavaScriptSourceRange["start"];
readonly maximumStart: JavaScriptSourceRange["start"];
readonly minimumEnd: JavaScriptSourceRange["end"];
readonly maximumEnd: JavaScriptSourceRange["end"];
readonly left: SourceIntervalTree<Interval> | null;
readonly right: SourceIntervalTree<Interval> | null;
}
/** Index one immutable file IR; read retained callable membership at query time. */
export const createSemanticCallableOwnerLookup = (
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);
): ((
location: JavaScriptSourceRange | null,
) => JavaScriptSemanticGraphNode | undefined) => {
const entries = ir.callables
.map((callable, order): CallableInterval => ({
callableId: callable.callableId,
range: callable.location,
order,
priority: positionKey(callable.location),
}))
.sort(
(left, right) =>
pointCompare(left.range.start, right.range.start) ||
left.order - right.order,
);
const tree = buildSourceIntervalTree(entries, 0, entries.length);
return (location) => {
if (location === null || tree === null || nodes.size === 0)
return undefined;
const pending = [tree];
let best: CallableInterval | undefined;
let result: JavaScriptSemanticGraphNode | undefined;
while (pending.length > 0) {
const current = pending.pop();
if (
current === undefined ||
pointCompare(current.minimumStart, location.start) > 0 ||
pointCompare(current.maximumEnd, location.end) < 0
)
continue;
const candidate = current.entry;
if (contains(candidate.range, location)) {
const node = nodes.get(candidate.callableId);
const rank =
best === undefined
? 1
: compareCodePoints(candidate.priority, best.priority) ||
best.order - candidate.order;
if (node !== undefined && rank > 0) {
best = candidate;
result = node;
}
}
if (current.left !== null) pending.push(current.left);
if (current.right !== null) pending.push(current.right);
}
return result;
};
};
/** Find retained semantic nodes whose exact ranges are within one expression. */
export const semanticNodesWithinRange = (
const buildSourceIntervalTree = <Interval extends SourceInterval>(
entries: readonly Interval[],
start: number,
end: number,
): SourceIntervalTree<Interval> | null => {
if (start >= end) return null;
const middle = Math.floor((start + end) / 2);
const entry = entries[middle];
if (entry === undefined) return null;
// This recursion follows a balanced index, not the producer's nesting depth.
const left = buildSourceIntervalTree(entries, start, middle);
const right = buildSourceIntervalTree(entries, middle + 1, end);
let minimumEnd = entry.range.end;
let maximumEnd = entry.range.end;
for (const child of [left, right]) {
if (child !== null && pointCompare(child.minimumEnd, minimumEnd) < 0)
minimumEnd = child.minimumEnd;
if (child !== null && pointCompare(child.maximumEnd, maximumEnd) > 0)
maximumEnd = child.maximumEnd;
}
return {
entry,
minimumStart: left?.minimumStart ?? entry.range.start,
maximumStart: right?.maximumStart ?? entry.range.start,
minimumEnd,
maximumEnd,
left,
right,
};
};
/** Index an immutable node list; return contained ranges in producer order. */
export const createSemanticNodeRangeLookup = (
nodes: readonly JavaScriptSemanticGraphNode[],
): ((range: JavaScriptSourceRange) => JavaScriptSemanticGraphNode[]) => {
const entries = nodes
.flatMap((node, order) => {
const range = node.identity.source_range;
return range === null ? [] : [{ node, range, order }];
})
.sort(
(left, right) =>
pointCompare(left.range.start, right.range.start) ||
left.order - right.order,
);
const tree = buildSourceIntervalTree(entries, 0, entries.length);
return (range) => {
if (tree === null) return [];
const pending = [tree];
const matches: (typeof entries)[number][] = [];
while (pending.length > 0) {
const current = pending.pop();
if (
current === undefined ||
pointCompare(current.maximumStart, range.start) < 0 ||
pointCompare(current.minimumEnd, range.end) > 0
)
continue;
if (contains(range, current.entry.range)) matches.push(current.entry);
if (current.left !== null) pending.push(current.left);
if (current.right !== null) pending.push(current.right);
}
return matches
.sort((left, right) => left.order - right.order)
.map(({ node }) => node);
};
};
/** Index exact call ranges, preserving the first producer occurrence. */
export const createSemanticCallSiteLookup = (
ir: JavaScriptSemanticIr,
nodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
): ((
range: JavaScriptSourceRange,
): JavaScriptSemanticGraphNode[] =>
nodes.filter(
(node) =>
node.identity.source_range !== null &&
contains(range, node.identity.source_range),
);
) => JavaScriptSemanticGraphNode | undefined) => {
const identifiers = new Map<string, string>();
for (const call of ir.callSites) {
const key = rangeKey(call.location);
if (!identifiers.has(key)) identifiers.set(key, call.callSiteId);
}
return (range) => {
const identifier = identifiers.get(rangeKey(range));
return identifier === undefined ? undefined : nodes.get(identifier);
};
};
const rangeKey = (range: JavaScriptSourceRange): string =>
`${range.start.line}:${range.start.column}-${range.end.line}:${range.end.column}`;
/** Report extractor support without treating missing families as absence. */
export const semanticFamilyStatus = (
family: (typeof JAVASCRIPT_SEMANTIC_RELATION_FAMILIES)[number],
analysis: JavaScriptArtifactAnalysis,
analysis: Pick<JavaScriptArtifactAnalysis, "truncated_scopes">,
): "complete" | "partial" | "unknown" | "unsupported" => {
if (
![

View File

@ -2,10 +2,9 @@ import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascript/ja
import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraph.js";
import type { JavaScriptSemanticResourceOperation } from "../../domain/javascript/javascriptSemanticIr.js";
import {
addSemanticGraphNode,
retainSemanticGraphNode,
addSemanticGraphRelation,
addSemanticGraphUnknown,
constructSemanticGraphNode,
} from "./JavaScriptSemanticGraphConstruction.js";
import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import {
@ -39,25 +38,18 @@ 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,
),
);
retainSemanticGraphNode(context.state, 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 },
});
const projectRelease = (
context: SemanticFlowProjectionContext,

View File

@ -4,9 +4,8 @@ import type {
JavaScriptSemanticValue,
} from "../../domain/javascript/javascriptSemanticIr.js";
import {
addSemanticGraphNode,
retainSemanticGraphNode,
addSemanticGraphRelation,
constructSemanticGraphNode,
} from "./JavaScriptSemanticGraphConstruction.js";
import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js";
@ -85,22 +84,15 @@ const addLiteralNode = (
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,
),
);
retainSemanticGraphNode(context.state, 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 },
});
/** Create one canonical property slot for a binding/property identity. */
export const semanticPropertySlot = (
@ -108,22 +100,15 @@ export const semanticPropertySlot = (
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,
),
);
retainSemanticGraphNode(context.state, 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>>,
});

View File

@ -61,6 +61,10 @@ function* bufferedParts(parts: Iterable<string>): Generator<string> {
}
function* encodeString(value: string): Generator<string> {
if (value.length <= 8192) {
yield JSON.stringify(value);
return;
}
yield '"';
for (let start = 0; start < value.length;) {
let end = Math.min(start + 8192, value.length);

View File

@ -17,6 +17,31 @@ const TARGET: BinaryTarget = {
const PROVIDER = { id: "fixture", name: "Fixture provider", version: "1" };
const PROFILE = createAnalysisProfile(PROVIDER, { loader: "default" });
it("snapshots caller-owned payloads before deriving their identity", () => {
const result = { nested: { values: ["observed"] } };
const rawResult = { entries: [{ value: "raw" }] };
const parameters = { selected: ["target"] };
const evidence = createEvidence(TARGET, PROVIDER, {
operation: "inspect",
parameters,
result,
rawResult,
});
result.nested.values.push("later");
const rawEntry = rawResult.entries[0];
if (rawEntry !== undefined) rawEntry.value = "changed";
parameters.selected.push("other");
expect(evidence.normalized_result).toEqual({
nested: { values: ["observed"] },
});
expect(evidence.raw_result).toEqual({ entries: [{ value: "raw" }] });
expect(evidence.parameters).toEqual({ selected: ["target"] });
expect(parseEvidence(evidence)).toEqual(evidence);
expect(() =>
parseEvidence({ ...evidence, normalized_result: result }),
).toThrow(/semantic identifier/u);
});
describe("analysis evidence identity", () => {
it("normalizes prototype-named parameter keys", () => {
const evidence = createEvidence(TARGET, PROVIDER, {

View File

@ -274,13 +274,23 @@ export const createEvidence = (
locations: [...(observation.locations ?? [])],
evidence_links: [...(observation.evidenceLinks ?? [])],
} satisfies JsonValue;
const normalized = evidenceRecordSchema.parse({
// Select the known envelope before parsing. A union otherwise traverses and
// clones a legacy result for the profiled branch before rejecting its absent
// profile and trying the legacy branch.
const schema =
observation.analysisProfile === undefined
? evidenceBaseSchema
: profiledEvidenceSchema;
const normalized = schema.parse({
...semantic,
evidence_id: `ev_${"0".repeat(64)}`,
subject,
});
return parseEvidence({
// The envelope has already been parsed into an independent snapshot. Only
// its derived identifier changes here; parsing again clones the full payload
// and recomputes the same digest while the previous snapshot is still live.
return {
...normalized,
evidence_id: computeEvidenceId(normalized),
});
};
};

View File

@ -38,7 +38,8 @@ const normalizeEvidence = (
evidence_ids: uniqueSorted(evidence.evidence_ids),
});
const nodeId = (
/** Derive a semantic node ID from its kind and exact artifact identity. */
export const javaScriptSemanticNodeId = (
node: Pick<JavaScriptSemanticGraphNode, "kind" | "identity">,
): string =>
`jsrg_node_${canonicalDigest({ kind: node.kind, identity: node.identity }, "JavaScript semantic graph")}`;
@ -59,7 +60,7 @@ export const createJavaScriptSemanticGraphNode = (
};
return javaScriptSemanticNodeSchema.parse({
...semantic,
node_id: nodeId(semantic),
node_id: javaScriptSemanticNodeId(semantic),
});
};
@ -129,11 +130,21 @@ export const createJavaScriptSemanticFingerprint = (input: unknown) => {
type GraphRecord = z.infer<typeof javaScriptSemanticGraphRecordSchema>;
type SemanticGraphIssue = {
readonly code: "custom";
readonly path: PropertyKey[];
readonly message: string;
};
interface GraphIssueReporter {
readonly addIssue: (issue: SemanticGraphIssue) => void;
}
const sortedUniqueIssue = (
values: readonly string[],
path: PropertyKey[],
label: string,
context: z.RefinementCtx,
context: GraphIssueReporter,
): void => {
for (let index = 1; index < values.length; index += 1) {
if (compareCodePoints(values[index - 1] ?? "", values[index] ?? "") < 0)
@ -147,7 +158,10 @@ const sortedUniqueIssue = (
}
};
const checkCoverage = (graph: GraphRecord, context: z.RefinementCtx): void => {
const checkCoverage = (
graph: GraphRecord,
context: GraphIssueReporter,
): void => {
const families = graph.coverage.families.map(({ family }) => family);
if (
canonicalJson(families, "JavaScript semantic graph") !==
@ -221,7 +235,7 @@ const checkCoverage = (graph: GraphRecord, context: z.RefinementCtx): void => {
const checkCanonicalOrder = (
graph: GraphRecord,
context: z.RefinementCtx,
context: GraphIssueReporter,
): void => {
sortedUniqueIssue(
graph.root_node_ids,
@ -259,10 +273,10 @@ const checkCanonicalOrder = (
const checkNodes = (
graph: GraphRecord,
nodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
context: z.RefinementCtx,
context: GraphIssueReporter,
): void => {
for (const [index, node] of graph.nodes.entries()) {
if (node.node_id !== nodeId(node))
if (node.node_id !== javaScriptSemanticNodeId(node))
context.addIssue({
code: "custom",
path: ["nodes", index, "node_id"],
@ -283,7 +297,7 @@ const checkNodes = (
const checkRoots = (
graph: GraphRecord,
nodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
context: z.RefinementCtx,
context: GraphIssueReporter,
): void => {
for (const root of graph.root_node_ids)
if (!nodes.has(root))
@ -297,7 +311,7 @@ const checkRoots = (
const checkRelations = (
graph: GraphRecord,
nodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
context: z.RefinementCtx,
context: GraphIssueReporter,
): void => {
for (const [index, relation] of graph.relations.entries()) {
const { relation_id: identifier, ...semantic } = relation;
@ -340,7 +354,7 @@ const checkRelations = (
const checkUnknowns = (
graph: GraphRecord,
nodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
context: z.RefinementCtx,
context: GraphIssueReporter,
): void => {
for (const [index, unknown] of graph.unknowns.entries()) {
const { unknown_id: identifier, ...semantic } = unknown;
@ -386,7 +400,7 @@ const checkUnknowns = (
const checkUnknownReferences = (
graph: GraphRecord,
context: z.RefinementCtx,
context: GraphIssueReporter,
): void => {
const unknownIds = new Set(
graph.unknowns.map(({ unknown_id }) => unknown_id),
@ -403,7 +417,7 @@ const checkUnknownReferences = (
const checkFingerprints = (
graph: GraphRecord,
nodes: ReadonlyMap<string, JavaScriptSemanticGraphNode>,
context: z.RefinementCtx,
context: GraphIssueReporter,
): void => {
for (const [index, fingerprint] of graph.fingerprints.entries()) {
if (nodes.get(fingerprint.function_node_id)?.kind !== "function")
@ -449,7 +463,10 @@ const checkFingerprints = (
}
};
const checkGraph = (graph: GraphRecord, context: z.RefinementCtx): void => {
const checkGraphContent = (
graph: GraphRecord,
context: GraphIssueReporter,
): void => {
checkCanonicalOrder(graph, context);
const nodes = new Map(graph.nodes.map((node) => [node.node_id, node]));
checkNodes(graph, nodes, context);
@ -465,6 +482,10 @@ const checkGraph = (graph: GraphRecord, context: z.RefinementCtx): void => {
path: ["limitations"],
message: "Non-complete graph coverage requires a limitation",
});
};
const checkGraph = (graph: GraphRecord, context: GraphIssueReporter): void => {
checkGraphContent(graph, context);
const { graph_id: identifier, ...semantic } = graph;
if (
identifier !==
@ -523,10 +544,21 @@ export const createJavaScriptSemanticGraph = (
},
limitations: uniqueSorted(parsed.limitations),
};
return javaScriptSemanticGraphSchema.parse({
// The input schema already cloned and validated every field. Validate the
// normalized relationships in place, then derive the only added field. The
// public schema still verifies arbitrary records and their commitments.
const record: GraphRecord = {
...semantic,
graph_id: `jsrg_${canonicalDigest(semantic, "JavaScript semantic graph")}`,
};
const issues: SemanticGraphIssue[] = [];
checkGraphContent(record, {
addIssue: (issue) => {
issues.push(issue);
},
});
if (issues.length > 0) throw new z.ZodError(issues);
return record;
};
export type { JavaScriptSemanticGraphNode, JavaScriptSemanticGraphRelation };

View File

@ -7,6 +7,18 @@ import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { reconstructJavaScriptArtifact } from "../../../src/application/javascript/JavaScriptArtifactReconstruction.js";
import {
analyzeJavaScriptArtifactFiles,
analyzeAndProjectJavaScriptArtifactFiles,
} from "../../../src/application/javascript/JavaScriptArtifactAnalysis.js";
import { buildJavaScriptArtifactGraph } from "../../../src/application/javascript/JavaScriptArtifactGraphBuilder.js";
import {
buildJavaScriptSemanticGraph,
createJavaScriptSemanticGraphProjection,
} from "../../../src/application/javascript/JavaScriptSemanticGraphBuilder.js";
import { createJavaScriptArtifactReader } from "../../../src/artifacts/javascript/JavaScriptArtifactReader.js";
import { readJavaScriptArtifactFiles } from "../../../src/artifacts/javascript/JavaScriptArtifactFiles.js";
import { scanCanonicalArtifactInventory } from "../../../src/artifacts/inventory/ArtifactInventory.js";
import { scanArtifactInventory } from "../../../src/artifacts/inventory/ArtifactInventory.js";
import { parseJavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
@ -14,6 +26,83 @@ import { createJavaScriptSemanticGraph } from "../../../src/domain/javascript/ja
import { parseJavaScriptSemanticGraph } from "../../../src/domain/javascript/javascriptSemanticGraphSerialization.js";
import { writeJavaScriptArtifactFixture } from "../../fixtures/javascriptArtifactApplication.js";
it("reports file progress and honors cancellation before the next source", async () => {
const root = await fixtureDirectory();
const controller = new AbortController();
const progress: string[] = [];
await expect(
reconstructJavaScriptArtifact({ input_path: root }, controller.signal, {
report: async (event) => {
if (event.phase === "parse_javascript_source") {
progress.push(event.message);
if (progress.length === 2) controller.abort();
}
},
}),
).rejects.toMatchObject({ reason: "cancelled" });
expect(progress).toHaveLength(2);
expect(progress[0]).not.toBe(progress[1]);
});
it("preserves graph commitments and export shapes when consuming file-local IR", async () => {
const root = await fixtureDirectory();
await writeFile(
join(root, "exports.mjs"),
`
import { readFile } from "node:fs";
export { readFile as read };
export function create(value) { return { value, nested: { enabled: true } }; }
function unexported() { return "private"; }
`,
);
const snapshot = await scanCanonicalArtifactInventory(root, {});
const reader = createJavaScriptArtifactReader(root, "directory");
try {
const files = await readJavaScriptArtifactFiles(reader, snapshot);
const full = analyzeJavaScriptArtifactFiles(files);
const projection = createJavaScriptSemanticGraphProjection();
const compact = await analyzeAndProjectJavaScriptArtifactFiles(
files,
projection.projectFile,
);
const graph = buildJavaScriptArtifactGraph(snapshot, files, full);
expect(buildJavaScriptArtifactGraph(snapshot, files, compact)).toEqual(
graph,
);
const semantic = projection.finish(
snapshot.manifest.root_sha256,
graph,
compact,
);
expect(semantic).toEqual(
buildJavaScriptSemanticGraph({
rootArtifactSha256: snapshot.manifest.root_sha256,
applicationGraph: graph,
analysis: full,
}),
);
expect(
semantic.nodes.some(
({ application_node_ids }) => application_node_ids.length > 0,
),
).toBe(true);
const exported = compact.files.find(
({ file }) => file.path === "exports.mjs",
);
expect(exported?.semantic?.ir.callables.map(({ name }) => name)).toEqual([
"create",
]);
expect(
full.files
.find(({ file }) => file.path === "exports.mjs")
?.semantic?.ir.callables.map(({ name }) => name),
).toContain("unexported");
expect(parseJavaScriptSemanticGraph(semantic)).toEqual(semantic);
} finally {
await reader.close();
}
});
it("reconstructs package, Electron roles, Webpack/Rspack modules, and cross-layer facts without execution", async () => {
const root = await fixtureDirectory();
Reflect.deleteProperty(globalThis, "__rea_bundle_executed");