diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 0d00452f1c..df14f67e43 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -586,6 +586,21 @@ not bypass worker heap, execution deadline or parent result admission. Truncated coverage reports both effective node ceilings; omitted relationships remain unknown. +Unknown records have independent defaults of 100000 across the application and +20000 per source. Select `max_semantic_unknowns` and +`max_semantic_unknowns_per_file` (CLI: `--max-semantic-unknowns` and +`--max-semantic-unknowns-per-file`), each from 1 to 1000000. The parent offers the +remaining capacity to the worker, and rejects excess records before adoption. +Unknown admission happens before canonical identity and provenance construction. +When capacity is exhausted, coverage is partial and truncated and names both +effective unknown ceilings. `coverage.omitted_unknown_occurrences` counts examined +emission occurrences by family and reason, including repeated emissions. It does +not claim distinct discarded identities or count frontiers in unexamined source. +Omitted unknown provenance and IDs are not retained. Existing source, node, +execution and memory limits continue to determine examined coverage. Raising only +a node capacity does not raise the unknown capacity. Graphs without unknown +omissions omit this optional coverage field, preserving their existing identities. + While sources remain, the sequential worker reserves one next compact static checkpoint at its 8 MiB bound with the same 6x retention expansion used at admission. Earlier checkpoints are already charged to retained bytes; the diff --git a/src/application/javascript/JavaScriptApplicationEvidence.ts b/src/application/javascript/JavaScriptApplicationEvidence.ts index ca20b813f7..c604949ef3 100644 --- a/src/application/javascript/JavaScriptApplicationEvidence.ts +++ b/src/application/javascript/JavaScriptApplicationEvidence.ts @@ -97,6 +97,12 @@ const parameters = ( ...(input.max_semantic_nodes_per_file === undefined ? {} : { max_semantic_nodes_per_file: input.max_semantic_nodes_per_file }), + ...(input.max_semantic_unknowns === undefined + ? {} + : { max_semantic_unknowns: input.max_semantic_unknowns }), + ...(input.max_semantic_unknowns_per_file === undefined + ? {} + : { max_semantic_unknowns_per_file: input.max_semantic_unknowns_per_file }), ...(input.source_paths === undefined ? {} : { source_paths: input.source_paths }), diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index a4b3e86bdd..c0e3bdd7c3 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -173,7 +173,7 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async ( } const result = await worker.analyze( file, - projection.remainingFileNodeBudget(), + projection.remainingFileCapacity(), signal, ); const facts = result.ok ? result.value : result.error; diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index 5f40f2928b..b25c545a8a 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -145,6 +145,12 @@ export const reconstructJavaScriptArtifact = async ( `Processing ${String(files.files.length - (files.source_selection?.unselected_files ?? 0))} selected application files`, ); const semanticProjection = createJavaScriptSemanticGraphProjection({ + ...(input.max_semantic_unknowns === undefined + ? {} + : { totalUnknowns: input.max_semantic_unknowns }), + ...(input.max_semantic_unknowns_per_file === undefined + ? {} + : { fileUnknowns: input.max_semantic_unknowns_per_file }), ...(input.max_semantic_nodes === undefined ? {} : { totalNodes: input.max_semantic_nodes }), diff --git a/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts b/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts index f54c5c4f31..fa2053d833 100644 --- a/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphAsyncProjection.ts @@ -1,5 +1,4 @@ import { compactStringIdentityKey } from "../../domain/compactStringIdentity.js"; -import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascript/javascriptSemanticGraph.js"; import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; import type { JavaScriptSemanticEventOperation, @@ -125,24 +124,18 @@ const addEventUnknown = ( event: JavaScriptSemanticGraphNode, relation: ReturnType, ): void => { - addSemanticGraphUnknown( - context.state, - createJavaScriptSemanticGraphUnknown( - { - node_id: event.node_id, - family: "event", - relation_kinds: [relation], - reason: "ambiguous-target", - detail: - operation.eventName === null - ? `Dynamic ${operation.method} event name remains unresolved.` - : `Static ${operation.method} emitter identity is partial.`, - candidate_node_ids: [], - evidence: unknownSemanticEvidence(context.file, operation.location), - }, - context.state.evidenceContexts, - ), - ); + addSemanticGraphUnknown(context.state, { + node_id: event.node_id, + family: "event", + relation_kinds: [relation], + reason: "ambiguous-target", + detail: + operation.eventName === null + ? `Dynamic ${operation.method} event name remains unresolved.` + : `Static ${operation.method} emitter identity is partial.`, + candidate_node_ids: [], + evidence: unknownSemanticEvidence(context.file, operation.location), + }); }; /** Project Node timer scheduling and exact local handle cancellation. */ @@ -218,19 +211,13 @@ const projectTimerCancellation = ( resolution: operation.resolution === "complete" ? "resolved" : "candidate", }); if (operation.resolution === "complete") return; - addSemanticGraphUnknown( - context.state, - createJavaScriptSemanticGraphUnknown( - { - node_id: callSite?.node_id ?? null, - family: "timer", - relation_kinds: ["cancels-timer"], - reason: "ambiguous-target", - detail: `Static ${operation.method} handle resolution was ${operation.resolution}.`, - candidate_node_ids: [], - evidence: unknownSemanticEvidence(context.file, operation.location), - }, - context.state.evidenceContexts, - ), - ); + addSemanticGraphUnknown(context.state, { + node_id: callSite?.node_id ?? null, + family: "timer", + relation_kinds: ["cancels-timer"], + reason: "ambiguous-target", + detail: `Static ${operation.method} handle resolution was ${operation.resolution}.`, + candidate_node_ids: [], + evidence: unknownSemanticEvidence(context.file, operation.location), + }); }; diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts index 0657107216..14312fc85e 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts @@ -1050,18 +1050,18 @@ it("projects and adopts more than the default per-file capacity when explicitly const steps = projectJavaScriptSemanticFileSteps( file, analyzeJavaScriptSemantics(text), - 20_002, + { nodes: 20_002, unknowns: 20_002 }, ); let result = steps.next(); while (!result.done) result = steps.next(); expect(result.value.nodes).toHaveLength(20_002); - expect(result.value.truncated).toBe(false); + expect(result.value.nodesTruncated).toBe(false); const projection = createJavaScriptSemanticGraphProjection({ totalNodes: 20_010, fileNodes: 20_002, }); projection.adoptFileProjection(result.value); - expect(projection.remainingFileNodeBudget()).toBe(8); + expect(projection.remainingFileCapacity().nodes).toBe(8); const graph = projection.finish(SHA256, { graph_id: `jag_${"b".repeat(64)}`, nodes: [], diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts index 07061943a9..a02b60c0c6 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts @@ -1,6 +1,5 @@ import { createJavaScriptSemanticGraph, - createJavaScriptSemanticGraphUnknown, sealTransferredJavaScriptSemanticGraphSteps, type JavaScriptSemanticGraph, } from "../../domain/javascript/javascriptSemanticGraph.js"; @@ -51,7 +50,7 @@ import { import type { JsonValue } from "../../domain/jsonValue.js"; import { JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS, - JAVASCRIPT_SEMANTIC_NODE_MAXIMUM, + JAVASCRIPT_SEMANTIC_ITEM_MAXIMUM, javaScriptAnalysisCoverageLimits, } from "../../domain/javascript/javascriptAnalysisResourceControls.js"; import { projectSemanticFunctionFingerprints } from "./JavaScriptSemanticGraphFingerprintProjection.js"; @@ -70,13 +69,18 @@ import { } from "./JavaScriptSemanticGraphProjection.js"; import { addSemanticFallbackRoot as addFallbackRoot, + addSemanticGraphUnknown, + recordSemanticUnknownOmission, retainSemanticGraphNode as retainNode, addSemanticGraphRelation as addRelation, createSemanticGraphProjectionState as emptyState, bindSemanticGraphApplicationNodes, type SemanticGraphProjectionState as BuilderState, } from "./JavaScriptSemanticGraphConstruction.js"; -import type { JavaScriptSemanticFileProjection } from "../../domain/javascript/javascriptSemanticFileProjection.js"; +import type { + JavaScriptSemanticFileProjection, + JavaScriptSemanticProjectionCapacity, +} from "../../domain/javascript/javascriptSemanticFileProjection.js"; import type { ElectronCallPath } from "../../domain/javascript/electronStaticAnalysisTypes.js"; import { projectElectronSemanticCallPaths } from "./JavaScriptSemanticGraphElectronProjection.js"; @@ -166,7 +170,7 @@ export const buildJavaScriptSemanticGraph = ({ /** File-local semantic projection that does not retain consumed source IR. */ export interface JavaScriptSemanticGraphProjection { /** Node capacity offered to the next independently analyzed source file. */ - readonly remainingFileNodeBudget: () => number; + readonly remainingFileCapacity: () => JavaScriptSemanticProjectionCapacity; /** Adopt decoded file-local facts; final validation and proof remain in this realm. */ readonly adoptFileProjection: ( projection: JavaScriptSemanticFileProjection, @@ -201,17 +205,33 @@ export interface JavaScriptSemanticGraphProjection { /** Accumulate semantic nodes while each source IR is still file-local. */ export const createJavaScriptSemanticGraphProjection = ( - limits: { readonly totalNodes?: number; readonly fileNodes?: number } = {}, + limits: { + readonly totalNodes?: number; + readonly fileNodes?: number; + readonly totalUnknowns?: number; + readonly fileUnknowns?: number; + } = {}, ): JavaScriptSemanticGraphProjection => { const nodeCeiling = limits.totalNodes ?? SEMANTIC_GRAPH_NODE_CEILING; const fileNodeCeiling = limits.fileNodes ?? SEMANTIC_GRAPH_FILE_NODE_CEILING; - for (const value of [nodeCeiling, fileNodeCeiling]) + const unknownCeiling = + limits.totalUnknowns ?? + JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.semanticUnknowns; + const fileUnknownCeiling = + limits.fileUnknowns ?? + JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS.semanticUnknownsPerFile; + for (const value of [ + nodeCeiling, + fileNodeCeiling, + unknownCeiling, + fileUnknownCeiling, + ]) if ( !Number.isSafeInteger(value) || value < 1 || - value > JAVASCRIPT_SEMANTIC_NODE_MAXIMUM + value > JAVASCRIPT_SEMANTIC_ITEM_MAXIMUM ) - throw new RangeError("Semantic graph requires valid node capacities"); + throw new RangeError("Semantic graph requires valid retained capacities"); const state = emptyState(); const fingerprints: JavaScriptSemanticFingerprint[] = []; let truncatedFiles = 0; @@ -233,12 +253,17 @@ export const createJavaScriptSemanticGraphProjection = ( const remainingTreeBudget = nodeCeiling - state.nodes.size; state.fileNodeBudget = Math.min(fileNodeCeiling, remainingTreeBudget); state.fileNodesDropped = false; + state.unknownCapacity = Math.min( + unknownCeiling, + state.unknowns.size + fileUnknownCeiling, + ); fingerprints.push(...(yield* projectFileSteps(file, ir, state, paths))); // 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; + state.unknownCapacity = null; }; const finishWith = ( factory: (input: unknown) => Value, @@ -249,6 +274,8 @@ export const createJavaScriptSemanticGraphProjection = ( if (state.roots.size === 0) addFallbackRoot(rootArtifactSha256, state); const relations = [...state.relations.values()]; const unknowns = [...state.unknowns.values()]; + const unknownOmissions = [...state.unknownOmissions.values()]; + const unknownsDropped = unknownOmissions.length > 0; type Family = (typeof JAVASCRIPT_SEMANTIC_RELATION_FAMILIES)[number]; const retainedByFamily = new Map(); const unknownIdsByFamily = new Map(); @@ -275,13 +302,33 @@ export const createJavaScriptSemanticGraphProjection = ( unknowns, coverage: { status: - truncatedFiles > 0 || unavailableFiles > 0 ? "partial" : "unknown", - truncated: truncatedFiles > 0 || resourceTruncatedFiles > 0, + truncatedFiles > 0 || unavailableFiles > 0 || unknownsDropped + ? "partial" + : "unknown", + truncated: + truncatedFiles > 0 || resourceTruncatedFiles > 0 || unknownsDropped, + ...(unknownsDropped + ? { omitted_unknown_occurrences: unknownOmissions } + : {}), omitted_nodes: truncatedFiles > 0 || unavailableFiles > 0 ? null : 0, omitted_relations: truncatedFiles > 0 || unavailableFiles > 0 ? null : 0, limits: [ ...executionLimits.values(), + ...(unknownsDropped + ? [ + { + name: "semantic_graph_unknown_ceiling", + value: unknownCeiling, + unit: "items" as const, + }, + { + name: "semantic_graph_file_unknown_ceiling", + value: fileUnknownCeiling, + unit: "items" as const, + }, + ] + : []), ...(truncatedFiles > 0 ? [ { @@ -307,6 +354,11 @@ export const createJavaScriptSemanticGraphProjection = ( })), }, limitations: [ + ...(unknownsDropped + ? [ + "Semantic unknown retention reached an effective capacity. Omission coverage counts examined emission occurrences by family and reason, including repeats; it does not count unexamined source frontiers or distinct discarded identities.", + ] + : []), "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.", @@ -323,14 +375,23 @@ export const createJavaScriptSemanticGraphProjection = ( state.nodes.clear(); state.relations.clear(); state.unknowns.clear(); + state.unknownOmissions.clear(); state.roots.clear(); state.evidenceContexts.clear(); fingerprints.length = 0; return graph; }; return { - remainingFileNodeBudget: () => - Math.min(fileNodeCeiling, Math.max(0, nodeCeiling - state.nodes.size)), + remainingFileCapacity: () => ({ + nodes: Math.min( + fileNodeCeiling, + Math.max(0, nodeCeiling - state.nodes.size), + ), + unknowns: Math.min( + fileUnknownCeiling, + Math.max(0, unknownCeiling - state.unknowns.size), + ), + }), adoptFileProjection: (projection) => { const newNodes = projection.nodes.filter( ({ node_id }) => !state.nodes.has(node_id), @@ -342,16 +403,28 @@ export const createJavaScriptSemanticGraphProjection = ( throw new RangeError( "Transferred semantic nodes exceed the offered tree capacity", ); + const newUnknowns = projection.unknowns.filter( + ({ unknown_id }) => !state.unknowns.has(unknown_id), + ); + if ( + newUnknowns.length > + Math.min(fileUnknownCeiling, unknownCeiling - state.unknowns.size) + ) + throw new RangeError( + "Transferred semantic unknowns exceed the offered tree capacity", + ); + for (const omission of projection.omittedUnknownOccurrences ?? []) + recordSemanticUnknownOmission(state, omission); for (const context of projection.evidenceContexts) state.evidenceContexts.adopt(context); for (const node of newNodes) state.nodes.set(node.node_id, node); for (const relation of projection.relations) state.relations.set(relation.relation_id, relation); - for (const unknown of projection.unknowns) + for (const unknown of newUnknowns) state.unknowns.set(unknown.unknown_id, unknown); for (const root of projection.roots) state.roots.add(root); fingerprints.push(...projection.fingerprints); - if (projection.truncated) truncatedFiles += 1; + if (projection.nodesTruncated) truncatedFiles += 1; }, recordUnavailableFile: (file, reason, limits, unknownReason) => { // Both full and incremental construction enter here. Metadata/native @@ -385,24 +458,25 @@ export const createJavaScriptSemanticGraphProjection = ( coverage: partialApplicationCoverage(retainedLimits, null), limitations: [reason], }; + state.unknownCapacity = Math.min( + unknownCeiling, + state.unknowns.size + fileUnknownCeiling, + ); for (const family of JAVASCRIPT_SEMANTIC_RELATION_FAMILIES) { - const unknown = createJavaScriptSemanticGraphUnknown( - { - node_id: module?.node_id ?? null, - family, - relation_kinds: JAVASCRIPT_SEMANTIC_RELATIONS.filter( - (relation) => - JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation] === family, - ), - reason: unknownReason, - detail: reason, - candidate_node_ids: [], - evidence, - }, - state.evidenceContexts, - ); - state.unknowns.set(unknown.unknown_id, unknown); + addSemanticGraphUnknown(state, { + node_id: module?.node_id ?? null, + family, + relation_kinds: JAVASCRIPT_SEMANTIC_RELATIONS.filter( + (relation) => + JAVASCRIPT_SEMANTIC_RELATION_FAMILY[relation] === family, + ), + reason: unknownReason, + detail: reason, + candidate_node_ids: [], + evidence, + }); } + state.unknownCapacity = null; unavailableFiles += 1; if (unknownReason === "resource-limit" && retainedLimits.length > 0) resourceTruncatedFiles += 1; @@ -427,23 +501,27 @@ export const createJavaScriptSemanticGraphProjection = ( }; }; -/** Project one source with the parent's remaining node capacity, retaining no full IR. */ +/** Project one source with the parent's remaining retained capacities, retaining no full IR. */ export function* projectJavaScriptSemanticFileSteps( file: JavaScriptArtifactFile, ir: JavaScriptSemanticIr, - nodeBudget: number, + capacity: JavaScriptSemanticProjectionCapacity, paths: readonly ElectronCallPath[] = [], ): Generator { if ( - !Number.isSafeInteger(nodeBudget) || - nodeBudget < 0 || - nodeBudget > JAVASCRIPT_SEMANTIC_NODE_MAXIMUM + [capacity.nodes, capacity.unknowns].some( + (value) => + !Number.isSafeInteger(value) || + value < 0 || + value > JAVASCRIPT_SEMANTIC_ITEM_MAXIMUM, + ) ) throw new RangeError( - "File semantic projection requires a valid offered node capacity", + "File semantic projection requires valid offered retained capacities", ); const state = emptyState(); - state.fileNodeBudget = nodeBudget; + state.fileNodeBudget = capacity.nodes; + state.unknownCapacity = capacity.unknowns; state.fileNodesDropped = false; const fingerprints = yield* projectFileSteps(file, ir, state, paths); return { @@ -453,7 +531,10 @@ export function* projectJavaScriptSemanticFileSteps( relations: [...state.relations.values()], unknowns: [...state.unknowns.values()], fingerprints, - truncated: Boolean(state.fileNodesDropped), + ...(state.unknownOmissions.size > 0 + ? { omittedUnknownOccurrences: [...state.unknownOmissions.values()] } + : {}), + nodesTruncated: Boolean(state.fileNodesDropped), }; } @@ -569,26 +650,22 @@ const projectFileResourceLimits = ( // already project from binding or return-site values and must not be duplicated. for (const limit of semanticCoverageResourceLimits(context.ir.coverage)) { if (limit !== "getter-expansion") continue; - const unknown = createJavaScriptSemanticGraphUnknown( - { - node_id: context.moduleNode.node_id, - family: "data-flow", - relation_kinds: ["defines"], - reason: "resource-limit", - detail: semanticResourceLimitReason(limit), - candidate_node_ids: [], - evidence: { - ...unknownSemanticEvidence(context.file, null), - coverage: partialApplicationCoverage( - semanticResourceLimitCoverage([limit]), - null, - ), - limitations: [semanticResourceLimitReason(limit)], - }, + addSemanticGraphUnknown(context.state, { + node_id: context.moduleNode.node_id, + family: "data-flow", + relation_kinds: ["defines"], + reason: "resource-limit", + detail: semanticResourceLimitReason(limit), + candidate_node_ids: [], + evidence: { + ...unknownSemanticEvidence(context.file, null), + coverage: partialApplicationCoverage( + semanticResourceLimitCoverage([limit]), + null, + ), + limitations: [semanticResourceLimitReason(limit)], }, - context.state.evidenceContexts, - ); - context.state.unknowns.set(unknown.unknown_id, unknown); + }); } }; diff --git a/src/application/javascript/JavaScriptSemanticGraphChildProcessProjection.ts b/src/application/javascript/JavaScriptSemanticGraphChildProcessProjection.ts index f314080cc9..df3f932ff4 100644 --- a/src/application/javascript/JavaScriptSemanticGraphChildProcessProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphChildProcessProjection.ts @@ -1,4 +1,3 @@ -import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascript/javascriptSemanticGraph.js"; import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; import type { JavaScriptSemanticChildProcessInteraction, @@ -233,19 +232,13 @@ const addSignalNode = ( const addChildUnknown = (input: ChildUnknownInput): void => { const { context, node, location, relationKinds, detail, candidateNodeIds } = input; - addSemanticGraphUnknown( - context.state, - createJavaScriptSemanticGraphUnknown( - { - node_id: node?.node_id ?? null, - family: "child-process", - relation_kinds: relationKinds, - reason: input.reason ?? "ambiguous-target", - detail, - candidate_node_ids: candidateNodeIds, - evidence: unknownSemanticEvidence(context.file, location), - }, - context.state.evidenceContexts, - ), - ); + addSemanticGraphUnknown(context.state, { + node_id: node?.node_id ?? null, + family: "child-process", + relation_kinds: relationKinds, + reason: input.reason ?? "ambiguous-target", + detail, + candidate_node_ids: candidateNodeIds, + evidence: unknownSemanticEvidence(context.file, location), + }); }; diff --git a/src/application/javascript/JavaScriptSemanticGraphConstruction.ts b/src/application/javascript/JavaScriptSemanticGraphConstruction.ts index c61047d5bf..dc3a9e55e5 100644 --- a/src/application/javascript/JavaScriptSemanticGraphConstruction.ts +++ b/src/application/javascript/JavaScriptSemanticGraphConstruction.ts @@ -1,8 +1,10 @@ +import type { z } from "zod"; import type { JavaScriptApplicationGraph } from "../../domain/javascript/javascriptApplicationGraph.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import { javascriptDisplayText } from "../../domain/javascript/javascriptAstValues.js"; import { createJavaScriptSemanticGraphNode, + createJavaScriptSemanticGraphUnknown, createJavaScriptSemanticGraphRelation, JavaScriptSemanticEvidenceContextRegistry, javaScriptSemanticNodeId, @@ -12,6 +14,8 @@ import type { ApplicationGraphEvidence } from "../../domain/javascript/javascrip import type { JavaScriptSemanticGraphRelation, JavaScriptSemanticGraphUnknown, + JavaScriptSemanticUnknownOmission, + javaScriptSemanticUnknownInputSchema, } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; @@ -26,6 +30,9 @@ export interface SemanticGraphProjectionState { readonly nodes: Map; readonly relations: Map; readonly unknowns: Map; + /** Absolute retained unknown count allowed during this file; null outside admission. */ + unknownCapacity: number | null; + readonly unknownOmissions: Map; readonly evidenceContexts: JavaScriptSemanticEvidenceContextRegistry; readonly roots: Set; /** @@ -74,6 +81,8 @@ export const createSemanticGraphProjectionState = nodes: new Map(), relations: new Map(), unknowns: new Map(), + unknownCapacity: null, + unknownOmissions: new Map(), evidenceContexts: new JavaScriptSemanticEvidenceContextRegistry(), roots: new Set(), }); @@ -204,11 +213,45 @@ export const addSemanticGraphRelation = ( state.relations.set(value.relation_id, value); }; -/** Retain one unresolved frontier. */ +/** Aggregate examined omissions without retaining unbounded discarded identities. */ +export const recordSemanticUnknownOmission = ( + state: SemanticGraphProjectionState, + omission: JavaScriptSemanticUnknownOmission, +): void => { + const key = `${omission.family}:${omission.reason}`; + const occurrences = + (state.unknownOmissions.get(key)?.occurrences ?? 0) + omission.occurrences; + if (!Number.isSafeInteger(occurrences)) + throw new RangeError("Semantic unknown omission count overflow"); + state.unknownOmissions.set(key, { ...omission, occurrences }); +}; + +/** Admit unknown provenance only while its independent retained capacity allows it. */ export const addSemanticGraphUnknown = ( state: SemanticGraphProjectionState, - unknown: JavaScriptSemanticGraphUnknown, + input: Omit< + z.input, + "relation_kinds" | "candidate_node_ids" + > & { + readonly relation_kinds: readonly JavaScriptSemanticGraphRelation["relation"][]; + readonly candidate_node_ids: readonly string[]; + }, ): void => { + if ( + state.unknownCapacity !== null && + state.unknowns.size >= state.unknownCapacity + ) { + recordSemanticUnknownOmission(state, { + family: input.family, + reason: input.reason, + occurrences: 1, + }); + return; + } + const unknown = createJavaScriptSemanticGraphUnknown( + input, + state.evidenceContexts, + ); if (state.unknowns.has(unknown.unknown_id)) return; state.unknowns.set(unknown.unknown_id, unknown); }; diff --git a/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts b/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts index 01e739e859..b1862168f9 100644 --- a/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphDataProjection.ts @@ -1,4 +1,3 @@ -import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascript/javascriptSemanticGraph.js"; import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; import type { JavaScriptSemanticBoundaryOperation, @@ -325,19 +324,13 @@ const addDataUnknown = (input: DataUnknownInput): void => { detail, candidateNodeIds, } = input; - addSemanticGraphUnknown( - context.state, - createJavaScriptSemanticGraphUnknown( - { - node_id: node.node_id, - family, - relation_kinds: relationKinds, - reason: input.reason ?? "ambiguous-target", - detail, - candidate_node_ids: candidateNodeIds, - evidence: unknownSemanticEvidence(context.file, location), - }, - context.state.evidenceContexts, - ), - ); + addSemanticGraphUnknown(context.state, { + node_id: node.node_id, + family, + relation_kinds: relationKinds, + reason: input.reason ?? "ambiguous-target", + detail, + candidate_node_ids: candidateNodeIds, + evidence: unknownSemanticEvidence(context.file, location), + }); }; diff --git a/src/application/javascript/JavaScriptSemanticGraphElectronProjection.test.ts b/src/application/javascript/JavaScriptSemanticGraphElectronProjection.test.ts index 07a8656daf..94ac750692 100644 --- a/src/application/javascript/JavaScriptSemanticGraphElectronProjection.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphElectronProjection.test.ts @@ -24,7 +24,7 @@ const project = (budget: number) => { const steps = projectJavaScriptSemanticFileSteps( file, ir, - budget, + { nodes: budget, unknowns: budget }, facts.electron.call_paths, ); let result = steps.next(); @@ -35,7 +35,7 @@ const project = (budget: number) => { it("retains Electron calls before library definitions consume the node budget", () => { const result = project(20); expect(result.nodes).toHaveLength(20); - expect(result.truncated).toBe(true); + expect(result.nodesTruncated).toBe(true); const terminal = result.nodes.find( (node) => node.label === "ipc-event.sender.copy", ); @@ -59,7 +59,7 @@ it("retains Electron calls before library definitions consume the node budget", it("reports truncation and keeps referential integrity even within a boundary path", () => { const result = project(3); expect(result.nodes).toHaveLength(3); - expect(result.truncated).toBe(true); + expect(result.nodesTruncated).toBe(true); expect( result.nodes.some((node) => node.label === "ipc-event.sender.copy"), ).toBe(false); diff --git a/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts b/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts index e98526160b..052a132618 100644 --- a/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphFlowProjection.ts @@ -1,4 +1,3 @@ -import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascript/javascriptSemanticGraph.js"; import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; import type { JavaScriptSemanticIr } from "../../domain/javascript/javascriptSemanticIr.js"; import { sourceRangesEqual } from "../../domain/javascript/javascriptStaticAnalysisHelpers.js"; @@ -177,24 +176,18 @@ const addPromiseUnknown = ( promiseNodes: ReadonlyMap, ): void => { const relation = operation.kind === "aggregate" ? "aggregates" : "chains"; - addSemanticGraphUnknown( - context.state, - createJavaScriptSemanticGraphUnknown( - { - node_id: promise.node_id, - family: "promise-ownership", - relation_kinds: [relation], - reason: "ambiguous-target", - detail: `Static ${operation.method} source resolution was ${operation.sourceResolution}.`, - candidate_node_ids: operation.sourcePromiseIds.flatMap((identifier) => { - const candidate = promiseNodes.get(identifier); - return candidate === undefined ? [] : [candidate.node_id]; - }), - evidence: unknownSemanticEvidence(context.file, operation.location), - }, - context.state.evidenceContexts, - ), - ); + addSemanticGraphUnknown(context.state, { + node_id: promise.node_id, + family: "promise-ownership", + relation_kinds: [relation], + reason: "ambiguous-target", + detail: `Static ${operation.method} source resolution was ${operation.sourceResolution}.`, + candidate_node_ids: operation.sourcePromiseIds.flatMap((identifier) => { + const candidate = promiseNodes.get(identifier); + return candidate === undefined ? [] : [candidate.node_id]; + }), + evidence: unknownSemanticEvidence(context.file, operation.location), + }); }; /** Link direct return expressions to their retained semantic references. */ @@ -258,22 +251,18 @@ export function* projectSemanticFrontiers( : frontier.kind === "dynamic-scope" ? (["reads", "writes"] as const) : (["reads-property", "writes-property"] as const); - const unknown = createJavaScriptSemanticGraphUnknown( - { - node_id: - frontier.callableId === null - ? context.moduleNode.node_id - : (context.callableNodes.get(frontier.callableId)?.node_id ?? - context.moduleNode.node_id), - family, - relation_kinds: [...relationKinds], - reason: frontier.kind, - detail: frontier.reason, - candidate_node_ids: [], - evidence: unknownSemanticEvidence(context.file, frontier.location), - }, - context.state.evidenceContexts, - ); - addSemanticGraphUnknown(context.state, unknown); + addSemanticGraphUnknown(context.state, { + node_id: + frontier.callableId === null + ? context.moduleNode.node_id + : (context.callableNodes.get(frontier.callableId)?.node_id ?? + context.moduleNode.node_id), + family, + relation_kinds: [...relationKinds], + reason: frontier.kind, + detail: frontier.reason, + candidate_node_ids: [], + evidence: unknownSemanticEvidence(context.file, frontier.location), + }); } } diff --git a/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts b/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts index 337899f837..722937ed1e 100644 --- a/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphObjectProjection.ts @@ -7,7 +7,6 @@ import type { JavaScriptSemanticSlotPresence, JavaScriptSemanticValue, } from "../../domain/javascript/javascriptSemanticValueTypes.js"; -import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascript/javascriptSemanticGraph.js"; import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; import type { JavaScriptSemanticObjectOperation } from "../../domain/javascript/javascriptSemanticIr.js"; import { @@ -167,19 +166,13 @@ const addObjectUnknown = ( target: Pick, ): void => { const relation = OBJECT_RELATIONS[operation.kind]; - addSemanticGraphUnknown( - context.state, - createJavaScriptSemanticGraphUnknown( - { - node_id: node?.node_id ?? null, - family: "object-flow", - relation_kinds: [relation], - reason: "ambiguous-target", - detail: `Static ${operation.kind} property ${operation.propertyPath === null ? "path is unresolved" : semanticPropertyPointer(operation.propertyPath)} has ${target.presence} presence; its receiver is ${target.receiverResolved ? "a retained container" : "unresolved or not a retained container"}; object identity is ${operation.resolution}.`, - candidate_node_ids: [], - evidence: unknownSemanticEvidence(context.file, operation.location), - }, - context.state.evidenceContexts, - ), - ); + addSemanticGraphUnknown(context.state, { + node_id: node?.node_id ?? null, + family: "object-flow", + relation_kinds: [relation], + reason: "ambiguous-target", + detail: `Static ${operation.kind} property ${operation.propertyPath === null ? "path is unresolved" : semanticPropertyPointer(operation.propertyPath)} has ${target.presence} presence; its receiver is ${target.receiverResolved ? "a retained container" : "unresolved or not a retained container"}; object identity is ${operation.resolution}.`, + candidate_node_ids: [], + evidence: unknownSemanticEvidence(context.file, operation.location), + }); }; diff --git a/src/application/javascript/JavaScriptSemanticGraphResourceProjection.ts b/src/application/javascript/JavaScriptSemanticGraphResourceProjection.ts index 7417eb4baa..24784f439c 100644 --- a/src/application/javascript/JavaScriptSemanticGraphResourceProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphResourceProjection.ts @@ -1,4 +1,3 @@ -import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascript/javascriptSemanticGraph.js"; import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; import type { JavaScriptSemanticResourceOperation } from "../../domain/javascript/javascriptSemanticIr.js"; import { @@ -67,21 +66,13 @@ const projectRelease = ( operation.resolution === "complete" ? "resolved" : "candidate", }); if (operation.resolution === "complete") return; - addSemanticGraphUnknown( - context.state, - createJavaScriptSemanticGraphUnknown( - { - node_id: callSite?.node_id ?? null, - family: "resource-lifecycle", - relation_kinds: ["releases"], - reason: "ambiguous-target", - detail: `Static ${operation.method} resource ownership is ambiguous.`, - candidate_node_ids: resources.map( - ({ node_id: identifier }) => identifier, - ), - evidence: unknownSemanticEvidence(context.file, operation.location), - }, - context.state.evidenceContexts, - ), - ); + addSemanticGraphUnknown(context.state, { + node_id: callSite?.node_id ?? null, + family: "resource-lifecycle", + relation_kinds: ["releases"], + reason: "ambiguous-target", + detail: `Static ${operation.method} resource ownership is ambiguous.`, + candidate_node_ids: resources.map(({ node_id: identifier }) => identifier), + evidence: unknownSemanticEvidence(context.file, operation.location), + }); }; diff --git a/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts b/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts index febe23d247..eac56882a7 100644 --- a/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts @@ -8,7 +8,6 @@ import type { JavaScriptSemanticProperty, JavaScriptSemanticValue, } from "../../domain/javascript/javascriptSemanticValueTypes.js"; -import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascript/javascriptSemanticGraph.js"; import { retainSemanticGraphNode, addSemanticGraphRelation, @@ -95,26 +94,20 @@ const projectValue = (input: ValueProjectionInput): void => { }); } if (container.coverage.status === "partial") - addSemanticGraphUnknown( - context.state, - createJavaScriptSemanticGraphUnknown( - { - node_id: target.node_id, - family: "object-flow", - relation_kinds: ["writes-property"], - reason: "ambiguous-target", - detail: `Initializer container has unknown ${ - value.status === "object" ? "properties" : "items" - }${container.coverage.omitted === null ? " with an unknown omitted count" : `; ${container.coverage.omitted} omitted`}.`, - candidate_node_ids: [target.node_id], - evidence: unknownSemanticEvidence( - context.file, - binding.definitions[0]?.location ?? null, - ), - }, - context.state.evidenceContexts, + addSemanticGraphUnknown(context.state, { + node_id: target.node_id, + family: "object-flow", + relation_kinds: ["writes-property"], + reason: "ambiguous-target", + detail: `Initializer container has unknown ${ + value.status === "object" ? "properties" : "items" + }${container.coverage.omitted === null ? " with an unknown omitted count" : `; ${container.coverage.omitted} omitted`}.`, + candidate_node_ids: [target.node_id], + evidence: unknownSemanticEvidence( + context.file, + binding.definitions[0]?.location ?? null, ), - ); + }); } else if ( value.status === "unknown" || value.status === "ambiguous" || @@ -122,30 +115,22 @@ const projectValue = (input: ValueProjectionInput): void => { ) { const location = binding.definitions[0]?.location ?? null; const evidence = observedSemanticEvidence(context.file, location); - addSemanticGraphUnknown( - context.state, - createJavaScriptSemanticGraphUnknown( - { - node_id: target.node_id, - family: "data-flow", - relation_kinds: ["defines"], - reason: - value.resourceLimit === undefined - ? "unknown-value" - : "resource-limit", - detail: `${value.reason} Unknown value at ${role}.`, - candidate_node_ids: [target.node_id], - evidence: { - ...evidence, - authority: "unknown", - state: "unknown", - confidence: "unknown", - limitations: [value.reason], - }, - }, - context.state.evidenceContexts, - ), - ); + addSemanticGraphUnknown(context.state, { + node_id: target.node_id, + family: "data-flow", + relation_kinds: ["defines"], + reason: + value.resourceLimit === undefined ? "unknown-value" : "resource-limit", + detail: `${value.reason} Unknown value at ${role}.`, + candidate_node_ids: [target.node_id], + evidence: { + ...evidence, + authority: "unknown", + state: "unknown", + confidence: "unknown", + limitations: [value.reason], + }, + }); } }; diff --git a/src/application/javascript/JavaScriptSemanticUnknownCapacity.test.ts b/src/application/javascript/JavaScriptSemanticUnknownCapacity.test.ts new file mode 100644 index 0000000000..c8d3999c0a --- /dev/null +++ b/src/application/javascript/JavaScriptSemanticUnknownCapacity.test.ts @@ -0,0 +1,243 @@ +import { createHash } from "node:crypto"; +import { expect, it } from "vitest"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; +import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js"; +import { + javaScriptSemanticGraphSchema, + createJavaScriptSemanticGraph, +} from "../../domain/javascript/javascriptSemanticGraph.js"; +import { queryJavaScriptSemanticGraph } from "../../domain/javascript/javascriptSemanticQuery.js"; +import { + createJavaScriptSemanticGraphProjection, + projectJavaScriptSemanticFileSteps, +} from "./JavaScriptSemanticGraphBuilder.js"; +import { + createSemanticGraphProjectionState, + addSemanticGraphUnknown, +} from "./JavaScriptSemanticGraphConstruction.js"; +import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js"; + +const digest = "a".repeat(64); +const application = { graph_id: `jag_${"b".repeat(64)}`, nodes: [] }; +const text = "globalThis.u.p(globalThis.v[k]);\n".repeat(4); +const source = (path = "app.js", value = text): JavaScriptArtifactFile => ({ + path, + sha256: createHash("sha256").update(value).digest("hex"), + container_sha256: digest, + bytes: Buffer.byteLength(value), + inventory_artifact_id: `art_${digest}`, + kind: "javascript", + unpacked: false, + text: { included: true, value }, +}); +const fileProjection = (file = source(), nodes = 200, unknowns = 200) => { + if (!file.text.included) throw new Error("Expected inert source"); + const steps = projectJavaScriptSemanticFileSteps( + file, + analyzeJavaScriptSemantics(file.text.value), + { nodes, unknowns }, + ); + let step = steps.next(); + while (!step.done) step = steps.next(); + return step.value; +}; + +it("bounds unknowns independently of retained nodes and accounts for examined omissions", () => { + const complete = fileProjection(); + const bounded = fileProjection(source(), 200, 2); + expect(complete.unknowns.length).toBeGreaterThan(2); + expect(bounded.nodes).toEqual(complete.nodes); + expect(bounded.relations).toEqual(complete.relations); + expect(bounded.unknowns).toHaveLength(2); + expect( + bounded.omittedUnknownOccurrences!.reduce( + (sum, row) => sum + row.occurrences, + 0, + ), + ).toBe(complete.unknowns.length - 2); + const projection = createJavaScriptSemanticGraphProjection({ + totalUnknowns: 2, + fileUnknowns: 2, + }); + projection.adoptFileProjection(bounded); + const graph = projection.finish(digest, application); + expect(graph.coverage).toMatchObject({ + truncated: true, + status: "partial", + omitted_nodes: 0, + }); + expect(graph.coverage.limits).toEqual( + expect.arrayContaining([ + { name: "semantic_graph_unknown_ceiling", value: 2, unit: "items" }, + { name: "semantic_graph_file_unknown_ceiling", value: 2, unit: "items" }, + ]), + ); + expect(javaScriptSemanticGraphSchema.parse(graph)).toEqual(graph); + expect( + queryJavaScriptSemanticGraph(graph, { + seed: { kind: "property", name: "missing" }, + direction: "backward-provenance", + }), + ).toMatchObject({ status: "partial", coverage: { status: "partial" } }); +}); + +it("keeps unknowns bounded even after their semantic nodes are dropped", () => { + const projection = fileProjection(source(), 1, 2); + expect(projection.nodesTruncated).toBe(true); + expect(projection.nodes).toHaveLength(1); + expect(projection.unknowns).toHaveLength(2); + expect(projection.omittedUnknownOccurrences!.length).toBeGreaterThan(0); +}); + +it("enforces per-file and total unknown capacities across transferred files", () => { + const projection = createJavaScriptSemanticGraphProjection({ + totalUnknowns: 3, + fileUnknowns: 2, + }); + for (const path of ["a.js", "b.js", "c.js"]) { + const capacity = projection.remainingFileCapacity(); + const file = source(path); + const steps = projectJavaScriptSemanticFileSteps( + file, + analyzeJavaScriptSemantics(text), + capacity, + ); + let step = steps.next(); + while (!step.done) step = steps.next(); + projection.adoptFileProjection(step.value); + } + expect(projection.remainingFileCapacity().unknowns).toBe(0); + const graph = projection.finish(digest, application); + expect(graph.unknowns).toHaveLength(3); + expect( + graph.unknowns.filter( + (row) => + row.evidence.location.available && + row.evidence.location.value.kind === "source-range" && + row.evidence.location.value.source === "a.js", + ), + ).toHaveLength(2); + expect( + graph.coverage.omitted_unknown_occurrences!.reduce( + (sum, row) => sum + row.occurrences, + 0, + ), + ).toBe(fileProjection().unknowns.length * 3 - 3); +}); + +it("rejects excess transferred unknowns before adopting any file facts", () => { + const projection = createJavaScriptSemanticGraphProjection({ + totalUnknowns: 1, + fileUnknowns: 1, + }); + const before = projection.remainingFileCapacity(); + expect(() => projection.adoptFileProjection(fileProjection())).toThrow( + "Transferred semantic unknowns exceed", + ); + expect(projection.remainingFileCapacity()).toEqual(before); +}); + +it("bounds unavailable-file frontiers through the same owner", () => { + const projection = createJavaScriptSemanticGraphProjection({ + totalUnknowns: 2, + fileUnknowns: 2, + }); + projection.recordUnavailableFile( + source(), + "Source unavailable", + null, + "incomplete-module", + ); + const graph = projection.finish(digest, application); + expect(graph.unknowns).toHaveLength(2); + expect( + graph.coverage.omitted_unknown_occurrences!.reduce( + (sum, row) => sum + row.occurrences, + 0, + ), + ).toBe(10); +}); + +it("does not intern omitted provenance and counts repeated omitted emissions as occurrences", () => { + const state = createSemanticGraphProjectionState(); + state.unknownCapacity = 1; + const file = source(); + for (const detail of ["retained", "omitted", "omitted"]) + addSemanticGraphUnknown(state, { + node_id: null, + family: "data-flow", + relation_kinds: ["defines"], + reason: "unknown-value", + detail, + candidate_node_ids: [], + evidence: { + ...unknownSemanticEvidence(file, null), + limitations: [detail], + }, + }); + expect(state.unknowns.size).toBe(1); + expect(state.evidenceContexts.contexts).toHaveLength(1); + expect([...state.unknownOmissions.values()]).toEqual([ + { family: "data-flow", reason: "unknown-value", occurrences: 2 }, + ]); +}); + +it("preserves omission-free graph identity and refuses contradictory omission coverage", () => { + const projection = createJavaScriptSemanticGraphProjection(); + projection.adoptFileProjection(fileProjection()); + const graph = projection.finish(digest, application); + expect(Object.hasOwn(graph.coverage, "omitted_unknown_occurrences")).toBe( + false, + ); + const { graph_id: _id, ...input } = graph; + expect(createJavaScriptSemanticGraph(input).graph_id).toBe(graph.graph_id); + const omission = { + family: "data-flow", + reason: "unknown-value", + occurrences: 1, + }; + expect(() => + createJavaScriptSemanticGraph({ + ...input, + coverage: { ...graph.coverage, omitted_unknown_occurrences: [omission] }, + }), + ).toThrow("Omitted unknown occurrences require"); + expect(() => + createJavaScriptSemanticGraph({ + ...input, + coverage: { + ...graph.coverage, + status: "partial", + truncated: true, + limits: [{ name: "authored", value: 1, unit: "items" }], + omitted_unknown_occurrences: [omission, omission], + }, + }), + ).toThrow("Unknown omission coverage"); +}); + +it("applies the same unknown admission in direct and transferred construction", () => { + const direct = createJavaScriptSemanticGraphProjection({ + totalUnknowns: 3, + fileUnknowns: 2, + }); + const transferred = createJavaScriptSemanticGraphProjection({ + totalUnknowns: 3, + fileUnknowns: 2, + }); + for (const name of ["a.js", "b.js"]) { + const file = source(name); + direct.projectFile(file, analyzeJavaScriptSemantics(text)); + const steps = projectJavaScriptSemanticFileSteps( + file, + analyzeJavaScriptSemantics(text), + transferred.remainingFileCapacity(), + ); + let step = steps.next(); + while (!step.done) step = steps.next(); + transferred.adoptFileProjection(step.value); + } + expect(direct.finish(digest, application)).toEqual( + transferred.finish(digest, application), + ); +}); diff --git a/src/cli/electronCommands.ts b/src/cli/electronCommands.ts index e6fe651182..02f399d21a 100644 --- a/src/cli/electronCommands.ts +++ b/src/cli/electronCommands.ts @@ -295,6 +295,15 @@ const registerJavaScriptApplicationCommand = ( max_semantic_nodes_per_file: options.maxSemanticNodesPerFile, }), + ...(options.maxSemanticUnknowns === undefined + ? {} + : { max_semantic_unknowns: options.maxSemanticUnknowns }), + ...(options.maxSemanticUnknownsPerFile === undefined + ? {} + : { + max_semantic_unknowns_per_file: + options.maxSemanticUnknownsPerFile, + }), ...(options.sourcePath === undefined ? {} : { source_paths: options.sourcePath }), diff --git a/src/cliObservationOptions.ts b/src/cliObservationOptions.ts index e7db14e0e4..fc83e896dd 100644 --- a/src/cliObservationOptions.ts +++ b/src/cliObservationOptions.ts @@ -65,6 +65,11 @@ export const javascriptApplicationOptions = z.object({ javaScriptAnalysisResourceControlsSchema.shape.max_semantic_nodes, maxSemanticNodesPerFile: javaScriptAnalysisResourceControlsSchema.shape.max_semantic_nodes_per_file, + maxSemanticUnknowns: + javaScriptAnalysisResourceControlsSchema.shape.max_semantic_unknowns, + maxSemanticUnknownsPerFile: + javaScriptAnalysisResourceControlsSchema.shape + .max_semantic_unknowns_per_file, sourcePath: javaScriptSourcePathsSchema .optional() .describe( diff --git a/src/composition/javascriptAnalysisWorkerProcess.ts b/src/composition/javascriptAnalysisWorkerProcess.ts index 48c4792df1..2ae016b52e 100644 --- a/src/composition/javascriptAnalysisWorkerProcess.ts +++ b/src/composition/javascriptAnalysisWorkerProcess.ts @@ -18,7 +18,7 @@ import { const analyze: JavaScriptSourceWorkerAnalyzer = async ( file, - nodeBudget, + capacity, callbacks, ) => { if (!file.text.included) @@ -70,7 +70,7 @@ const analyze: JavaScriptSourceWorkerAnalyzer = async ( projectJavaScriptSemanticFileSteps( file, ir, - nodeBudget, + capacity, facts.electron.call_paths, ), ); diff --git a/src/domain/javascript/javascriptAnalysisResourceControls.test.ts b/src/domain/javascript/javascriptAnalysisResourceControls.test.ts index e5f98bda3d..8d04b603d7 100644 --- a/src/domain/javascript/javascriptAnalysisResourceControls.test.ts +++ b/src/domain/javascript/javascriptAnalysisResourceControls.test.ts @@ -7,13 +7,22 @@ it("keeps omitted operational choices absent and validates explicit semantic bud javaScriptAnalysisResourceControlsSchema.parse({ max_semantic_nodes: 750_000, max_semantic_nodes_per_file: 700_000, + max_semantic_unknowns: 500_000, + max_semantic_unknowns_per_file: 400_000, }), ).toEqual({ max_semantic_nodes: 750_000, max_semantic_nodes_per_file: 700_000, + max_semantic_unknowns: 500_000, + max_semantic_unknowns_per_file: 400_000, }); for (const value of [0, -1, 1.5, 1_000_001, Infinity, NaN]) { - for (const key of ["max_semantic_nodes", "max_semantic_nodes_per_file"]) + for (const key of [ + "max_semantic_nodes", + "max_semantic_nodes_per_file", + "max_semantic_unknowns", + "max_semantic_unknowns_per_file", + ]) expect( javaScriptAnalysisResourceControlsSchema.safeParse({ [key]: value }) .success, diff --git a/src/domain/javascript/javascriptAnalysisResourceControls.ts b/src/domain/javascript/javascriptAnalysisResourceControls.ts index c627ad880b..deec5b8219 100644 --- a/src/domain/javascript/javascriptAnalysisResourceControls.ts +++ b/src/domain/javascript/javascriptAnalysisResourceControls.ts @@ -8,10 +8,12 @@ export const JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS = { timeoutMs: 300_000, semanticNodes: 100_000, semanticNodesPerFile: 20_000, + semanticUnknowns: 100_000, + semanticUnknownsPerFile: 20_000, } as const; -/** Maximum caller-selected node capacity; heap and transfer admission still apply. */ -export const JAVASCRIPT_SEMANTIC_NODE_MAXIMUM = 1_000_000; +/** Maximum caller-selected retained record capacity; heap and transfer admission still apply. */ +export const JAVASCRIPT_SEMANTIC_ITEM_MAXIMUM = 1_000_000; /** Project observed execution limits without inventing unmeasured values. */ export const javaScriptAnalysisCoverageLimits = ( @@ -46,7 +48,7 @@ export const javaScriptAnalysisResourceControlsSchema = z.strictObject({ .number() .int() .min(1) - .max(JAVASCRIPT_SEMANTIC_NODE_MAXIMUM) + .max(JAVASCRIPT_SEMANTIC_ITEM_MAXIMUM) .optional() .describe( "Maximum retained semantic nodes across the application; defaults to 100000. Worker heap and parent result admission remain enforced.", @@ -55,11 +57,29 @@ export const javaScriptAnalysisResourceControlsSchema = z.strictObject({ .number() .int() .min(1) - .max(JAVASCRIPT_SEMANTIC_NODE_MAXIMUM) + .max(JAVASCRIPT_SEMANTIC_ITEM_MAXIMUM) .optional() .describe( "Maximum retained semantic nodes per source file; defaults to 20000, additionally bounded by max_semantic_nodes and memory/transfer limits.", ), + max_semantic_unknowns: z + .number() + .int() + .min(1) + .max(JAVASCRIPT_SEMANTIC_ITEM_MAXIMUM) + .optional() + .describe( + "Maximum retained semantic unknown records across the application; defaults to 100000. Omitted emission occurrences are counted by family and reason.", + ), + max_semantic_unknowns_per_file: z + .number() + .int() + .min(1) + .max(JAVASCRIPT_SEMANTIC_ITEM_MAXIMUM) + .optional() + .describe( + "Maximum retained semantic unknown records per source file; defaults to 20000, additionally bounded by max_semantic_unknowns and memory/transfer limits.", + ), max_heap_mb: z .number() .int() diff --git a/src/domain/javascript/javascriptSemanticFileProjection.ts b/src/domain/javascript/javascriptSemanticFileProjection.ts index 8a7a4bb5e0..f0db49aad3 100644 --- a/src/domain/javascript/javascriptSemanticFileProjection.ts +++ b/src/domain/javascript/javascriptSemanticFileProjection.ts @@ -4,6 +4,7 @@ import type { JavaScriptSemanticGraphNode, JavaScriptSemanticGraphRelation, JavaScriptSemanticGraphUnknown, + JavaScriptSemanticUnknownOmission, } from "./javascriptSemanticGraphSchemas.js"; /** File-local facts transported before binding them to the containing application graph. */ @@ -13,6 +14,13 @@ export interface JavaScriptSemanticFileProjection { readonly nodes: readonly JavaScriptSemanticGraphNode[]; readonly relations: readonly JavaScriptSemanticGraphRelation[]; readonly unknowns: readonly JavaScriptSemanticGraphUnknown[]; + readonly omittedUnknownOccurrences?: readonly JavaScriptSemanticUnknownOmission[]; readonly fingerprints: readonly JavaScriptSemanticFingerprint[]; - readonly truncated: boolean; + readonly nodesTruncated: boolean; +} + +/** Independent retained capacities offered to one source projection. */ +export interface JavaScriptSemanticProjectionCapacity { + readonly nodes: number; + readonly unknowns: number; } diff --git a/src/domain/javascript/javascriptSemanticGraph.ts b/src/domain/javascript/javascriptSemanticGraph.ts index 4b77391f9e..15029f6b7b 100644 --- a/src/domain/javascript/javascriptSemanticGraph.ts +++ b/src/domain/javascript/javascriptSemanticGraph.ts @@ -265,6 +265,22 @@ function* checkCoverageSteps( graph: GraphRecord, context: GraphIssueReporter, ): Generator { + const omittedUnknowns = graph.coverage.omitted_unknown_occurrences ?? []; + sortedUniqueIssue( + omittedUnknowns.map(({ family, reason }) => `${family}:${reason}`), + ["coverage", "omitted_unknown_occurrences"], + "Unknown omission coverage", + context, + ); + if ( + omittedUnknowns.length > 0 && + (!graph.coverage.truncated || graph.coverage.status !== "partial") + ) + context.addIssue({ + code: "custom", + path: ["coverage"], + message: "Omitted unknown occurrences require truncated partial coverage", + }); const families = graph.coverage.families.map(({ family }) => family); if ( canonicalJson(families, "JavaScript semantic graph") !== @@ -333,7 +349,8 @@ function* checkCoverageSteps( family.status === "complete" && (family.omitted_relations !== 0 || family.unknown_ids.length > 0 || - unknownFamilies.has(family.family)) + unknownFamilies.has(family.family) || + omittedUnknowns.some((omitted) => omitted.family === family.family)) ) context.addIssue({ code: "custom", @@ -759,6 +776,18 @@ const normalizeGraphInput = ( canonicalJson(right, "JavaScript semantic graph"), ), ), + ...(parsed.coverage.omitted_unknown_occurrences === undefined + ? {} + : { + omitted_unknown_occurrences: [ + ...parsed.coverage.omitted_unknown_occurrences, + ].sort((left, right) => + compareUnicodeCodePoints( + `${left.family}:${left.reason}`, + `${right.family}:${right.reason}`, + ), + ), + }), families: [...parsed.coverage.families] .map((family) => ({ ...family, diff --git a/src/domain/javascript/javascriptSemanticGraphSchemas.ts b/src/domain/javascript/javascriptSemanticGraphSchemas.ts index 115d3762ec..b7cefc1025 100644 --- a/src/domain/javascript/javascriptSemanticGraphSchemas.ts +++ b/src/domain/javascript/javascriptSemanticGraphSchemas.ts @@ -271,6 +271,24 @@ export const JAVASCRIPT_SEMANTIC_UNKNOWN_REASONS = [ "unsupported-syntax", ] as const; +/** Counts examined emission occurrences, including repeats, without retaining discarded IDs. */ +export const javaScriptSemanticUnknownOmissionSchema = z.strictObject({ + family: z.enum(JAVASCRIPT_SEMANTIC_RELATION_FAMILIES), + reason: z.enum(JAVASCRIPT_SEMANTIC_UNKNOWN_REASONS), + occurrences: z.number().int().positive(), +}); +/** At most one summary row for each finite family/reason pair. */ +export const javaScriptSemanticUnknownOmissionsSchema = z + .array(javaScriptSemanticUnknownOmissionSchema) + .min(1) + .max( + JAVASCRIPT_SEMANTIC_RELATION_FAMILIES.length * + JAVASCRIPT_SEMANTIC_UNKNOWN_REASONS.length, + ); +export type JavaScriptSemanticUnknownOmission = z.infer< + typeof javaScriptSemanticUnknownOmissionSchema +>; + /** One unresolved semantic frontier before its ID is derived. */ export const javaScriptSemanticUnknownInputSchema = z.strictObject({ node_id: semanticNodeIdSchema.nullable(), @@ -337,6 +355,8 @@ const familyCoverageSchema = z.strictObject({ const graphCoverageSchema = z.strictObject({ status: z.enum(["complete", "partial", "unknown", "unavailable"]), truncated: z.boolean(), + omitted_unknown_occurrences: + javaScriptSemanticUnknownOmissionsSchema.exactOptional(), omitted_nodes: z.number().int().min(0).nullable(), omitted_relations: z.number().int().min(0).nullable(), limits: z.array( diff --git a/src/domain/javascript/javascriptSourceAnalysis.ts b/src/domain/javascript/javascriptSourceAnalysis.ts index 36bc3c632b..6ee47c9b09 100644 --- a/src/domain/javascript/javascriptSourceAnalysis.ts +++ b/src/domain/javascript/javascriptSourceAnalysis.ts @@ -1,3 +1,4 @@ +import type { JavaScriptSemanticProjectionCapacity } from "./javascriptSemanticFileProjection.js"; import type { AnalysisError } from "../analysisErrorBase.js"; import type { AnalysisResourceConstraintError } from "../analysisErrorCore.js"; import type { Result } from "../result.js"; @@ -33,7 +34,7 @@ export interface JavaScriptSourceAnalysisLimits { export interface JavaScriptSourceAnalysisPort { analyze( file: JavaScriptArtifactFile, - nodeBudget: number, + capacity: JavaScriptSemanticProjectionCapacity, signal?: AbortSignal, ): Promise>; close(): Promise; diff --git a/src/javascript/analysis/JavaScriptAnalysisTransfer.ts b/src/javascript/analysis/JavaScriptAnalysisTransfer.ts index 64882e4aba..1ca11334eb 100644 --- a/src/javascript/analysis/JavaScriptAnalysisTransfer.ts +++ b/src/javascript/analysis/JavaScriptAnalysisTransfer.ts @@ -6,7 +6,11 @@ import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore. import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import type { JavaScriptModuleSemanticIr } from "../../domain/javascript/javascriptModuleSemanticIr.js"; -import type { JavaScriptSemanticFileProjection } from "../../domain/javascript/javascriptSemanticFileProjection.js"; +import { JAVASCRIPT_SEMANTIC_ITEM_MAXIMUM } from "../../domain/javascript/javascriptAnalysisResourceControls.js"; +import type { + JavaScriptSemanticFileProjection, + JavaScriptSemanticProjectionCapacity, +} from "../../domain/javascript/javascriptSemanticFileProjection.js"; import { digestSchema } from "../../domain/digests.js"; import { javaScriptWorkerGraphRecordSchema, @@ -183,7 +187,15 @@ export const writeJavaScriptAnalysisTransfer = async ( try { await append({ kind: "projection-header", - value: { roots: projection.roots, truncated: projection.truncated }, + value: { + roots: projection.roots, + nodesTruncated: projection.nodesTruncated, + ...(projection.omittedUnknownOccurrences === undefined + ? {} + : { + omittedUnknownOccurrences: projection.omittedUnknownOccurrences, + }), + }, }); for (const record of moduleTransferRecords(module)) await append(record); for (const value of projection.evidenceContexts) @@ -226,7 +238,7 @@ export function readJavaScriptAnalysisTransfer( descriptor: Descriptor, source: Pick, signal: AbortSignal | undefined, - retainGraph: boolean, + capacity: JavaScriptSemanticProjectionCapacity | null, ): Promise<{ readonly module: JavaScriptModuleSemanticIr; readonly projection: JavaScriptSemanticFileProjection | null; @@ -236,11 +248,26 @@ export async function readJavaScriptAnalysisTransfer( descriptor: Descriptor, source: Pick, signal?: AbortSignal, - retainGraph = true, + capacity: JavaScriptSemanticProjectionCapacity | null = { + nodes: JAVASCRIPT_SEMANTIC_ITEM_MAXIMUM, + unknowns: JAVASCRIPT_SEMANTIC_ITEM_MAXIMUM, + }, ): Promise<{ readonly module: JavaScriptModuleSemanticIr; readonly projection: JavaScriptSemanticFileProjection | null; }> { + if ( + capacity !== null && + [capacity.nodes, capacity.unknowns].some( + (value) => + !Number.isSafeInteger(value) || + value < 0 || + value > JAVASCRIPT_SEMANTIC_ITEM_MAXIMUM, + ) + ) + throw new RangeError( + "JavaScript transfer requires valid retained capacities", + ); const expected = javaScriptAnalysisTransferDescriptorSchema.parse(descriptor); if ((await stat(path)).size !== expected.bytes) throw new TypeError( @@ -340,7 +367,7 @@ export async function readJavaScriptAnalysisTransfer( "JavaScript transfer facts precede their headers", ); if ( - !retainGraph && + capacity === null && [ "evidence-context", "node", @@ -389,6 +416,10 @@ export async function readJavaScriptAnalysisTransfer( throw new TypeError( "JavaScript transfer node belongs to a different artifact source", ); + if (capacity !== null && nodes.length >= capacity.nodes) + throw new TypeError( + "JavaScript transfer nodes exceed the offered capacity", + ); nodes.push(record.value); break; } @@ -398,6 +429,10 @@ export async function readJavaScriptAnalysisTransfer( break; case "unknown": retainIdentity(record.kind, record.value.unknown_id); + if (capacity !== null && unknowns.length >= capacity.unknowns) + throw new TypeError( + "JavaScript transfer unknowns exceed the offered capacity", + ); unknowns.push(record.value); break; case "fingerprint": @@ -433,15 +468,16 @@ export async function readJavaScriptAnalysisTransfer( throw new TypeError("JavaScript analysis transfer is missing its headers"); return { module: { ...moduleHeader, scopes, bindings, callables, moduleLinks }, - projection: retainGraph - ? { - ...projectionHeader, - evidenceContexts, - nodes, - relations, - unknowns, - fingerprints, - } - : null, + projection: + capacity !== null + ? { + ...projectionHeader, + evidenceContexts, + nodes, + relations, + unknowns, + fingerprints, + } + : null, }; } diff --git a/src/javascript/analysis/JavaScriptAnalysisWorker.ts b/src/javascript/analysis/JavaScriptAnalysisWorker.ts index b9471abb96..5a96f4a92a 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorker.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorker.ts @@ -1,3 +1,4 @@ +import type { JavaScriptSemanticProjectionCapacity } from "../../domain/javascript/javascriptSemanticFileProjection.js"; import { createHash, randomUUID } from "node:crypto"; import { readFile, rm, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -154,7 +155,7 @@ export class JavaScriptAnalysisWorker { /** Analyze inert source; hard worker failure does not terminate this process. */ async analyze( file: JavaScriptArtifactFile, - nodeBudget: number, + capacity: JavaScriptSemanticProjectionCapacity, signal?: AbortSignal, ): Promise< Result @@ -203,7 +204,8 @@ export class JavaScriptAnalysisWorker { let offeredResultBudget = 0; const request = javaScriptWorkerRequestSchema.parse({ id, - node_budget: nodeBudget, + node_budget: capacity.nodes, + unknown_budget: capacity.unknowns, text_sha256: createHash("sha256").update(file.text.value).digest("hex"), file: metadata, }); @@ -344,7 +346,7 @@ export class JavaScriptAnalysisWorker { descriptor, file, deadline.signal, - retainGraph, + retainGraph ? capacity : null, ); module = decoded.module; projection = decoded.projection; diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts index 5c81c5ce0f..b054e6ba85 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerProtocol.ts @@ -4,7 +4,7 @@ import { jsonValueSchema } from "../../domain/jsonValue.js"; import { digestSchema } from "../../domain/digests.js"; import { JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS, - JAVASCRIPT_SEMANTIC_NODE_MAXIMUM, + JAVASCRIPT_SEMANTIC_ITEM_MAXIMUM, } from "../../domain/javascript/javascriptAnalysisResourceControls.js"; import { javaScriptAnalysisTransferDescriptorSchema } from "./JavaScriptAnalysisTransfer.js"; @@ -18,7 +18,8 @@ export const JAVASCRIPT_ANALYSIS_WORKER_DEFAULTS = { /** Caller-selected artifact identity, separate from the decoded source-text digest. */ export const javaScriptWorkerRequestSchema = z.strictObject({ id: z.uuid(), - node_budget: z.number().int().min(0).max(JAVASCRIPT_SEMANTIC_NODE_MAXIMUM), + unknown_budget: z.number().int().min(0).max(JAVASCRIPT_SEMANTIC_ITEM_MAXIMUM), + node_budget: z.number().int().min(0).max(JAVASCRIPT_SEMANTIC_ITEM_MAXIMUM), text_sha256: digestSchema, file: z.strictObject({ path: z.string(), diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index d6587a3c43..17be5e0747 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -1,3 +1,4 @@ +import { javaScriptSemanticUnknownOmissionsSchema } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; import { z } from "zod"; import { digestSchema } from "../../domain/digests.js"; import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; @@ -653,8 +654,10 @@ export const javaScriptWorkerGraphRecordSchema = z.discriminatedUnion("kind", [ /** Portable per-file header, before any graph binding or immutable proof. */ export const javaScriptWorkerProjectionHeaderSchema = z.strictObject({ + omittedUnknownOccurrences: + javaScriptSemanticUnknownOmissionsSchema.exactOptional(), roots: z.array(javaScriptSemanticNodeSchema.shape.node_id), - truncated: z.boolean(), + nodesTruncated: z.boolean(), }); /** Module facts are transferred individually to avoid a whole-IR JSON string. */ diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts index e3d0a8bc2e..08c8f45fb4 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerServer.ts @@ -1,3 +1,4 @@ +import type { JavaScriptSemanticProjectionCapacity } from "../../domain/javascript/javascriptSemanticFileProjection.js"; import { createHash } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -26,7 +27,7 @@ import { export interface JavaScriptSourceWorkerAnalyzer { ( file: JavaScriptArtifactFile, - nodeBudget: number, + capacity: JavaScriptSemanticProjectionCapacity, callbacks: { readonly phase: ( phase: "parse" | "static" | "semantic" | "projection", @@ -111,7 +112,7 @@ const analyzeJob = async ( ...request.file, text: { included: true, value: source }, }, - request.node_budget, + { nodes: request.node_budget, unknowns: request.unknown_budget }, { phase: async (phase) => send({ kind: "phase", id: request.id, phase }), checkpoint: async (facts) => { diff --git a/tests/acceptance/applications/javascriptUnknownCapacity.test.ts b/tests/acceptance/applications/javascriptUnknownCapacity.test.ts new file mode 100644 index 0000000000..6af4858728 --- /dev/null +++ b/tests/acceptance/applications/javascriptUnknownCapacity.test.ts @@ -0,0 +1,92 @@ +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { expect } from "vitest"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "preserves caller unknown capacities and omission coverage through real worker, CLI and stdio MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-js-unknown-capacity-"); + for (const name of ["first.js", "second.js"]) + await writeFile( + join(root, name), + "globalThis.u.p(globalThis.v[k]);\n".repeat(4), + ); + const options = { + max_semantic_nodes: 100, + max_semantic_nodes_per_file: 100, + max_semantic_unknowns: 3, + max_semantic_unknowns_per_file: 2, + }; + const contract = toolContract("analyze_javascript_application"); + const output = await cli.run({ + arguments: [ + "analyze-javascript-application", + root, + "--format", + "json", + "--max-semantic-nodes", + "100", + "--max-semantic-nodes-per-file", + "100", + "--max-semantic-unknowns", + "3", + "--max-semantic-unknowns-per-file", + "2", + ], + }); + expect(output.exitCode).toBe(0); + const evidence = contract.outputSchema.parse(output.json); + expect(evidence.parameters).toMatchObject(options); + const result = javascriptApplicationAnalysisResultSchema.parse( + evidence.normalized_result, + ); + expect(result.semantic_graph.unknowns).toHaveLength(3); + expect(result.semantic_graph.coverage).toMatchObject({ + status: "partial", + truncated: true, + omitted_nodes: 0, + }); + expect( + result.semantic_graph.coverage.omitted_unknown_occurrences!.reduce( + (sum, row) => sum + row.occurrences, + 0, + ), + ).toBe(29); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [resolve("scripts/rea.mjs"), "mcp"], + cwd: process.cwd(), + env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent" }, + stderr: "pipe", + }); + const client = new Client({ + name: "unknown-capacity-regression", + version: "1", + }); + try { + await client.connect(transport); + const response = await client.callTool({ + name: contract.name, + arguments: { input_path: root, detail: "complete", ...options }, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const mcp = contract.outputSchema.parse(response.structuredContent); + expect(mcp.parameters).toEqual(evidence.parameters); + expect(mcp.evidence_id).toBe(evidence.evidence_id); + expect(mcp.normalized_result).toEqual(evidence.normalized_result); + } finally { + try { + await client.close(); + } finally { + await transport.close(); + } + } + }, + 30_000, +); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts index e247cb60c7..8d355d8c1f 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts @@ -211,7 +211,7 @@ it("preserves graph commitments and export shapes when consuming file-local IR", projectJavaScriptSemanticFileSteps( analyzed.file, analyzed.semantic.ir, - transferred.remainingFileNodeBudget(), + transferred.remainingFileCapacity(), ), ); const module = projectJavaScriptModuleSemantics(analyzed.semantic.ir); diff --git a/tests/boundary/javascript/javascriptAnalysisTransferBudgets.test.ts b/tests/boundary/javascript/javascriptAnalysisTransferBudgets.test.ts index 5089690b32..16d67f94ba 100644 --- a/tests/boundary/javascript/javascriptAnalysisTransferBudgets.test.ts +++ b/tests/boundary/javascript/javascriptAnalysisTransferBudgets.test.ts @@ -38,7 +38,7 @@ it("enforces the offered complete-file byte budget while preserving authenticate const ir = analyzeJavaScriptSemantics(text); const module = projectJavaScriptModuleSemantics(ir); const projection = await completeJavaScriptAnalysisSteps( - projectJavaScriptSemanticFileSteps(file, ir, 100), + projectJavaScriptSemanticFileSteps(file, ir, { nodes: 100, unknowns: 100 }), ); const observed = await writeJavaScriptAnalysisTransfer( join(root, "observed"), @@ -98,3 +98,41 @@ it("enforces the offered complete-file byte budget while preserving authenticate ), ).rejects.toBeInstanceOf(RangeError); }); + +it("rejects authenticated worker rows beyond the offered node or unknown capacity", async () => { + const root = await createTestTempDirectory("rea-js-transfer-capacity-"); + const text = "globalThis.u.p(globalThis.v[k]);"; + const file: JavaScriptArtifactFile = { + path: "app.js", + sha256: createHash("sha256").update(text).digest("hex"), + container_sha256: "a".repeat(64), + bytes: Buffer.byteLength(text), + inventory_artifact_id: "fixture-source", + kind: "javascript", + unpacked: false, + text: { included: true, value: text }, + }; + const ir = analyzeJavaScriptSemantics(text); + const projection = await completeJavaScriptAnalysisSteps( + projectJavaScriptSemanticFileSteps(file, ir, { nodes: 100, unknowns: 100 }), + ); + expect(projection.unknowns.length).toBeGreaterThan(1); + const path = join(root, "records"); + const descriptor = await writeJavaScriptAnalysisTransfer( + path, + projectJavaScriptModuleSemantics(ir), + projection, + ); + await expect( + readJavaScriptAnalysisTransfer(path, descriptor, file, undefined, { + nodes: 100, + unknowns: 1, + }), + ).rejects.toThrow("unknowns exceed the offered capacity"); + await expect( + readJavaScriptAnalysisTransfer(path, descriptor, file, undefined, { + nodes: 1, + unknowns: 100, + }), + ).rejects.toThrow("nodes exceed the offered capacity"); +}); diff --git a/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts b/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts index 9ffe94a94b..0a6272bfc6 100644 --- a/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts +++ b/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts @@ -51,7 +51,7 @@ it("reuses one real owned analyzer while retaining distinct source identities an "export function next() { return { observed: 8 }; }", ); for (const [index, file] of [first, second].entries()) { - const result = await worker.analyze(file, 100); + const result = await worker.analyze(file, { nodes: 100, unknowns: 100 }); if (!result.ok) throw result.error.error; expect(result.value.javascript.parse_status).toBe("complete"); expect( @@ -83,7 +83,7 @@ it("reports an actual startup deadline and closes its owned resources before sub const file = source("app.mjs", "export const observed = 7;"); const expired = new JavaScriptAnalysisWorker({ heapMb: 128, timeoutMs: 1 }); try { - const result = await expired.analyze(file, 100); + const result = await expired.analyze(file, { nodes: 100, unknowns: 100 }); if (result.ok) throw new Error( "A one-millisecond fresh process deadline unexpectedly completed", @@ -97,7 +97,7 @@ it("reports an actual startup deadline and closes its owned resources before sub } const next = new JavaScriptAnalysisWorker({ heapMb: 128, timeoutMs: 30_000 }); try { - const result = await next.analyze(file, 100); + const result = await next.analyze(file, { nodes: 100, unknowns: 100 }); if (!result.ok) throw result.error.error; expect(result.value.javascript.parse_status).toBe("complete"); } finally { @@ -131,14 +131,18 @@ it("preserves a failed owned cleanup retry when the next caller cancels before a const next = new JavaScriptAnalysisWorker({ heapMb: 128, timeoutMs: 30_000 }); try { const file = source("app.mjs", "export const observed = 7;"); - const first = await worker.analyze(file, 100); + const first = await worker.analyze(file, { nodes: 100, unknowns: 100 }); if (!first.ok) throw first.error.error; const closing = worker.close(); await expect(closing).rejects.toBeInstanceOf(ProviderCleanupError); await expect(closing).rejects.toMatchObject({ cause: { message: "Fixture owned cleanup is unavailable" }, }); - const interrupted = await next.analyze(file, 100, controller.signal); + const interrupted = await next.analyze( + file, + { nodes: 100, unknowns: 100 }, + controller.signal, + ); if (interrupted.ok) throw new Error("Unreleased ownership must block subsequent analysis"); expect(controller.signal.aborted).toBe(true); @@ -147,7 +151,7 @@ it("preserves a failed owned cleanup retry when the next caller cancels before a }); releaseAllowed = true; await worker.close(); - const recovered = await next.analyze(file, 100); + const recovered = await next.analyze(file, { nodes: 100, unknowns: 100 }); if (!recovered.ok) throw recovered.error.error; expect(recovered.value.javascript.parse_status).toBe("complete"); } finally { @@ -195,7 +199,7 @@ it("ends worker stdin and unreferences process handles when cleanup reports insp ); try { const file = source("app.mjs", "export const observed = 7;"); - const result = await worker.analyze(file, 100); + const result = await worker.analyze(file, { nodes: 100, unknowns: 100 }); if (!result.ok) throw result.error.error; const closing = worker.close(); await expect(closing).rejects.toBeInstanceOf(ProviderCleanupError); @@ -249,7 +253,7 @@ const worker = new JavaScriptAnalysisWorker({ heapMb: 128, timeoutMs: 10000 }, a await writeFile(${JSON.stringify(ownershipPath)}, JSON.stringify(ownership)); return { ...launched, cleanup: async () => ({ cleaned: false, reason: "fixture inspection unavailable" }) }; }); -const analyzed = await worker.analyze(${JSON.stringify(source("app.mjs", "export const value = 1;"))}, 100); +const analyzed = await worker.analyze(${JSON.stringify(source("app.mjs", "export const value = 1;"))}, { nodes: 100, unknowns: 100 }); if (!analyzed.ok) throw analyzed.error.error; try { await worker.close(); throw new Error("cleanup unexpectedly completed"); } catch (error) { @@ -308,7 +312,7 @@ it("formats a real worker's declined semantic budget once", async () => { try { const result = await worker.analyze( source("main.js", "export const observed = 7;"), - 100, + { nodes: 100, unknowns: 100 }, ); if (result.ok) throw new Error("expected a declined semantic budget"); const { error } = result.error; @@ -350,7 +354,7 @@ it("reports the worker heap limit only when the runtime enforces V8 heap flags", try { const result = await worker.analyze( source("app.mjs", "export const observed = 7;"), - 100, + { nodes: 100, unknowns: 100 }, ); if (result.ok) throw new Error("expected a reported resource failure"); expect(result.error.error).toMatchObject({ @@ -382,7 +386,7 @@ it("rejects a worker heap report that contradicts the launching runtime", async try { const result = await worker.analyze( source("app.mjs", "export const observed = 7;"), - 100, + { nodes: 100, unknowns: 100 }, ); if (result.ok) throw new Error("expected a handshake failure"); expect(result.error.error._tag).toBe("AnalysisOutputError"); @@ -426,7 +430,7 @@ require("node:readline").createInterface({ input: process.stdin }).once("line", try { const result = await worker.analyze( source("app.mjs", "export const observed = 7;"), - 100, + { nodes: 100, unknowns: 100 }, ); if (result.ok) throw new Error("expected the worker to fail"); expect(result.error.error).toMatchObject(