From abfccc0683e73ee396087309ab98c78c9f6dde4b Mon Sep 17 00:00:00 2001 From: Saurabh Sungh Date: Sat, 7 Nov 2026 04:43:15 +0530 Subject: [PATCH] fix(javascript): bound symbolic property and member queries (#8876) Fixes #8838. Admit all symbolic query entry points through the existing resolver budget, preserve cache-hit depth and restore entered frames in finally. Retain independent facts with partial source coverage instead of losing entire scripts to recursive spreads. Validated 273 focused tests, check:fast/docs:check, real CLI/stdio-MCP projections and complete parent reads, independent public-source oracle, and unchanged full results for the official Electron control and Bitwarden selected preload. Full technical CI 42308068679 passes, including all eight shards and required aggregates. Greptile reviews this head at 5/5; no completed Codex review of this head is available. The Author readiness-only exception is explicitly authorized; no human-review attestation is added. Assisted-by: Reimu (gensokyo-skills:reimu-incident-triage) --- docs/javascript-application-graph.md | 7 + .../javascriptSymbolicBindings.test.ts | 77 ++++++++ .../javascript/javascriptSymbolicBindings.ts | 178 +++++++++++------- .../javascriptApplicationService.test.ts | 53 ++++++ 4 files changed, 244 insertions(+), 71 deletions(-) diff --git a/docs/javascript-application-graph.md b/docs/javascript-application-graph.md index b232d86b86..3b45dd0841 100644 --- a/docs/javascript-application-graph.md +++ b/docs/javascript-application-graph.md @@ -171,6 +171,13 @@ resource truncation. JavaScript summary `limitation_count` counts the full, deduplicated union of parent, application and semantic caveats, including messages whose delivery is omitted by the view's byte budget. +Symbolic property reads and member enumeration share the expression resolver's +existing resource budget, including recursive return spreads with fresh arguments. +A bounded source retains independent window, preload and endpoint facts while +reporting `parse_status: partial` and limited examination in `analysis_coverage`. +Unresolved recursive values remain unknown; selected views retain source coverage +separately from the completeness of the returned page. + Unavailable values are represented explicitly rather than as missing fields, so absence of evidence cannot silently become evidence of absence. diff --git a/src/domain/javascript/javascriptSymbolicBindings.test.ts b/src/domain/javascript/javascriptSymbolicBindings.test.ts index 170a6cf8fd..1c9df55055 100644 --- a/src/domain/javascript/javascriptSymbolicBindings.test.ts +++ b/src/domain/javascript/javascriptSymbolicBindings.test.ts @@ -954,3 +954,80 @@ it("keeps a possibly repeated factory's native descriptor value unknown", () => })();`); expect(value.kind).toBe("unknown"); }); + +it("bounds recursion when spreading a self-recursive call result and querying properties", () => { + const { resolver, values } = inspectFixture( + `function f(o) { const stay = f(o); return { ...stay, c: stay.sc.startsWith("k") }; } + inspect(f({}));`, + (bindings, node) => { + const obj = bindings.evaluate(node.arguments[0]); + const sc = bindings.property(obj, "sc"); + const c = bindings.property(obj, "c"); + const members = bindings.members(obj); + const unknownMembers = bindings.unknownMembers(obj); + expect(sc.kind).toBe("unknown"); + expect(c.kind).toBe("unknown"); + expect(members).toContain("c"); + expect(unknownMembers).toBeGreaterThan(0); + return obj; + }, + ); + expect(values).toHaveLength(1); + expect(resolver.truncated).toBe(true); +}); + +it("evaluates finite recursion across returned closures and object arguments to literal results", () => { + const closureResult = inspectOne(` + function make(flag) { return function step() { return flag ? { next: make(false)() } : { value: "end" }; }; } + inspect(make(true)().next.value); + `); + expect(closureResult).toMatchObject({ kind: "literal", value: "end" }); + + const stepResult = inspectOne(` + function f(step) { return step ? { next: f(step.next) } : { value: "end" }; } + inspect(f({ next: { next: null } }).next.next.value); + `); + expect(stepResult).toMatchObject({ kind: "literal", value: "end" }); +}); + +it("bounds recursion with fresh arguments while preserving own stable members", () => { + const { resolver, values } = inspectFixture( + `function grow(value) { return { ...grow({ value }), stable: 1 }; } + inspect(grow(null));`, + (bindings, node) => { + const obj = bindings.evaluate(node.arguments[0]); + const stable = bindings.property(obj, "stable"); + const members = bindings.members(obj); + const unknownMembers = bindings.unknownMembers(obj); + expect(stable).toMatchObject({ kind: "literal", value: 1 }); + expect(members).toContain("stable"); + expect(unknownMembers).toBeGreaterThan(0); + // A bounded recursive read must restore depth for a later independent read. + expect(bindings.property(obj, "stable")).toMatchObject({ + kind: "literal", + value: 1, + }); + return obj; + }, + ); + expect(values).toHaveLength(1); + expect(resolver.truncated).toBe(true); +}); + +it("keeps repeated cached queries within an independent depth budget", () => { + const { resolver, values } = inspectFixture( + 'const shared = { value: "end" }; inspect(shared);', + (bindings, node) => { + for (let index = 0; index < 256; index += 1) { + const value = bindings.property( + bindings.evaluate(node.arguments[0]), + "value", + ); + expect(value).toMatchObject({ kind: "literal", value: "end" }); + } + return bindings.evaluate(node.arguments[0]); + }, + ); + expect(values).toHaveLength(1); + expect(resolver.truncated).toBe(false); +}); diff --git a/src/domain/javascript/javascriptSymbolicBindings.ts b/src/domain/javascript/javascriptSymbolicBindings.ts index 01e6614485..52eb90f5aa 100644 --- a/src/domain/javascript/javascriptSymbolicBindings.ts +++ b/src/domain/javascript/javascriptSymbolicBindings.ts @@ -65,6 +65,8 @@ interface SymbolicReadBoundary { } type SymbolicNamespace = Extract; +const SYMBOLIC_DEPTH_LIMIT = 128; + const emptyEnvironment: SymbolicEnvironment = { values: new Map() }; const containsOwnedReturn = (root: t.Node): boolean => { @@ -210,15 +212,25 @@ export class JavaScriptSymbolicBindings { return emptyEnvironment; } + /** Shared admission; cache hits keep their existing depth-free fast path. */ + #admitEvaluation(): boolean { + if ( + --this.#remainingEvaluations < 0 || + this.#depth >= SYMBOLIC_DEPTH_LIMIT + ) { + this.truncated = true; + return false; + } + return true; + } + evaluate( input: t.Node | null | undefined, environment?: SymbolicEnvironment, ): SymbolicValue { if (!input) return { kind: "literal", value: undefined }; - if (--this.#remainingEvaluations < 0 || this.#depth >= 128) { - this.truncated = true; + if (!this.#admitEvaluation()) return symbolicUnknown("symbolic resource limit"); - } const node = unwrapJavaScriptExpression(input).node; const env = environment ?? this.environmentAt(node); const cache = this.#cache.get(env) ?? new WeakMap(); @@ -278,14 +290,21 @@ export class JavaScriptSymbolicBindings { key: string, read?: SymbolicPropertyRead, ): SymbolicValue { - let result: SymbolicValue; - if (value.kind === "union") - result = symbolicUnion([ - ...this.#commonProperty(value.values, key, read), - ...value.values.map((variant) => this.property(variant, key, read)), - ]); - else result = this.#property(value, key, read); - return this.#withConditions(result, value); + if (!this.#admitEvaluation()) + return symbolicUnknown("symbolic resource limit"); + this.#depth += 1; + try { + let result: SymbolicValue; + if (value.kind === "union") + result = symbolicUnion([ + ...this.#commonProperty(value.values, key, read), + ...value.values.map((variant) => this.property(variant, key, read)), + ]); + else result = this.#property(value, key, read); + return this.#withConditions(result, value); + } finally { + this.#depth -= 1; + } } #commonProperty( @@ -465,40 +484,51 @@ export class JavaScriptSymbolicBindings { active = new Set(), ): readonly string[] { if (active.has(value)) return []; + if (!this.#admitEvaluation()) return []; active.add(value); - if (value.kind === "union") - return [...new Set(value.values.flatMap((v) => this.members(v, active)))]; - if (value.kind === "record") return [...value.values.keys()]; - if (value.kind === "namespace") { - const available = this.#namespaceWrites(value); - if (available === undefined) return []; - const replacement = available.findLast( - (write) => write.path.length === 0, - ); - return replacement - ? this.members( - this.evaluate(replacement.value, value.environment), - active, - ) - : [ - ...new Set( - available.flatMap((w) => (w.path.length === 1 ? w.path : [])), - ), - ]; - } - if (value.kind !== "object") return []; - return [ - ...new Set( - value.node.properties.flatMap((p) => - t.isSpreadElement(p) - ? this.members(this.evaluate(p.argument, value.environment), active) - : [semanticStaticPropertyKey(p.key, p.computed)].filter( - (k): k is string => - k !== null && !isObjectPrototypeSetter(p, k), + this.#depth += 1; + try { + if (value.kind === "union") + return [ + ...new Set(value.values.flatMap((v) => this.members(v, active))), + ]; + if (value.kind === "record") return [...value.values.keys()]; + if (value.kind === "namespace") { + const available = this.#namespaceWrites(value); + if (available === undefined) return []; + const replacement = available.findLast( + (write) => write.path.length === 0, + ); + return replacement + ? this.members( + this.evaluate(replacement.value, value.environment), + active, + ) + : [ + ...new Set( + available.flatMap((w) => (w.path.length === 1 ? w.path : [])), ), + ]; + } + if (value.kind !== "object") return []; + return [ + ...new Set( + value.node.properties.flatMap((p) => + t.isSpreadElement(p) + ? this.members( + this.evaluate(p.argument, value.environment), + active, + ) + : [semanticStaticPropertyKey(p.key, p.computed)].filter( + (k): k is string => + k !== null && !isObjectPrototypeSetter(p, k), + ), + ), ), - ), - ]; + ]; + } finally { + this.#depth -= 1; + } } unknownMembers( @@ -506,39 +536,45 @@ export class JavaScriptSymbolicBindings { active = new Set(), ): number { if (active.has(value)) return 1; + if (!this.#admitEvaluation()) return 1; active.add(value); - if (value.kind === "union") - return value.values.reduce( - (sum, v) => sum + this.unknownMembers(v, active), + this.#depth += 1; + try { + if (value.kind === "union") + return value.values.reduce( + (sum, v) => sum + this.unknownMembers(v, active), + 0, + ); + if (value.kind === "record") return 0; + if (value.kind === "namespace") { + if (this.#mutated.has(value.bindingId)) return 1; + const available = this.#namespaceWrites(value); + if (available === undefined) return 1; + const replacement = available.findLast( + (write) => write.path.length === 0, + ); + return replacement + ? this.unknownMembers( + this.evaluate(replacement.value, value.environment), + active, + ) + : 0; + } + if (value.kind !== "object") return 1; + return value.node.properties.reduce( + (sum, p) => + sum + + (t.isSpreadElement(p) + ? this.unknownMembers( + this.evaluate(p.argument, value.environment), + new Set(active), + ) + : Number(semanticStaticPropertyKey(p.key, p.computed) === null)), 0, ); - if (value.kind === "record") return 0; - if (value.kind === "namespace") { - if (this.#mutated.has(value.bindingId)) return 1; - const available = this.#namespaceWrites(value); - if (available === undefined) return 1; - const replacement = available.findLast( - (write) => write.path.length === 0, - ); - return replacement - ? this.unknownMembers( - this.evaluate(replacement.value, value.environment), - active, - ) - : 0; + } finally { + this.#depth -= 1; } - if (value.kind !== "object") return 1; - return value.node.properties.reduce( - (sum, p) => - sum + - (t.isSpreadElement(p) - ? this.unknownMembers( - this.evaluate(p.argument, value.environment), - new Set(active), - ) - : Number(semanticStaticPropertyKey(p.key, p.computed) === null)), - 0, - ); } /** Calling a generator creates an iterator; advancing it is not modeled. */ diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts index 9bff3d5f09..677ec2631f 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts @@ -555,3 +555,56 @@ describe("JavaScript application cleanup diagnostics", () => { }); }); }); + +describe("JavaScript application symbolic coverage", () => { + it("analyzes sources spreading recursive call results while preserving coverage limits and independent endpoints", async () => { + const root = await createTestTempDirectory("rea-js-recursive-spread-"); + await writeFile( + join(root, "self.js"), + 'function f(o) { const stay = f(o); return { ...stay, c: stay.sc.startsWith("k") }; }\nfetch("/kept-self");\n', + ); + await writeFile( + join(root, "mutual.js"), + 'function a(o) { const stay = b(o); return { ...stay, c: stay.sc.startsWith("k") }; }\nfunction b(o) { const stay = a(o); return { ...stay, c: stay.sc.startsWith("k") }; }\nfetch("/kept-mutual");\n', + ); + const result = await analyzeJavaScriptApplication({ + input_path: root, + format: "directory", + source_paths: ["self.js", "mutual.js"], + }); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + const normalized = javascriptApplicationAnalysisResultSchema.parse( + result.value.normalized_result, + ); + expect(normalized.statistics).toMatchObject({ + parsed_javascript_files: 2, + parse_failures: 0, + }); + const endpoints = normalized.graph.nodes + .filter(({ kind }) => kind === "endpoint") + .flatMap(({ observations }) => + observations.map(({ properties }) => properties.value), + ) + .sort(); + expect(endpoints).toEqual(["/kept-mutual", "/kept-self"]); + + const assetObservations = normalized.graph.nodes + .filter(({ kind }) => kind === "javascript-asset") + .flatMap(({ observations }) => observations); + for (const path of ["self.js", "mutual.js"]) { + const observation = assetObservations.find( + ({ properties }) => properties.path === path, + ); + expect(observation?.properties).toMatchObject({ + parse_status: "partial", + analysis_coverage: { + examination: { + status: "limited", + electron_call_paths_limited: true, + }, + }, + }); + } + }); +});