From 0c7b6d834af2d044b23196e0e522cc2468327a1e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:58:10 +0800 Subject: [PATCH] fix(javascript): bound local getter-result expansion Charge inner alias and branch work to the existing getter query budget before allocating queued states. Widen local suffixes during fallback so a single getter cannot reintroduce path permutations. Validation: two new class/object controls fail on the baseline; 411 tests across eight affected files pass. Public CLI baseline/candidate controls preserve unrelated observations and expose intended partial resource coverage. Typecheck/lint, docs and formatting pass. Diagnostic costs retained; no speed or token reduction claim. This fixes a separate getter gap and does not resolve the getter-free Qodo input. Refs #3839. --- ...tSemanticAnalysis.getter_expansion.test.ts | 30 ++++++++++++ .../javascriptSemanticGetterQueries.ts | 21 ++++++-- .../javascriptSemanticGetterResults.ts | 48 ++++++++++++++----- .../javascriptSemanticResourceLimits.ts | 4 +- 4 files changed, 83 insertions(+), 20 deletions(-) diff --git a/src/domain/javascript/javascriptSemanticAnalysis.getter_expansion.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.getter_expansion.test.ts index d353f8f48a..e372385d60 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.getter_expansion.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.getter_expansion.test.ts @@ -92,3 +92,33 @@ it("escapes constructed fields when a getter query widens its suffix", () => { expect(ir.coverage.resourceLimits).toEqual(["getter-expansion"]); expectRetainedFacts(ir); }); + +it.each(["class", "object"] as const)( + "bounds branching local aliases inside one %s getter", + (receiver) => { + const aliases = Array.from( + { length: 10 }, + (_, index) => + `const a${index + 1} = flag ? a${index}.left : a${index}.right;`, + ).join("\n"); + const getter = `get state() { const a0 = this; ${aliases} return a10; }`; + const definition = + receiver === "class" + ? `class Request { slot = shared; ${getter} } const request = new Request();` + : `const request = { slot: shared, ${getter} };`; + const ir = analyzeJavaScriptSemantics(` + import imported from "dep"; + const shared = { value: 1 }; + ${definition} + consume(request.state); + const result = shared.value; + const unrelated = 42; + `); + expect(ir.coverage).toMatchObject({ + status: "partial", + resourceLimits: ["getter-expansion"], + }); + expect(onlyBinding(ir, "result").value.status).toBe("unknown"); + expectRetainedFacts(ir); + }, +); diff --git a/src/domain/javascript/javascriptSemanticGetterQueries.ts b/src/domain/javascript/javascriptSemanticGetterQueries.ts index 5836b561d0..904e7b65d2 100644 --- a/src/domain/javascript/javascriptSemanticGetterQueries.ts +++ b/src/domain/javascript/javascriptSemanticGetterQueries.ts @@ -64,6 +64,21 @@ export class SemanticGetterQuery { constructor(readonly state: JavaScriptSemanticAnalysisState) {} + get widening(): boolean { + return this.#fallback; + } + + /** Charge retained local work and getter jobs to the same precise query. */ + reserveResult(): boolean { + if (this.#limited) return false; + if (!this.#fallback && this.#visits >= SEMANTIC_GETTER_EXPANSION_LIMIT) { + this.#limited = true; + return false; + } + this.#visits++; + return true; + } + retain(reference: Reference, escapes = false): void { // Direct fields keep their write suffix; only getter jobs widen omitted paths. this.#references.add({ @@ -85,11 +100,7 @@ export class SemanticGetterQuery { const paths = receivers?.get(receiver); const key = JSON.stringify([path, escapes]); if (paths?.has(key)) return true; - if (!this.#fallback && this.#visits >= SEMANTIC_GETTER_EXPANSION_LIMIT) { - this.#limited = true; - return true; - } - this.#visits++; + if (!this.reserveResult()) return true; const selections = paths ?? new Set(); const owners = receivers ?? new WeakMap>(); selections.add(key); diff --git a/src/domain/javascript/javascriptSemanticGetterResults.ts b/src/domain/javascript/javascriptSemanticGetterResults.ts index 4417541c4a..d2327c480c 100644 --- a/src/domain/javascript/javascriptSemanticGetterResults.ts +++ b/src/domain/javascript/javascriptSemanticGetterResults.ts @@ -75,7 +75,7 @@ const resultBranches = (node: t.Node): readonly t.Node[] | undefined => { /** Each walk owns local aliases; getter-to-getter traversal belongs to the query worklist. */ class GetterResultWalker { - readonly #pending: GetterWork[]; + readonly #pending: GetterWork[] = []; readonly #aliases = new Map>(); readonly #scope: string | undefined; @@ -84,9 +84,10 @@ class GetterResultWalker { readonly query: SemanticGetterQuery, readonly resolvers: GetterResultResolvers, ) { - this.#pending = semanticCallableResultExpressions( + for (const { node } of semanticCallableResultExpressions( job.invocation.getter, - ).flatMap(({ node }) => (node === null ? [] : [{ node, path: job.path }])); + )) + if (node !== null) this.#enqueue(() => ({ node, path: job.path })); this.#scope = query.state.scopeByNode.get(job.invocation.getter)?.scopeId; } @@ -97,6 +98,25 @@ class GetterResultWalker { } } + #enqueue(create: () => GetterWork): void { + if (!this.query.reserveResult()) return; + const work = create(); + // The fallback escapes receiver roots. Keeping local suffix permutations + // here would repeat the precise expansion inside a single getter. + this.#pending.push( + this.query.widening + ? { + ...work, + path: [], + escapes: true, + ...(work.privateRead === undefined + ? {} + : { privateRead: { ...work.privateRead, path: [] } }), + } + : work, + ); + } + #retain(current: GetterWork, reference: Reference): void { this.query.retain(reference, current.escapes || this.job.escapes); } @@ -149,12 +169,12 @@ class GetterResultWalker { if (previous !== undefined) { // Growing aliases can reach arbitrarily deep children of the same roots. if (current.path.length > previous.pathLength && !current.escapes) - this.#pending.push({ + this.#enqueue(() => ({ ...current, path: [], aliases: undefined, escapes: true, - }); + })); return true; } const key = JSON.stringify([ @@ -195,12 +215,12 @@ class GetterResultWalker { (origin) => !semanticReferenceSourcesInactive(origin, state), ), ]) - this.#pending.push({ + this.#enqueue(() => ({ ...current, node: initializer.node, path: [...initializer.projection, ...current.path], aliases, - }); + })); } #ownsThis(expression: t.ThisExpression): boolean { @@ -305,27 +325,28 @@ class GetterResultWalker { t.isPrivateName(expression.property) && t.isClass(this.job.invocation.owner) ) { - this.#pending.push({ + const name = expression.property.id.name; + this.#enqueue(() => ({ ...current, node: expression.object, path: [], privateRead: { node: expression, - name: expression.property.id.name, + name, path: current.path, }, - }); + })); return; } if (!this.#superValue(current, expression)) - this.#pending.push({ + this.#enqueue(() => ({ ...current, node: expression.object, path: [ semanticStaticPropertyKey(expression.property, expression.computed), ...current.path, ], - }); + })); } #visit(current: GetterWork): void { @@ -340,7 +361,8 @@ class GetterResultWalker { else { const branches = resultBranches(expression); if (branches !== undefined) - for (const node of branches) this.#pending.push({ ...current, node }); + for (const node of branches) + this.#enqueue(() => ({ ...current, node })); else if (current.privateRead !== undefined) this.#privateRead(current); else this.#retain(current, { node: expression, path: current.path }); } diff --git a/src/domain/javascript/javascriptSemanticResourceLimits.ts b/src/domain/javascript/javascriptSemanticResourceLimits.ts index adbd43d124..52f836da24 100644 --- a/src/domain/javascript/javascriptSemanticResourceLimits.ts +++ b/src/domain/javascript/javascriptSemanticResourceLimits.ts @@ -7,7 +7,7 @@ import type { export const SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT = 256; /** Maximum active expression nesting during inert semantic evaluation. */ export const SEMANTIC_EXPRESSION_DEPTH_LIMIT = 256; -/** Distinct getter, receiver and path states retained in one precise query. */ +/** Getter jobs and local result work retained in one precise query. */ export const SEMANTIC_GETTER_EXPANSION_LIMIT = 512; /** Bound normalized strings and transient keys independently of transport limits. */ export const SEMANTIC_PRIMITIVE_JSON_BYTES_LIMIT = 1_048_576; @@ -20,7 +20,7 @@ const RESOURCE_LIMIT_REASON: Record = { "expression-depth": "Semantic expression depth budget exceeded (maximum 256 nested expressions).", "getter-expansion": - "Getter expansion was conservatively bounded (maximum 512 distinct states or a growing receiver cycle).", + "Getter expansion was conservatively bounded (maximum 512 scheduled states or a growing receiver cycle).", }; /** Check UTF-16 length against worst-case JSON escaping without flattening. */