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, + }, + }, + }); + } + }); +});