Files
rea/src/domain/javascript/javascriptSemanticGetterQueries.ts
T
morluto 0c7b6d834a 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.
2026-10-17 06:58:19 +08:00

197 lines
6.3 KiB
TypeScript

import type * as t from "@babel/types";
import type { JavaScriptSemanticObjectReference as Reference } from "./javascriptSemanticObjectReferences.js";
import type { JavaScriptSemanticPropertyPath as PropertyPath } from "./javascriptSemanticPropertyPaths.js";
import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js";
import { SEMANTIC_GETTER_EXPANSION_LIMIT } from "./javascriptSemanticResourceLimits.js";
export interface GetterInvocation {
readonly getter: t.ObjectMethod | t.ClassMethod;
readonly owner: t.ObjectExpression | t.ClassDeclaration | t.ClassExpression;
readonly receiver?: GetterInvocation["owner"];
}
interface GetterAncestor {
readonly getter: t.Node;
readonly receiver: t.Node;
readonly length: number;
readonly parent?: GetterAncestor;
}
export interface GetterQueryJob {
readonly invocation: GetterInvocation;
readonly path: PropertyPath;
readonly escapes: boolean;
readonly ancestry: GetterAncestor;
}
/** References at different receiver allocations must not erase ownership. */
export class SemanticObjectReferenceSet {
readonly #nodes = new Map<t.Node, Map<string, Reference>>();
readonly #owners = new WeakMap<t.Node, number>();
#ownerCount = 0;
add(reference: Reference): void {
const owner = reference.ownerInitializer;
if (owner !== undefined && !this.#owners.has(owner))
this.#owners.set(owner, ++this.#ownerCount);
// Escaping a primitive cannot cover a write to its slot; retain both effects.
const key = JSON.stringify([
reference.path,
owner === undefined ? null : this.#owners.get(owner),
reference.escapes === true,
reference.widenedGetterResult === true,
]);
const paths =
this.#nodes.get(reference.node) ?? new Map<string, Reference>();
paths.set(key, reference);
this.#nodes.set(reference.node, paths);
}
values(): readonly Reference[] {
return [...this.#nodes.values()].flatMap((paths) => [...paths.values()]);
}
}
/** One syntax query owns its memo and worklist; no receiver evaluation is cached across mutations. */
export class SemanticGetterQuery {
#memo = new WeakMap<t.Node, WeakMap<t.Node, Set<string>>>();
#references = new SemanticObjectReferenceSet();
#pending: GetterQueryJob[] = [];
#current: GetterQueryJob | undefined;
#limited = false;
#visits = 0;
#fallback = false;
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({
...reference,
...(escapes ? { escapes: true } : {}),
...(this.#fallback && this.#current !== undefined
? { widenedGetterResult: true }
: {}),
});
}
#known(
invocation: GetterInvocation,
path: PropertyPath,
escapes: boolean,
): boolean {
const receiver = invocation.receiver ?? invocation.owner;
const receivers = this.#memo.get(invocation.getter);
const paths = receivers?.get(receiver);
const key = JSON.stringify([path, escapes]);
if (paths?.has(key)) return true;
if (!this.reserveResult()) return true;
const selections = paths ?? new Set<string>();
const owners = receivers ?? new WeakMap<t.Node, Set<string>>();
selections.add(key);
owners.set(receiver, selections);
this.#memo.set(invocation.getter, owners);
return false;
}
#ancestor(invocation: GetterInvocation): GetterAncestor | undefined {
const receiver = invocation.receiver ?? invocation.owner;
for (
let visit = this.#current?.ancestry;
visit !== undefined;
visit = visit.parent
)
if (visit.getter === invocation.getter && visit.receiver === receiver)
return visit;
return undefined;
}
#grows(invocation: GetterInvocation, path: PropertyPath): boolean {
if (this.#fallback) return false;
const previous = this.#ancestor(invocation);
if (previous === undefined || previous.length >= path.length) return false;
// Pruning by ancestry would make memo results depend on the first caller.
// Widen growing cycles; identical finite cycles are handled by the memo alone.
this.#limited = true;
return true;
}
schedule(
invocation: GetterInvocation,
path: PropertyPath,
escaped = false,
): void {
if (this.#limited) return;
const escapes =
escaped || this.#fallback || this.#current?.escapes === true;
const selection = this.#fallback ? [] : path;
if (this.#grows(invocation, selection)) return;
if (this.#known(invocation, selection, escapes)) return;
this.#pending.push({
invocation,
path: selection,
escapes,
ancestry: {
getter: invocation.getter,
receiver: invocation.receiver ?? invocation.owner,
length: selection.length,
...(this.#current === undefined
? {}
: { parent: this.#current.ancestry }),
},
});
}
#collect(
root: () => readonly Reference[],
expand: (job: GetterQueryJob) => void,
): void {
for (const reference of root()) this.retain(reference);
for (
let cursor = 0;
cursor < this.#pending.length && !this.#limited;
cursor++
) {
const job = this.#pending[cursor];
if (job === undefined) break;
this.#current = job;
expand(job);
}
this.#current = undefined;
}
run(
root: () => readonly Reference[],
expand: (job: GetterQueryJob) => void,
): readonly Reference[] {
this.#collect(root, expand);
if (this.#limited) {
this.state.getterResourceLimits.add("getter-expansion");
// Escaping each selected getter's result covers every omitted suffix.
// Canonical empty suffixes make the retry finite in receiver/getter pairs.
this.#fallback = true;
this.#limited = false;
this.#memo = new WeakMap();
this.#references = new SemanticObjectReferenceSet();
this.#pending = [];
this.#collect(root, expand);
}
return this.#references.values();
}
}