diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 8d4bfeb76f..8014def273 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -225,11 +225,20 @@ reports truncation when its format or parser safety boundary is reached. The application graph has no aggregate node, edge, root, or observation prefix cap. Semantic evaluation retains at most 256 distinct primitive candidates per -expression and traverses at most 256 expression levels. It checks union products -before allocating combinations. When either boundary is reached, the value stays -unknown, semantic coverage becomes partial, and graph evidence retains the -expression location and limiting reason. The number of unresolved alternatives -is unknown; it is not reported as an exact omission count. +expression and at most 1 MiB of worst-case JSON string bytes across a normalized +primitive value's string candidates. The byte budget estimates six JSON bytes +per UTF-16 code unit, the expansion bound for escaped strings, and bounds both +retained candidate strings and temporary canonical-key serialization. This is +an in-memory semantic allocation bound independent of CLI or MCP transport +budgets. Source literal bytes remain in the parsed source and artifact evidence; +when a normalized semantic value exceeds the bound, its value is unknown and +coverage is partial. Expression +evaluation and provenance walks stop after 256 nested levels. Union products +and string lengths are checked before allocating combinations or concatenated +strings. When a boundary is reached, the value stays unknown, semantic coverage +becomes partial, and graph evidence retains the expression location and typed +limiting reason. The number of unresolved alternatives is unknown; it is not +reported as an exact omission count. Byte, entry, path, and graph-shape bounds are hard limits. The parse deadline is checked before and between bounded parsing and traversal phases; the synchronous diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index 54bd4f4690..beddcca9c2 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -25,6 +25,7 @@ import { semanticCoverageResourceLimits } from "../../domain/javascript/javascri import { SEMANTIC_EXPRESSION_DEPTH_LIMIT, SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT, + SEMANTIC_PRIMITIVE_JSON_BYTES_LIMIT, semanticResourceLimitReason, } from "../../domain/javascript/javascriptSemanticResourceLimits.js"; import { @@ -136,11 +137,15 @@ const applicationGraphResourceLimit = ( value: resourceLimit === "primitive-candidates" ? SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT - : SEMANTIC_EXPRESSION_DEPTH_LIMIT, + : resourceLimit === "primitive-bytes" + ? SEMANTIC_PRIMITIVE_JSON_BYTES_LIMIT + : SEMANTIC_EXPRESSION_DEPTH_LIMIT, unit: resourceLimit === "expression-depth" ? ("depth" as const) - : ("items" as const), + : resourceLimit === "primitive-bytes" + ? ("bytes" as const) + : ("items" as const), }); const graphLimitations = ( diff --git a/src/application/javascript/JavaScriptModuleRelationships.ts b/src/application/javascript/JavaScriptModuleRelationships.ts index 25849528ee..d3a8c825b9 100644 --- a/src/application/javascript/JavaScriptModuleRelationships.ts +++ b/src/application/javascript/JavaScriptModuleRelationships.ts @@ -10,6 +10,7 @@ import { semanticCoverageResourceLimits } from "../../domain/javascript/javascri import { SEMANTIC_EXPRESSION_DEPTH_LIMIT, SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT, + SEMANTIC_PRIMITIVE_JSON_BYTES_LIMIT, semanticResourceLimitReason, } from "../../domain/javascript/javascriptSemanticResourceLimits.js"; import type { @@ -389,11 +390,15 @@ const semanticCoverage = ( value: resourceLimit === "primitive-candidates" ? SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT - : SEMANTIC_EXPRESSION_DEPTH_LIMIT, + : resourceLimit === "primitive-bytes" + ? SEMANTIC_PRIMITIVE_JSON_BYTES_LIMIT + : SEMANTIC_EXPRESSION_DEPTH_LIMIT, unit: resourceLimit === "expression-depth" ? ("depth" as const) - : ("items" as const), + : resourceLimit === "primitive-bytes" + ? ("bytes" as const) + : ("items" as const), })), semantic.ir.coverage.omittedCount, ); diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts index a4df0ad064..bef8a1c9e6 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts @@ -111,6 +111,30 @@ it("preserves primitive candidate budget unknowns in semantic graph coverage", ( ); }); +it("preserves derived string-byte limit reasons in semantic graph unknowns", () => { + const declarations = ['const value0 = "x";']; + for (let index = 1; index <= 30; index += 1) { + const previous = `value${String(index - 1)}`; + declarations.push( + `const value${String(index)} = ${previous} + ${previous};`, + ); + } + declarations.push("const answer = value30;"); + const source = declarations.join("\n"); + const ir = analyzeJavaScriptSemantics(source); + const graph = graphFor(source, ir); + + expect(semanticCoverageResourceLimits(ir.coverage)).toContain( + "primitive-bytes", + ); + expect(graph.unknowns).toContainEqual( + expect.objectContaining({ + reason: "resource-limit", + detail: expect.stringMatching(/primitive string-byte budget exceeded/i), + }), + ); +}); + it("retains resource-limit reasons at nested object property slots", () => { const term = '(true ? "a" : "b")'; const source = `const answer = { nested: { value: ${Array.from({ length: 20 }, () => term).join(" + ")} } };`; diff --git a/src/domain/javascript/javascriptSemanticAnalysis.unions.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.unions.test.ts index 32abf4cc33..24bbb17071 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.unions.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.unions.test.ts @@ -219,7 +219,7 @@ describe("logical primitive unions", () => { }); }); -describe("semantic expression resource bounds", () => { +describe("semantic primitive allocation and binding reuse", () => { it("returns an explicit unknown before expanding a large addition product", () => { const term = '(choice ? "a" : "b")'; const expression = Array.from({ length: 20 }, () => term).join(" + "); @@ -238,6 +238,102 @@ describe("semantic expression resource bounds", () => { }); }); + it("bounds derived single-string growth before concatenation or key serialization", () => { + const declarations = ['const value0 = "x";']; + for (let index = 1; index <= 100; index += 1) { + const previous = `value${String(index - 1)}`; + declarations.push( + `const value${String(index)} = ${previous} + ${previous};`, + ); + } + declarations.push("const answer = value100;"); + const ir = analyzeJavaScriptSemantics(declarations.join("\n")); + expect(topLevelBinding(ir, "answer").value).toMatchObject({ + status: "unknown", + resourceLimit: "primitive-bytes", + reason: expect.stringMatching(/primitive string-byte budget exceeded/i), + }); + expect(ir.coverage).toMatchObject({ + status: "partial", + omittedCount: null, + resourceLimits: ["primitive-bytes"], + }); + }); + + it("memoizes by resolved binding without crossing shadowing or mutation", () => { + const ir = analyzeJavaScriptSemantics(` + const shared = "outer"; + function readsOuter() { return shared; } + function readsShadow() { const shared = "inner"; return shared; } + function readsMutation() { + let shared = "before"; + shared = "after"; + return shared; + } + `); + expect(topLevelBinding(ir, "shared").value).toEqual({ + status: "literal", + value: "outer", + }); + expect( + ir.callables.find(({ name }) => name === "readsOuter")?.returnSites[0] + ?.value, + ).toEqual({ + status: "literal", + value: "outer", + }); + expect( + ir.callables.find(({ name }) => name === "readsShadow")?.returnSites[0] + ?.value, + ).toEqual({ + status: "literal", + value: "inner", + }); + expect( + ir.callables.find(({ name }) => name === "readsMutation")?.returnSites[0] + ?.value.status, + ).toBe("ambiguous"); + }); + + it("retains a large derived string while its aggregate JSON estimate fits", () => { + const part = "x".repeat(32_768); + const ir = analyzeJavaScriptSemantics( + `const left = ${JSON.stringify(part)}; const right = ${JSON.stringify(part)}; const answer = left + right;`, + ); + expect(topLevelBinding(ir, "answer").value).toMatchObject({ + status: "literal", + value: expect.stringMatching(/^x+$/u), + }); + const value = topLevelBinding(ir, "answer").value; + if (value.status !== "literal" || typeof value.value !== "string") + throw new Error("Expected exact derived string"); + expect(value.value.length).toBe(65_536); + expect(ir.coverage.status).toBe("complete"); + }); + + it("retains exact candidates for a near-budget Cartesian workload", () => { + const left = "l".repeat(85); + const right = "r".repeat(85); + const expression = Array.from( + { length: 7 }, + () => + `(${JSON.stringify(left)} + (${`choice ? ${JSON.stringify(left)} : ${JSON.stringify(right)}`}))`, + ).join(" + "); + const value = valueOf(expression); + expect(value.status).toBe("union"); + if (value.status !== "union") + throw new Error("Expected exact primitive union"); + expect(value.values).toHaveLength(128); + expect( + value.values.every( + (candidate) => + typeof candidate === "string" && candidate.length === 1_190, + ), + ).toBe(true); + }); +}); + +describe("semantic expression depth bounds", () => { it("returns an explicit unknown for a deeply nested unary AST without throwing", () => { const parsed = parseJavaScriptSource("const answer = true;"); const declaration = parsed?.program.body[0]; diff --git a/src/domain/javascript/javascriptSemanticPrimitives.ts b/src/domain/javascript/javascriptSemanticPrimitives.ts index 04f5110946..e3a8b4875d 100644 --- a/src/domain/javascript/javascriptSemanticPrimitives.ts +++ b/src/domain/javascript/javascriptSemanticPrimitives.ts @@ -9,6 +9,7 @@ import { semanticPrimitiveKey } from "./javascriptSemanticProvenance.js"; import { readExactJavaScriptLiteral } from "./javascriptAstValues.js"; import { SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT, + exceedsSemanticPrimitiveStringByteBudget, semanticResourceLimitUnknown, } from "./javascriptSemanticResourceLimits.js"; @@ -23,20 +24,29 @@ export const MAX_SEMANTIC_PRIMITIVE_CANDIDATES = export const semanticPrimitiveSet = ( values: readonly JavaScriptSemanticPrimitive[], ): JavaScriptSemanticValue => { - const byKey = new Map(); + const uniqueValues = new Set(); for (const value of values) { if (typeof value === "number" && !Number.isFinite(value)) return { status: "unknown", reason: "Nonfinite numbers are outside the JSON primitive lattice.", }; - byKey.set(semanticPrimitiveKey(value), value); - if (byKey.size > MAX_SEMANTIC_PRIMITIVE_CANDIDATES) + uniqueValues.add(value); + if (uniqueValues.size > MAX_SEMANTIC_PRIMITIVE_CANDIDATES) return semanticResourceLimitUnknown("primitive-candidates"); } - const unique = [...byKey.values()].sort((left, right) => - compareCodePoints(semanticPrimitiveKey(left), semanticPrimitiveKey(right)), - ); + if ( + exceedsSemanticPrimitiveStringByteBudget( + [...uniqueValues].flatMap((value) => + typeof value === "string" ? [value] : [], + ), + ) + ) + return semanticResourceLimitUnknown("primitive-bytes"); + const unique = [...uniqueValues] + .map((value) => ({ key: semanticPrimitiveKey(value), value })) + .sort((left, right) => compareCodePoints(left.key, right.key)) + .map(({ value }) => value); const only = unique[0]; return unique.length === 1 && only !== undefined ? { status: "literal", value: only } diff --git a/src/domain/javascript/javascriptSemanticResourceLimits.ts b/src/domain/javascript/javascriptSemanticResourceLimits.ts index 88a0f019b3..32155e3390 100644 --- a/src/domain/javascript/javascriptSemanticResourceLimits.ts +++ b/src/domain/javascript/javascriptSemanticResourceLimits.ts @@ -3,16 +3,71 @@ import type { JavaScriptSemanticValue, } from "./javascriptSemanticValueTypes.js"; +/** Maximum distinct alternatives retained in one normalized primitive value. */ export const SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT = 256; +/** Maximum active expression nesting during inert semantic evaluation. */ export const SEMANTIC_EXPRESSION_DEPTH_LIMIT = 256; +/** Bound normalized strings and transient keys independently of transport limits. */ +export const SEMANTIC_PRIMITIVE_JSON_BYTES_LIMIT = 1_048_576; const RESOURCE_LIMIT_REASON: Record = { "primitive-candidates": "Primitive candidate budget exceeded (maximum 256 alternatives).", + "primitive-bytes": + "Primitive string-byte budget exceeded (maximum 1048576 estimated JSON bytes).", "expression-depth": "Semantic expression depth budget exceeded (maximum 256 nested expressions).", }; +/** Check UTF-16 length against worst-case JSON escaping without flattening. */ +const jsonStringBytesFromLength = (codeUnits: number): number => + codeUnits * 6 + 2; + +/** Bound string payload before JSON key serialization or candidate creation. */ +export const exceedsSemanticPrimitiveStringByteBudget = ( + strings: Iterable, +): boolean => { + let total = 0; + for (const value of strings) { + total += jsonStringBytesFromLength(value.length); + if (total > SEMANTIC_PRIMITIVE_JSON_BYTES_LIMIT) return true; + } + return false; +}; + +/** Bound all outputs of a primitive addition before concatenating any. */ +export const exceedsSemanticPrimitiveAdditionByteBudget = ( + left: readonly (string | number | boolean | null)[], + right: readonly (string | number | boolean | null)[], +): boolean => { + let total = 0; + for (const leftValue of left) { + for (const rightValue of right) { + if (typeof leftValue !== "string" && typeof rightValue !== "string") + continue; + const length = String(leftValue).length + String(rightValue).length; + total += jsonStringBytesFromLength(length); + if (total > SEMANTIC_PRIMITIVE_JSON_BYTES_LIMIT) return true; + } + } + return false; +}; + +/** Bound template interpolation outputs before creating concatenated strings. */ +export const exceedsSemanticTemplateByteBudget = ( + prefixes: readonly string[], + suffixes: readonly string[], +): boolean => { + let total = 0; + for (const prefix of prefixes) { + for (const suffix of suffixes) { + total += jsonStringBytesFromLength(prefix.length + suffix.length); + if (total > SEMANTIC_PRIMITIVE_JSON_BYTES_LIMIT) return true; + } + } + return false; +}; + /** Make an explicit unknown value tagged with the semantic resource bound. */ export const semanticResourceLimitUnknown = ( resourceLimit: JavaScriptSemanticResourceLimit, diff --git a/src/domain/javascript/javascriptSemanticValueTypes.ts b/src/domain/javascript/javascriptSemanticValueTypes.ts index 657c780e0f..e2545b2b61 100644 --- a/src/domain/javascript/javascriptSemanticValueTypes.ts +++ b/src/domain/javascript/javascriptSemanticValueTypes.ts @@ -4,6 +4,7 @@ export type JavaScriptSemanticPrimitive = string | number | boolean | null; /** Resource bound that prevented an exact semantic value. */ export type JavaScriptSemanticResourceLimit = | "primitive-candidates" + | "primitive-bytes" | "expression-depth"; type JavaScriptSemanticObjectValue = { diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index a7e78dd3a8..ab8bcf37fc 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -32,6 +32,8 @@ import { import { SEMANTIC_EXPRESSION_DEPTH_LIMIT, SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT, + exceedsSemanticPrimitiveAdditionByteBudget, + exceedsSemanticTemplateByteBudget, semanticResourceLimitUnknown, semanticResourceLimitReason, } from "./javascriptSemanticResourceLimits.js"; @@ -40,8 +42,24 @@ interface EvaluationContext { readonly state: JavaScriptSemanticAnalysisState; readonly bindings: ReadonlySet; readonly expressionDepth: number; + readonly primitiveBindingValues: Map; } +const primitiveBindingValuesByState = new WeakMap< + JavaScriptSemanticAnalysisState, + Map +>(); + +const primitiveBindingValuesFor = ( + state: JavaScriptSemanticAnalysisState, +): Map => { + const existing = primitiveBindingValuesByState.get(state); + if (existing !== undefined) return existing; + const values = new Map(); + primitiveBindingValuesByState.set(state, values); + return values; +}; + // Cap expression recursion independently from graph nodes and primitive unions. const isSemanticResourceLimit = ( value: JavaScriptSemanticValue, @@ -56,14 +74,24 @@ export const evaluateSemanticBinding = ( binding: JavaScriptSemanticBindingState, state: JavaScriptSemanticAnalysisState, ): JavaScriptSemanticValue => - evaluateBinding(binding, { state, bindings: new Set(), expressionDepth: 0 }); + evaluateBinding(binding, { + state, + bindings: new Set(), + expressionDepth: 0, + primitiveBindingValues: primitiveBindingValuesFor(state), + }); /** Evaluate one arbitrary inert expression in the established lexical state. */ export const evaluateSemanticExpression = ( node: t.Node, state: JavaScriptSemanticAnalysisState, ): JavaScriptSemanticValue => - evaluateExpression(node, { state, bindings: new Set(), expressionDepth: 0 }); + evaluateExpression(node, { + state, + bindings: new Set(), + expressionDepth: 0, + primitiveBindingValues: primitiveBindingValuesFor(state), + }); /** Follow module provenance through destructuring, members, and aliases. */ export const evaluateSemanticProvenance = ( @@ -74,6 +102,7 @@ export const evaluateSemanticProvenance = ( state, bindings: new Set(), expressionDepth: 0, + primitiveBindingValues: primitiveBindingValuesFor(state), }); const evaluateBinding = ( @@ -82,6 +111,8 @@ const evaluateBinding = ( ): JavaScriptSemanticValue => { if (context.bindings.has(binding.bindingId)) return { status: "cycle", reason: `Alias cycle at ${binding.name}.` }; + const cached = context.primitiveBindingValues.get(binding.bindingId); + if (cached !== undefined) return cached; if (binding.initializers.length === 0) return { status: "unknown", @@ -100,7 +131,19 @@ const evaluateBinding = ( evaluateExpression(initializer.node, nested), initializer.projection, ); - return binding.mutatedPaths.reduce(invalidateSemanticMutationPath, value); + const projected = binding.mutatedPaths.reduce( + invalidateSemanticMutationPath, + value, + ); + if ( + projected.status === "literal" || + projected.status === "union" || + (isSemanticResourceLimit(projected) && + (projected.resourceLimit === "primitive-bytes" || + projected.resourceLimit === "primitive-candidates")) + ) + context.primitiveBindingValues.set(binding.bindingId, projected); + return projected; }; const evaluateExpression = ( @@ -155,6 +198,8 @@ const evaluateTemplate = ( // Semantic template evaluation retains its established raw fallback for // synthetic AST inputs; exact atom readers remain cooked-only. const text = quasi?.value.cooked ?? quasi?.value.raw ?? ""; + if (exceedsSemanticTemplateByteBudget(candidates, [text])) + return semanticResourceLimitUnknown("primitive-bytes"); candidates = candidates.map((prefix) => `${prefix}${text}`); const expression = node.expressions[index]; if (expression === undefined) continue; @@ -169,8 +214,11 @@ const evaluateTemplate = ( } if (candidates.length > SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT / values.length) return semanticResourceLimitUnknown("primitive-candidates"); + const suffixes = values.map((value) => String(value)); + if (exceedsSemanticTemplateByteBudget(candidates, suffixes)) + return semanticResourceLimitUnknown("primitive-bytes"); candidates = candidates.flatMap((prefix) => - values.map((value) => `${prefix}${String(value)}`), + suffixes.map((suffix) => `${prefix}${suffix}`), ); } return primitiveSet(candidates); @@ -347,6 +395,8 @@ const addPrimitiveValues = ( } if (left.length > MAX_SEMANTIC_PRIMITIVE_CANDIDATES / right.length) return semanticResourceLimitUnknown("primitive-candidates"); + if (exceedsSemanticPrimitiveAdditionByteBudget(left, right)) + return semanticResourceLimitUnknown("primitive-bytes"); const values = left.flatMap((leftValue) => right.map((rightValue) => typeof leftValue === "string" || typeof rightValue === "string" @@ -563,6 +613,7 @@ const nestedContext = ( bindingId?: string, ): EvaluationContext => ({ state: context.state, + primitiveBindingValues: context.primitiveBindingValues, expressionDepth: context.expressionDepth + 1, bindings: bindingId === undefined diff --git a/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts b/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts index 882cb364b1..e97892e08a 100644 --- a/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts +++ b/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts @@ -114,6 +114,15 @@ it("reports semantic value resource limits in application graph coverage", async join(root, "app.js"), `const answer = { nested: ${expression} };`, ); + const declarations = ['const value0 = "x";']; + for (let index = 1; index <= 30; index += 1) { + const previous = `value${String(index - 1)}`; + declarations.push( + `const value${String(index)} = ${previous} + ${previous};`, + ); + } + declarations.push("const answer = value30;"); + await writeFile(join(root, "growth.js"), declarations.join("\n")); const snapshot = await scanCanonicalArtifactInventory(root, {}); const reader = createJavaScriptArtifactReader(root, "directory"); try { @@ -124,17 +133,25 @@ it("reports semantic value resource limits in application graph coverage", async expect(graph.coverage).toMatchObject({ status: "partial", omitted_count: null, - limits: [ - { + limits: expect.arrayContaining([ + expect.objectContaining({ name: "javascript_semantic_primitive_candidates", - value: 256, unit: "items", - }, - ], + }), + expect.objectContaining({ + name: "javascript_semantic_primitive_bytes", + unit: "bytes", + }), + ]), }); expect(graph.limitations).toContain( "Primitive candidate budget exceeded (maximum 256 alternatives).", ); + expect(graph.limitations).toEqual( + expect.arrayContaining([ + expect.stringMatching(/primitive string-byte budget exceeded/i), + ]), + ); } finally { await reader.close(); }