From b9fb43883570bef7cb29fd16f27c25d5a7e5bb54 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:15:35 +0800 Subject: [PATCH] fix(javascript): complete large-ASAR analysis with file-local projection (#1055) --- docs/javascript-artifact-reconstruction.md | 15 + .../javascript/ElectronBoundaryAnalysis.ts | 6 +- .../JavaScriptApplicationEvidence.ts | 3 +- .../javascript/JavaScriptArtifactAnalysis.ts | 108 ++++- .../JavaScriptArtifactAnalysisTypes.ts | 27 +- .../JavaScriptArtifactGraphBuilder.ts | 4 +- .../JavaScriptArtifactGraphContext.ts | 6 +- .../JavaScriptArtifactGraphStructure.ts | 4 +- .../JavaScriptArtifactReconstruction.ts | 32 +- .../JavaScriptModuleRelationships.ts | 9 +- .../JavaScriptReturnShapeProjection.ts | 4 +- .../JavaScriptSemanticGraphAsyncProjection.ts | 106 ++--- .../JavaScriptSemanticGraphBuilder.ts | 406 +++++++++--------- ...riptSemanticGraphChildProcessProjection.ts | 136 +++--- .../JavaScriptSemanticGraphConstruction.ts | 50 +++ .../JavaScriptSemanticGraphDataProjection.ts | 118 +++-- .../JavaScriptSemanticGraphFlowProjection.ts | 55 +-- ...JavaScriptSemanticGraphObjectProjection.ts | 46 +- .../JavaScriptSemanticGraphProjection.test.ts | 259 +++++++++++ .../JavaScriptSemanticGraphProjection.ts | 181 ++++++-- ...vaScriptSemanticGraphResourceProjection.ts | 34 +- .../JavaScriptSemanticGraphValueProjection.ts | 59 +-- src/cli/jsonOutput.ts | 4 + src/domain/evidence.test.ts | 25 ++ src/domain/evidence.ts | 16 +- .../javascript/javascriptSemanticGraph.ts | 60 ++- .../javascriptArtifactReconstruction.test.ts | 89 ++++ 27 files changed, 1231 insertions(+), 631 deletions(-) create mode 100644 src/application/javascript/JavaScriptSemanticGraphProjection.test.ts diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 7bdf39959..f3eda9698 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -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. diff --git a/src/application/javascript/ElectronBoundaryAnalysis.ts b/src/application/javascript/ElectronBoundaryAnalysis.ts index 99de494e2..c1f842317 100644 --- a/src/application/javascript/ElectronBoundaryAnalysis.ts +++ b/src/application/javascript/ElectronBoundaryAnalysis.ts @@ -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 }) => diff --git a/src/application/javascript/JavaScriptApplicationEvidence.ts b/src/application/javascript/JavaScriptApplicationEvidence.ts index c0ae7551c..c291f7673 100644 --- a/src/application/javascript/JavaScriptApplicationEvidence.ts +++ b/src/application/javascript/JavaScriptApplicationEvidence.ts @@ -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", diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index b390854e0..aba362b76 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -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[]; 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; + 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, +): Promise => { + const state = emptyArtifactAnalysis(); + 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 = ( + fileSet: JavaScriptArtifactFileSet, + projectSemantics: ArtifactAnalysisContext["projectSemantics"], +): JavaScriptArtifactAnalysis => { + const state = emptyArtifactAnalysis(); + 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, +): JavaScriptArtifactAnalysis => { const truncatedScopes = state.truncatedScopes; return { files: state.files, @@ -79,9 +150,9 @@ const finalizeArtifactAnalysis = ( }; }; -const analyzeArtifactFile = ( +const analyzeArtifactFile = ( file: JavaScriptArtifactFile, - context: ArtifactAnalysisContext, + context: ArtifactAnalysisContext, ): 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, ): void => { const sourceMap = parseSourceMap(file); context.state.sourceMaps.push(sourceMap); }; -const emptyArtifactAnalysis = (): MutableArtifactAnalysis => ({ +const emptyArtifactAnalysis = < + SemanticIr extends JavaScriptModuleSemanticIr, +>(): MutableArtifactAnalysis => ({ files: [], packages: [], jsonModules: [], @@ -142,7 +218,7 @@ const emptyArtifactAnalysis = (): MutableArtifactAnalysis => ({ const addStructuredObservations = ( file: JavaScriptArtifactFile, - state: MutableArtifactAnalysis, + state: MutableArtifactAnalysis, ): void => { if (file.kind === "package-json") state.packages.push(parsePackage(file)); if (file.kind !== "json") return; diff --git a/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts b/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts index d1cdd2a6b..152b3520a 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts @@ -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[]; 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; diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index 49f317270..e910d9cc5 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -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); diff --git a/src/application/javascript/JavaScriptArtifactGraphContext.ts b/src/application/javascript/JavaScriptArtifactGraphContext.ts index 291dfa46f..0ef275eeb 100644 --- a/src/application/javascript/JavaScriptArtifactGraphContext.ts +++ b/src/application/javascript/JavaScriptArtifactGraphContext.ts @@ -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; readonly fileNodes: Map; @@ -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" diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.ts index 039659f89..1299f28dc 100644 --- a/src/application/javascript/JavaScriptArtifactGraphStructure.ts +++ b/src/application/javascript/JavaScriptArtifactGraphStructure.ts @@ -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( diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index 7f14c7898..57575552f 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -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, diff --git a/src/application/javascript/JavaScriptModuleRelationships.ts b/src/application/javascript/JavaScriptModuleRelationships.ts index d3b688ca4..e87d165bd 100644 --- a/src/application/javascript/JavaScriptModuleRelationships.ts +++ b/src/application/javascript/JavaScriptModuleRelationships.ts @@ -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"; diff --git a/src/application/javascript/JavaScriptReturnShapeProjection.ts b/src/application/javascript/JavaScriptReturnShapeProjection.ts index dcd806ef8..f22a5feca 100644 --- a/src/application/javascript/JavaScriptReturnShapeProjection.ts +++ b/src/application/javascript/JavaScriptReturnShapeProjection.ts @@ -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; @@ -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; diff --git a/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts b/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts index c44b1cc5a..8d949e964 100644 --- a/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts @@ -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]; }), ); diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts index 38a352234..424ab9fdf 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts @@ -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; readonly referenceNodes: JavaScriptSemanticGraphNode[]; readonly callResolutions: ReadonlyMap; + 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, + ) => 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 = { 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, +): 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, + }); diff --git a/src/application/javascript/JavaScriptSemanticGraphChildProcessProjection.ts b/src/application/javascript/JavaScriptSemanticGraphChildProcessProjection.ts index 450ecff56..600d0390b 100644 --- a/src/application/javascript/JavaScriptSemanticGraphChildProcessProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphChildProcessProjection.ts @@ -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 } = diff --git a/src/application/javascript/JavaScriptSemanticGraphConstruction.ts b/src/application/javascript/JavaScriptSemanticGraphConstruction.ts index d69d9e736..bb7322783 100644 --- a/src/application/javascript/JavaScriptSemanticGraphConstruction.ts +++ b/src/application/javascript/JavaScriptSemanticGraphConstruction.ts @@ -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, +): 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, diff --git a/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts b/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts index 8ebd024b8..761eba0e3 100644 --- a/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts @@ -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, diff --git a/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts b/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts index fba83df6b..b8c685bc5 100644 --- a/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts @@ -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; readonly returnSiteNodes: ReadonlyMap; 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, diff --git a/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts b/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts index e0a5d8890..e0e3b78e8 100644 --- a/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts @@ -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, diff --git a/src/application/javascript/JavaScriptSemanticGraphProjection.test.ts b/src/application/javascript/JavaScriptSemanticGraphProjection.test.ts new file mode 100644 index 000000000..086319bdc --- /dev/null +++ b/src/application/javascript/JavaScriptSemanticGraphProjection.test.ts @@ -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, +) => { + 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(); +}); diff --git a/src/application/javascript/JavaScriptSemanticGraphProjection.ts b/src/application/javascript/JavaScriptSemanticGraphProjection.ts index 4d09591e4..ac0b4e519 100644 --- a/src/application/javascript/JavaScriptSemanticGraphProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphProjection.ts @@ -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 { + readonly entry: Interval; + readonly minimumStart: JavaScriptSourceRange["start"]; + readonly maximumStart: JavaScriptSourceRange["start"]; + readonly minimumEnd: JavaScriptSourceRange["end"]; + readonly maximumEnd: JavaScriptSourceRange["end"]; + readonly left: SourceIntervalTree | null; + readonly right: SourceIntervalTree | null; +} + +/** Index one immutable file IR; read retained callable membership at query time. */ +export const createSemanticCallableOwnerLookup = ( ir: JavaScriptSemanticIr, nodes: ReadonlyMap, -): 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 = ( + entries: readonly Interval[], + start: number, + end: number, +): SourceIntervalTree | 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, +): (( range: JavaScriptSourceRange, -): JavaScriptSemanticGraphNode[] => - nodes.filter( - (node) => - node.identity.source_range !== null && - contains(range, node.identity.source_range), - ); +) => JavaScriptSemanticGraphNode | undefined) => { + const identifiers = new Map(); + 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, ): "complete" | "partial" | "unknown" | "unsupported" => { if ( ![ diff --git a/src/application/javascript/JavaScriptSemanticGraphResourceProjection.ts b/src/application/javascript/JavaScriptSemanticGraphResourceProjection.ts index 22ca8aff6..8689cc673 100644 --- a/src/application/javascript/JavaScriptSemanticGraphResourceProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphResourceProjection.ts @@ -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, diff --git a/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts b/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts index 96298f349..bcda8680e 100644 --- a/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts @@ -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>, - }, - 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>, + }); diff --git a/src/cli/jsonOutput.ts b/src/cli/jsonOutput.ts index 0e7e75503..cc401ee32 100644 --- a/src/cli/jsonOutput.ts +++ b/src/cli/jsonOutput.ts @@ -61,6 +61,10 @@ function* bufferedParts(parts: Iterable): Generator { } function* encodeString(value: string): Generator { + 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); diff --git a/src/domain/evidence.test.ts b/src/domain/evidence.test.ts index 29a629d3d..d5173c194 100644 --- a/src/domain/evidence.test.ts +++ b/src/domain/evidence.test.ts @@ -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, { diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index 6348fc38a..844c9e488 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -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), - }); + }; }; diff --git a/src/domain/javascript/javascriptSemanticGraph.ts b/src/domain/javascript/javascriptSemanticGraph.ts index a7af45a99..86884cba7 100644 --- a/src/domain/javascript/javascriptSemanticGraph.ts +++ b/src/domain/javascript/javascriptSemanticGraph.ts @@ -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, ): 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; +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, - 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, - 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, - 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, - 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, - 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 }; diff --git a/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts b/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts index 7dad88ab9..3f14ac5b4 100644 --- a/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts +++ b/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts @@ -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");