fix(javascript): bound semantic unknown retention at projection

Admit unknowns before canonical construction and provenance interning. Offer independent per-file and application capacities through CLI, Evidence and the owned analysis worker, and reject over-allocation during streamed decoding.

Report examined omitted emission occurrences by family and reason without retaining discarded identities. Preserve omission-free graph identities and distinguish node truncation from overall partial coverage.

Fixes #4797
This commit is contained in:
morluto
2026-10-17 21:57:31 +08:00
parent 926e18b3fc
commit f4322b15ed
34 changed files with 892 additions and 293 deletions
@@ -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<JavaScriptArtifactFile, "path" | "sha256">,
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<JavaScriptArtifactFile, "path" | "sha256">,
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,
};
}
@@ -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<JavaScriptAnalyzedSource, JavaScriptSourceAnalysisFailure>
@@ -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;
@@ -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(),
@@ -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. */
@@ -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) => {