diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts index c3e20b4c75..0657107216 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts @@ -22,6 +22,7 @@ import { } from "../../domain/javascript/javascriptSemanticAnalysis.js"; import { parseJavaScriptSource } from "../../domain/javascript/javascriptSourceParser.js"; import { semanticCoverageResourceLimits } from "../../domain/javascript/javascriptSemanticCoverage.js"; +import { getterExpansionSource } from "../../../tests/fixtures/javascriptGetterExpansion.js"; const SHA256 = "a".repeat(64); @@ -177,13 +178,15 @@ it("preserves primitive candidate budget unknowns in semantic graph coverage", ( status: "partial", resourceLimits: ["primitive-candidates"], }); - expect(graph.unknowns).toContainEqual( + expect( + graph.unknowns.filter(({ reason }) => reason === "resource-limit"), + ).toEqual([ expect.objectContaining({ family: "data-flow", reason: "resource-limit", detail: expect.stringMatching(/primitive candidate budget exceeded/i), }), - ); + ]); }); it("preserves derived string-byte limit reasons in semantic graph unknowns", () => { @@ -242,12 +245,14 @@ it("preserves deep expression resource reasons through graph projection", () => expect(semanticCoverageResourceLimits(ir.coverage)).toEqual([ "expression-depth", ]); - expect(graph.unknowns).toContainEqual( + expect( + graph.unknowns.filter(({ reason }) => reason === "resource-limit"), + ).toEqual([ expect.objectContaining({ reason: "resource-limit", detail: expect.stringMatching(/expression depth budget exceeded/i), }), - ); + ]); }); it("keeps ambiguous interprocedural flow out of the default traversal", () => { @@ -473,6 +478,26 @@ const semanticNodeFor = (roleKey: string) => { ); }; +it("preserves getter expansion limits in semantic graph coverage", () => { + const source = getterExpansionSource(); + const graph = graphFor(source, analyzeJavaScriptSemantics(source)); + const module = graph.nodes.find(({ kind }) => kind === "module"); + if (module === undefined) throw new Error("Expected module node"); + expect( + graph.unknowns.filter(({ reason }) => reason === "resource-limit"), + ).toEqual([ + expect.objectContaining({ + node_id: module.node_id, + family: "data-flow", + reason: "resource-limit", + detail: expect.stringMatching( + /getter expansion was conservatively bounded/i, + ), + }), + ]); + expect(graph.coverage.status).toBe("unknown"); +}); + it.each([ "const value = consume(Promise.resolve(1));", "async function run() { await consume(Promise.resolve(1)); }", diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts index 647b350d3a..07061943a9 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts @@ -34,8 +34,16 @@ import { import { projectSemanticResources } from "./JavaScriptSemanticGraphResourceProjection.js"; import { projectSemanticObjects } from "./JavaScriptSemanticGraphObjectProjection.js"; import { projectSemanticValues } from "./JavaScriptSemanticGraphValueProjection.js"; -import { inferredSemanticEvidenceAt } from "./JavaScriptSemanticGraphEvidence.js"; -import { observedSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js"; +import { + inferredSemanticEvidenceAt, + observedSemanticEvidence, + unknownSemanticEvidence, +} from "./JavaScriptSemanticGraphEvidence.js"; +import { + semanticCoverageResourceLimits, + semanticResourceLimitCoverage, +} from "../../domain/javascript/javascriptSemanticCoverage.js"; +import { semanticResourceLimitReason } from "../../domain/javascript/javascriptSemanticResourceLimits.js"; import { partialApplicationCoverage, type ApplicationCoverage, @@ -465,6 +473,7 @@ function* projectFileSteps( }); if (moduleNode === null) return []; state.roots.add(moduleNode.node_id); + projectFileResourceLimits({ file, ir, state, moduleNode }); yield* projectElectronSemanticCallPaths(file, paths, state); // Node creation keeps source order, which the per-file node budget relies // on; yields between nodes do not change it. @@ -553,6 +562,36 @@ function* projectFileSteps( ); } +const projectFileResourceLimits = ( + context: Pick, +): void => { + // Getter limits describe query effects without tagged values; other limits + // already project from binding or return-site values and must not be duplicated. + for (const limit of semanticCoverageResourceLimits(context.ir.coverage)) { + if (limit !== "getter-expansion") continue; + const unknown = createJavaScriptSemanticGraphUnknown( + { + node_id: context.moduleNode.node_id, + family: "data-flow", + relation_kinds: ["defines"], + reason: "resource-limit", + detail: semanticResourceLimitReason(limit), + candidate_node_ids: [], + evidence: { + ...unknownSemanticEvidence(context.file, null), + coverage: partialApplicationCoverage( + semanticResourceLimitCoverage([limit]), + null, + ), + limitations: [semanticResourceLimitReason(limit)], + }, + }, + context.state.evidenceContexts, + ); + context.state.unknowns.set(unknown.unknown_id, unknown); + } +}; + function* createReturnSiteNodes( file: JavaScriptArtifactFile, ir: JavaScriptSemanticIr, diff --git a/src/domain/javascript/javascriptSemanticAnalysis.getter_expansion.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.getter_expansion.test.ts new file mode 100644 index 0000000000..d353f8f48a --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.getter_expansion.test.ts @@ -0,0 +1,94 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { onlyBinding } from "./javascriptSemanticAnalysis.fixture.js"; + +const permutationSource = (count: number, isStatic: boolean) => { + const getterPrefix = isStatic ? "static " : ""; + const access = isStatic ? "Request.field0" : "new Request().field0"; + const getters = Array.from( + { length: count }, + (_, index) => + `${getterPrefix}get field${index}() { return this[key].field${index}; }`, + ).join("\n"); + return ` + import imported from "dep"; + const shared = { value: 1 }; + class Request { + ${getters} + ${getterPrefix}get state() { return shared; } + } + consume(${access}); + const result = shared.value; + const unrelated = 42; + `; +}; + +const expectRetainedFacts = ( + ir: ReturnType, +) => { + expect(onlyBinding(ir, "unrelated").value).toEqual({ + status: "literal", + value: 42, + }); + expect(onlyBinding(ir, "imported").provenance.status).toBe("module"); + expect(ir.moduleLinks).toContainEqual( + expect.objectContaining({ kind: "import", specifier: "dep" }), + ); +}; + +it.each([false, true])( + "bounds unknown-key %s getter permutations and preserves unrelated facts", + (isStatic) => { + const ir = analyzeJavaScriptSemantics(permutationSource(10, isStatic)); + expect(ir.coverage).toMatchObject({ + status: "partial", + omittedCount: null, + resourceLimits: ["getter-expansion"], + }); + expect(onlyBinding(ir, "result").value.status).toBe("unknown"); + expect(onlyBinding(ir, "result").provenance).toMatchObject({ + status: "local", + origins: [], + }); + expect(ir.limitations.join(" ")).toMatch( + /Getter expansion was conservatively bounded/, + ); + expectRetainedFacts(ir); + }, +); + +it("keeps direct field writes through an escaped growing getter result", () => { + const ir = analyzeJavaScriptSemantics(` + import imported from "dep"; + const shared = { x: 1 }; + class Request { + slot = shared; + get grow() { return this.grow.more; } + } + new Request()[key].x = 2; + const result = shared.x; + const unrelated = 42; + `); + expect(onlyBinding(ir, "result").value.status).toBe("unknown"); + expect(ir.coverage.resourceLimits).toEqual(["getter-expansion"]); + expectRetainedFacts(ir); +}); + +it("escapes constructed fields when a getter query widens its suffix", () => { + const ir = analyzeJavaScriptSemantics(` + import imported from "dep"; + const shared = { value: 1 }; + class Inner { alias = shared; } + class Outer { + get result() { return new Inner(); } + get grow() { return this.grow.more; } + } + new Outer()[key].alias.value = 2; + const result = shared.value; + const unrelated = 42; + `); + expect(onlyBinding(ir, "result").value.status).toBe("unknown"); + expect(ir.coverage.resourceLimits).toEqual(["getter-expansion"]); + expectRetainedFacts(ir); +}); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index ee98affa79..83c4c144f3 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -66,6 +66,7 @@ import { } from "./javascriptElectronMemberWrites.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; import { semanticCoverage } from "./javascriptSemanticCoverage.js"; +import { semanticResourceLimitReason } from "./javascriptSemanticResourceLimits.js"; import { parseJavaScriptSource, type ParsedJavaScriptSource, @@ -200,9 +201,17 @@ export function* analyzeParsedJavaScriptSemanticsSteps( ...derived, coverage: semanticCoverage( parserPartial, - [...state.valueResourceLimits].sort(), + [ + ...new Set([ + ...state.valueResourceLimits, + ...state.getterResourceLimits, + ]), + ].sort(), ), limitations: [ + ...[...state.getterResourceLimits] + .sort() + .map(semanticResourceLimitReason), ...(parserPartial ? [ "The parser recovered from syntax errors; affected bindings are partial.", @@ -698,6 +707,7 @@ const createState = ( program, staticImportResolutionMode, valueResourceLimits: new Set(), + getterResourceLimits: new Set(), parentsByNode: new WeakMap(), scopes: [root], scopesById: new Map([[root.scopeId, root]]), diff --git a/src/domain/javascript/javascriptSemanticCoverage.ts b/src/domain/javascript/javascriptSemanticCoverage.ts index c40edc03a8..143f8aa09a 100644 --- a/src/domain/javascript/javascriptSemanticCoverage.ts +++ b/src/domain/javascript/javascriptSemanticCoverage.ts @@ -2,6 +2,7 @@ import type { JavaScriptSemanticResourceLimit } from "./javascriptSemanticValueT import type { ApplicationCoverage } from "./javascriptApplicationEvidenceSchemas.js"; import { SEMANTIC_EXPRESSION_DEPTH_LIMIT, + SEMANTIC_GETTER_EXPANSION_LIMIT, SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT, SEMANTIC_PRIMITIVE_JSON_BYTES_LIMIT, } from "./javascriptSemanticResourceLimits.js"; @@ -62,6 +63,10 @@ const semanticResourceBounds: Record< value: SEMANTIC_EXPRESSION_DEPTH_LIMIT, unit: "depth", }, + "getter-expansion": { + value: SEMANTIC_GETTER_EXPANSION_LIMIT, + unit: "items", + }, }; /** Map semantic evaluator bounds to their caller-visible application coverage. */ diff --git a/src/domain/javascript/javascriptSemanticGetterQueries.ts b/src/domain/javascript/javascriptSemanticGetterQueries.ts new file mode 100644 index 0000000000..5836b561d0 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticGetterQueries.ts @@ -0,0 +1,185 @@ +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>(); + readonly #owners = new WeakMap(); + #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(); + 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>>(); + #references = new SemanticObjectReferenceSet(); + #pending: GetterQueryJob[] = []; + #current: GetterQueryJob | undefined; + #limited = false; + #visits = 0; + #fallback = false; + + constructor(readonly state: JavaScriptSemanticAnalysisState) {} + + 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.#fallback && this.#visits >= SEMANTIC_GETTER_EXPANSION_LIMIT) { + this.#limited = true; + return true; + } + this.#visits++; + const selections = paths ?? new Set(); + const owners = receivers ?? new WeakMap>(); + 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(); + } +} diff --git a/src/domain/javascript/javascriptSemanticGetterResults.ts b/src/domain/javascript/javascriptSemanticGetterResults.ts new file mode 100644 index 0000000000..4417541c4a --- /dev/null +++ b/src/domain/javascript/javascriptSemanticGetterResults.ts @@ -0,0 +1,354 @@ +import * as t from "@babel/types"; +import { + unwrapJavaScriptExpression, + semanticStaticPropertyKey, +} from "./javascriptAstValues.js"; +import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js"; +import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; +import { semanticReferenceSourcesInactive } from "./javascriptSemanticReferenceSources.js"; +import { + resolveSemanticBindingState, + type JavaScriptSemanticBindingState, +} from "./javascriptSemanticState.js"; +import type { JavaScriptSemanticObjectReference as Reference } from "./javascriptSemanticObjectReferences.js"; +import type { JavaScriptSemanticPropertyPath as PropertyPath } from "./javascriptSemanticPropertyPaths.js"; +import type { + GetterQueryJob, + SemanticGetterQuery, +} from "./javascriptSemanticGetterQueries.js"; + +type ClassNode = t.ClassDeclaration | t.ClassExpression; +interface AliasVisit { + readonly identity: string; + readonly pathLength: number; + readonly parent?: AliasVisit; +} +interface GetterWork { + readonly node: t.Node; + readonly path: PropertyPath; + readonly aliases?: AliasVisit | undefined; + readonly escapes?: true; + readonly privateRead?: { + readonly node: t.MemberExpression | t.OptionalMemberExpression; + readonly name: string; + readonly path: PropertyPath; + }; +} +export interface GetterResultResolvers { + readonly classReferences: ( + lookup: { + readonly owner: ClassNode; + readonly receiver?: ClassNode; + readonly kind: "static" | "instance" | "prototype"; + }, + path: PropertyPath, + escapes: boolean, + ) => readonly Reference[]; + readonly objectReferences: ( + receiver: t.ObjectExpression, + path: PropertyPath, + ) => readonly Reference[]; + readonly privateReceiver: ( + receiver: ClassNode, + owner: ClassNode, + path: PropertyPath, + isStatic: boolean, + ) => boolean; + readonly bases: (node: t.Node) => readonly ClassNode[]; +} + +/** Branch syntax contributes alternatives without evaluating application code. */ +const resultBranches = (node: t.Node): readonly t.Node[] | undefined => { + if (t.isConditionalExpression(node)) return [node.consequent, node.alternate]; + if (t.isLogicalExpression(node)) return [node.left, node.right]; + if (t.isSequenceExpression(node)) { + const last = node.expressions.at(-1); + return last === undefined ? [] : [last]; + } + if (t.isAssignmentExpression(node)) { + if (node.operator === "=") return [node.right]; + if (["&&=", "||=", "??="].includes(node.operator)) + return [node.left, node.right]; + } + return undefined; +}; + +/** Each walk owns local aliases; getter-to-getter traversal belongs to the query worklist. */ +class GetterResultWalker { + readonly #pending: GetterWork[]; + readonly #aliases = new Map>(); + readonly #scope: string | undefined; + + constructor( + readonly job: GetterQueryJob, + readonly query: SemanticGetterQuery, + readonly resolvers: GetterResultResolvers, + ) { + this.#pending = semanticCallableResultExpressions( + job.invocation.getter, + ).flatMap(({ node }) => (node === null ? [] : [{ node, path: job.path }])); + this.#scope = query.state.scopeByNode.get(job.invocation.getter)?.scopeId; + } + + run(): void { + for (let cursor = 0; cursor < this.#pending.length; cursor++) { + const current = this.#pending[cursor]; + if (current !== undefined) this.#visit(current); + } + } + + #retain(current: GetterWork, reference: Reference): void { + this.query.retain(reference, current.escapes || this.job.escapes); + } + + #privateRead(current: GetterWork): void { + if (current.privateRead !== undefined) + this.#retain(current, { + node: current.privateRead.node, + path: current.escapes ? [] : current.privateRead.path, + }); + } + + #localBinding(expression: t.Identifier) { + const state = this.query.state; + const binding = resolveSemanticBindingState( + state, + expression, + expression.name, + ); + let scope = + binding === undefined ? undefined : state.scopesById.get(binding.scopeId); + while (scope !== undefined && scope.scopeId !== this.#scope) { + if (scope.kind === "function" || scope.parentScopeId === null) + return undefined; + scope = state.scopesById.get(scope.parentScopeId); + } + return scope !== undefined && + binding?.referenceInitializers.every( + (initializer) => initializer.copyKind === undefined, + ) + ? binding + : undefined; + } + + #cycle(current: GetterWork, identity: string): AliasVisit | undefined { + for (let visit = current.aliases; visit !== undefined; visit = visit.parent) + if (visit.identity === identity) return visit; + return undefined; + } + + #alias(current: GetterWork, expression: t.Identifier): boolean { + const binding = this.#localBinding(expression); + if (binding === undefined) return false; + const identity = JSON.stringify([ + binding.bindingId, + expression.start, + expression.end, + ]); + const previous = this.#cycle(current, identity); + 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({ + ...current, + path: [], + aliases: undefined, + escapes: true, + }); + return true; + } + const key = JSON.stringify([ + current.path, + current.privateRead?.name, + current.privateRead?.path, + current.escapes, + ]); + const known = this.#aliases.get(identity) ?? new Set(); + if (!known.has(key)) { + known.add(key); + this.#aliases.set(identity, known); + this.#aliasOrigins(current, expression, binding, identity); + } + return true; + } + + #aliasOrigins( + current: GetterWork, + expression: t.Identifier, + binding: JavaScriptSemanticBindingState, + identity: string, + ): void { + const state = this.query.state; + const { binding: origins } = semanticBindingAtRead( + binding, + expression, + state.parentsByNode, + ); + const aliases: AliasVisit = { + identity, + pathLength: current.path.length, + ...(current.aliases === undefined ? {} : { parent: current.aliases }), + }; + for (const initializer of [ + ...origins.initializers, + ...origins.referenceInitializers.filter( + (origin) => !semanticReferenceSourcesInactive(origin, state), + ), + ]) + this.#pending.push({ + ...current, + node: initializer.node, + path: [...initializer.projection, ...current.path], + aliases, + }); + } + + #ownsThis(expression: t.ThisExpression): boolean { + let owner = this.query.state.parentsByNode.get(expression); + while ( + owner !== undefined && + !t.isClass(owner) && + !(t.isFunction(owner) && !t.isArrowFunctionExpression(owner)) + ) + owner = this.query.state.parentsByNode.get(owner); + return owner === this.job.invocation.getter; + } + + #privateValue(current: GetterWork, owner: ClassNode): void { + const read = current.privateRead; + if (read === undefined) return; + const { getter, receiver = owner } = this.job.invocation; + const isStatic = t.isClassMethod(getter) && getter.static; + if ( + !t.isClass(receiver) || + !this.resolvers.privateReceiver(receiver, owner, current.path, isStatic) + ) { + this.#privateRead(current); + return; + } + for (const member of owner.body.body) + if ( + t.isClassPrivateProperty(member) && + member.static === isStatic && + member.key.id.name === read.name && + member.value != null + ) + this.#retain(current, { + node: member.value, + path: current.escapes ? [] : read.path, + }); + } + + #thisValue(current: GetterWork, expression: t.ThisExpression): void { + if (!this.#ownsThis(expression)) return; + const { getter, owner, receiver = owner } = this.job.invocation; + if (current.privateRead !== undefined && t.isClass(owner)) { + this.#privateValue(current, owner); + return; + } + const references = t.isClass(receiver) + ? this.resolvers.classReferences( + { + owner: receiver, + kind: + t.isClassMethod(getter) && getter.static ? "static" : "instance", + }, + current.path, + current.escapes || this.job.escapes, + ) + : this.resolvers.objectReferences(receiver, current.path); + for (const reference of references) this.#retain(current, reference); + } + + #superValue( + current: GetterWork, + expression: t.MemberExpression | t.OptionalMemberExpression, + ): boolean { + const { getter, owner, receiver = owner } = this.job.invocation; + if ( + !t.isSuper(expression.object) || + !t.isClass(owner) || + owner.superClass == null + ) + return false; + const key = semanticStaticPropertyKey( + expression.property, + expression.computed, + ); + for (const base of this.resolvers.bases(owner.superClass)) + for (const reference of this.resolvers.classReferences( + { + owner: base, + receiver: t.isClass(receiver) ? receiver : owner, + kind: + t.isClassMethod(getter) && getter.static ? "static" : "prototype", + }, + [key, ...current.path], + current.escapes || this.job.escapes, + )) + this.#retain(current, reference); + return true; + } + + #member( + current: GetterWork, + expression: t.MemberExpression | t.OptionalMemberExpression, + ): void { + if ( + current.privateRead !== undefined && + t.isPrivateName(expression.property) + ) { + this.#privateRead(current); + return; + } + if ( + t.isPrivateName(expression.property) && + t.isClass(this.job.invocation.owner) + ) { + this.#pending.push({ + ...current, + node: expression.object, + path: [], + privateRead: { + node: expression, + name: expression.property.id.name, + path: current.path, + }, + }); + return; + } + if (!this.#superValue(current, expression)) + this.#pending.push({ + ...current, + node: expression.object, + path: [ + semanticStaticPropertyKey(expression.property, expression.computed), + ...current.path, + ], + }); + } + + #visit(current: GetterWork): void { + const expression = unwrapJavaScriptExpression(current.node).node; + if (t.isIdentifier(expression) && this.#alias(current, expression)) return; + if (t.isThisExpression(expression)) this.#thisValue(current, expression); + else if ( + t.isMemberExpression(expression) || + t.isOptionalMemberExpression(expression) + ) + this.#member(current, expression); + else { + const branches = resultBranches(expression); + if (branches !== undefined) + for (const node of branches) this.#pending.push({ ...current, node }); + else if (current.privateRead !== undefined) this.#privateRead(current); + else this.#retain(current, { node: expression, path: current.path }); + } + } +} + +export const expandSemanticGetterResult = ( + job: GetterQueryJob, + query: SemanticGetterQuery, + resolvers: GetterResultResolvers, +): void => new GetterResultWalker(job, query, resolvers).run(); diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index ce7310e367..e0b6f2623c 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -251,6 +251,7 @@ export function* collectSemanticMemberMutationsSteps( readonly effect: ValueEffect; readonly mutation?: t.Node; readonly originAt: t.Node | undefined; + readonly widenedGetterResult?: true; }[] = []; const iterableReferences: IterableReference[] = []; const iterableReferenceKeys = new WeakMap>(); @@ -266,6 +267,7 @@ export function* collectSemanticMemberMutationsSteps( reference.originAt?.start, reference.originAt?.end, reference.iterationOnly, + reference.widenedGetterResult === true, reference.requiredSources?.map((source) => [ source.node.start, source.node.end, @@ -291,6 +293,7 @@ export function* collectSemanticMemberMutationsSteps( mutation?: t.Node, originAt: t.Node | undefined = mutation, ownerInitializer?: t.Node, + widenedGetterResult = false, ): void => { const pending: (ReferencedValue & { readonly bindings: ReadonlySet; @@ -392,6 +395,7 @@ export function* collectSemanticMemberMutationsSteps( mutation, current.originAt, current.ownerInitializer, + widenedGetterResult, ); continue; } @@ -477,7 +481,7 @@ export function* collectSemanticMemberMutationsSteps( current.originAt?.start ?? -1 }\u0001${current.originAt?.end ?? -1}\u0001${ current.ownerInitializer?.start ?? -1 - }\u0001${current.ownerInitializer?.end ?? -1}`; + }\u0001${current.ownerInitializer?.end ?? -1}\u0001${widenedGetterResult}`; let covering = coveringPaths.get(pathState); if (covering === undefined) { covering = new SemanticPropertyPathCoverage(); @@ -531,6 +535,7 @@ export function* collectSemanticMemberMutationsSteps( path: copiedPath, bindings: nested, effect, + ...(widenedGetterResult ? { widenedGetterResult: true } : {}), originAt: initializer.node, ...(mutation === undefined ? {} : { mutation }), }); @@ -550,9 +555,17 @@ export function* collectSemanticMemberMutationsSteps( }); } else { for (const value of effect === "escape" - ? escapedReferenceLeaves(expression, current.path) + ? escapedReferenceLeaves( + expression, + current.path, + widenedGetterResult, + ) : expandedValues(expression, current.path, effect)) { - if (value.escapes && effect !== "escape") + if ( + value.escapes && + (effect !== "escape" || + (value.widenedGetterResult && !widenedGetterResult)) + ) markValue( value.node, value.path, @@ -561,6 +574,7 @@ export function* collectSemanticMemberMutationsSteps( mutation, current.originAt, value.ownerInitializer, + widenedGetterResult || value.widenedGetterResult === true, ); else if (value.iterableFallbackPath !== undefined) deferIterable({ @@ -571,6 +585,7 @@ export function* collectSemanticMemberMutationsSteps( bindings: current.bindings, effect, originAt: current.originAt, + ...(widenedGetterResult ? { widenedGetterResult: true } : {}), ...(mutation === undefined ? {} : { mutation }), }); else @@ -592,6 +607,7 @@ export function* collectSemanticMemberMutationsSteps( expression: t.Node, path: PropertyPath, effect: ValueEffect, + widenedGetterResult = false, ): readonly ReferencedValue[] { if ( t.isArrayExpression(expression) && @@ -599,7 +615,20 @@ export function* collectSemanticMemberMutationsSteps( (path[0] === "constructor" && path[1] === "prototype")) ) arrayIterationUnknown = true; - return referencedValues(expression, path, effect, state); + const references = referencedValues( + expression, + path, + effect, + state, + widenedGetterResult, + ); + return widenedGetterResult + ? references.map((reference) => ({ + ...reference, + escapes: true, + widenedGetterResult: true, + })) + : references; } // Expanding a value below its references depends only on syntax, not on the // escape site. Escaping an object reaches every method's results, so @@ -613,53 +642,79 @@ export function* collectSemanticMemberMutationsSteps( function escapedReferenceLeaves( root: t.Node, rootPath: PropertyPath, + widenedGetterResult: boolean, ): readonly ReferencedValue[] { // Parser recovery can leave an argument hole, which expands to nothing. if (!t.isNode(root)) return []; - const rootKey = JSON.stringify(rootPath); + const rootKey = JSON.stringify([rootPath, widenedGetterResult]); const cached = escapedLeaves.get(root)?.get(rootKey); if (cached !== undefined) return cached; + const leaves = collectEscapedLeaves([ + ...expandedValues(root, rootPath, "escape", widenedGetterResult), + ]); + const byPath = escapedLeaves.get(root) ?? new Map(); + byPath.set(rootKey, leaves); + escapedLeaves.set(root, byPath); + return leaves; + } + function retainEscapedLeaf( + value: ReferencedValue, + node: t.Node, + leaves: ReferencedValue[], + identities: Set, + ): boolean { + if ( + value.iterableFallbackPath !== undefined || + t.isMemberExpression(node) || + t.isOptionalMemberExpression(node) + ) { + leaves.push(value); + return true; + } + if (!t.isIdentifier(node)) return false; + const binding = resolveBinding(node, node.name); + const identity = JSON.stringify([ + binding === undefined ? null : binding.bindingId, + binding === undefined ? node.name : null, + value.path, + value.widenedGetterResult === true, + value.ownerInitializer?.start, + value.ownerInitializer?.end, + ]); + if (!identities.has(identity)) { + identities.add(identity); + leaves.push(value); + } + return true; + } + function collectEscapedLeaves( + pending: ReferencedValue[], + ): readonly ReferencedValue[] { const leaves: ReferencedValue[] = []; - const leafIdentities = new Set(); + const identities = new Set(); const expanded = new WeakMap>(); - const pending = [...expandedValues(root, rootPath, "escape")]; for (let cursor = 0; cursor < pending.length; cursor++) { const value = pending[cursor]; if (value === undefined) break; const node = unwrapJavaScriptExpression(value.node).node; - if (!t.isNode(node)) continue; - const pathKey = JSON.stringify(value.path); - if (t.isIdentifier(node) && value.iterableFallbackPath === undefined) { - const binding = resolveBinding(node, node.name); - const identity = JSON.stringify([ - binding === undefined ? null : binding.bindingId, - binding === undefined ? node.name : null, - pathKey, - value.ownerInitializer?.start, - value.ownerInitializer?.end, - ]); - if (leafIdentities.has(identity)) continue; - leafIdentities.add(identity); - leaves.push(value); - } else if ( - value.iterableFallbackPath !== undefined || - t.isIdentifier(node) || - t.isMemberExpression(node) || - t.isOptionalMemberExpression(node) - ) - leaves.push(value); - else { - const paths = expanded.get(node) ?? new Set(); - if (paths.has(pathKey)) continue; - paths.add(pathKey); - expanded.set(node, paths); - for (const reference of expandedValues(node, value.path, "escape")) - pending.push(reference); - } + if (!t.isNode(node) || retainEscapedLeaf(value, node, leaves, identities)) + continue; + const key = JSON.stringify([ + value.path, + value.widenedGetterResult === true, + ]); + const paths = expanded.get(node) ?? new Set(); + if (paths.has(key)) continue; + paths.add(key); + expanded.set(node, paths); + for (const reference of expandedValues( + node, + value.path, + "escape", + value.widenedGetterResult === true, + )) + pending.push(reference); } - const byPath = escapedLeaves.get(root) ?? new Map(); - byPath.set(rootKey, leaves); - escapedLeaves.set(root, byPath); return leaves; } const markReceiver = (callee: t.Node, mutation: t.Node): void => { @@ -743,6 +798,9 @@ export function* collectSemanticMemberMutationsSteps( fallbackPath: [], bindings: reference.bindings, effect: reference.effect, + ...(reference.widenedGetterResult + ? { widenedGetterResult: true } + : {}), originAt: initializer.node, ...(reference.mutation === undefined ? {} @@ -761,6 +819,9 @@ export function* collectSemanticMemberMutationsSteps( fallbackPath: projection, bindings: reference.bindings, effect: reference.effect, + ...(reference.widenedGetterResult + ? { widenedGetterResult: true } + : {}), originAt: reference.originAt, ...(reference.mutation === undefined ? {} @@ -774,6 +835,8 @@ export function* collectSemanticMemberMutationsSteps( reference.effect, reference.mutation, reference.originAt, + undefined, + reference.widenedGetterResult === true, ); } for (const [index, reference] of [...iterableReferences].entries()) { @@ -803,6 +866,8 @@ export function* collectSemanticMemberMutationsSteps( knownArray ? reference.effect : "escape", reference.mutation, reference.originAt, + undefined, + reference.widenedGetterResult === true, ); } if ( @@ -899,6 +964,7 @@ const referencedValues = ( path: PropertyPath, effect: ValueEffect, state: JavaScriptSemanticAnalysisState, + widenedGetterResult = false, ): readonly ReferencedValue[] => { if (effect === "escape" && t.isFunction(node)) return semanticCallableResultExpressions(node).flatMap( @@ -912,30 +978,47 @@ const referencedValues = ( ? semanticObjectPropertyReferences(node, path, state) : arrayReferencedValues(node, path); } - if (t.isClass(node)) { - const [key, ...remaining] = path; - if (key === undefined) return []; - // Replacing or deleting the getter's property does not use the value it - // would return. Only a deeper path can mutate the returned object. - if (effect === "write" && remaining.length === 0) return []; - return semanticClassPropertyReferences( - { owner: node, kind: "static" }, + if (t.isClass(node) || t.isNewExpression(node)) + return constructedReferenceValues( + node, + path, + effect, + state, + widenedGetterResult, + ); + return branchReferenceValues(node, path); +}; + +const constructedReferenceValues = ( + node: t.ClassDeclaration | t.ClassExpression | t.NewExpression, + path: PropertyPath, + effect: ValueEffect, + state: JavaScriptSemanticAnalysisState, + widenedGetterResult: boolean, +): readonly ReferencedValue[] => { + // Only retry results lost a suffix that could reach any constructed field. + // Preserve that reachability through aliases and nested allocations, without + // broadening ordinary whole-receiver escapes. + const [key = widenedGetterResult ? null : undefined, ...remaining] = path; + // Replacing a receiver property does not mutate the object it previously held. + if (key === undefined || (effect === "write" && remaining.length === 0)) + return []; + const classes = t.isClass(node) + ? [node] + : semanticClassCandidates(node.callee, state); + return classes.flatMap((owner) => + semanticClassPropertyReferences( + { owner, kind: t.isClass(node) ? "static" : "instance" }, [key, ...remaining], state, - ); - } - if (t.isNewExpression(node)) { - const [key, ...remaining] = path; - if (key === undefined) return []; - if (effect === "write" && remaining.length === 0) return []; - return semanticClassCandidates(node.callee, state).flatMap((klass) => - semanticClassPropertyReferences( - { owner: klass, kind: "instance" }, - [key, ...remaining], - state, - ), - ); - } + ), + ); +}; + +const branchReferenceValues = ( + node: t.Node, + path: PropertyPath, +): readonly ReferencedValue[] => { if (t.isAssignmentExpression(node)) return node.operator === "||=" || node.operator === "??=" ? [ diff --git a/src/domain/javascript/javascriptSemanticObjectReferences.test.ts b/src/domain/javascript/javascriptSemanticObjectReferences.test.ts index f9867ed197..2931e2bf11 100644 --- a/src/domain/javascript/javascriptSemanticObjectReferences.test.ts +++ b/src/domain/javascript/javascriptSemanticObjectReferences.test.ts @@ -2,9 +2,13 @@ import * as t from "@babel/types"; import { expect, it } from "vitest"; import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js"; -import { semanticObjectPropertyReferences } from "./javascriptSemanticObjectReferences.js"; +import { + semanticClassPropertyReferences, + semanticObjectPropertyReferences, +} from "./javascriptSemanticObjectReferences.js"; import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { getterExpansionSource } from "../../../tests/fixtures/javascriptGetterExpansion.js"; const objectAndState = (source: string) => { const parsed = parseJavaScriptSource(source, "object.js"); @@ -71,3 +75,67 @@ it("resolves exact keys with source-order overrides, spreads and accessors", () spelled(objectAndState(`const o = { 1: "one", 2: "two" };`), 1), ).toEqual([['"one"', "[]"]]); }); + +it("preserves references through a small static getter permutation", () => { + const getters = Array.from( + { length: 5 }, + (_, index) => + `static get field${index}() { return this[key].field${index}; }`, + ).join("\n"); + const source = `const key = getKey(); const shared = {}; class Request { + ${getters} + static get state() { return shared; } + }`; + const parsed = parseJavaScriptSource(source, "getters.js"); + if (parsed === null) throw new Error("Expected a parsed fixture"); + const klass = parsed.program.body.find((node): node is t.ClassDeclaration => + t.isClassDeclaration(node), + ); + if (klass === undefined) throw new Error("Expected a class declaration"); + const state = completeSemanticSteps( + indexJavaScriptBindingsSteps(parsed.program), + ); + const references = semanticClassPropertyReferences( + { owner: klass, kind: "static" }, + ["field0"], + state, + ); + + expect( + references.some( + ({ node }) => source.slice(node.start ?? 0, node.end ?? 0) === "shared", + ), + ).toBe(true); + const distinctReferences = new Set( + references.map(({ node, path, escapes }) => + JSON.stringify([node.start, node.end, path, escapes]), + ), + ); + expect(references).toHaveLength(distinctReferences.size); +}); + +it("widens a large static getter permutation to one escaped reference", () => { + const source = getterExpansionSource(30).replaceAll("get ", "static get "); + const parsed = parseJavaScriptSource(source, "getters.js"); + if (parsed === null) throw new Error("Expected a parsed fixture"); + const klass = parsed.program.body.find((node): node is t.ClassDeclaration => + t.isClassDeclaration(node), + ); + if (klass === undefined) throw new Error("Expected a class declaration"); + const state = completeSemanticSteps( + indexJavaScriptBindingsSteps(parsed.program), + ); + const references = semanticClassPropertyReferences( + { owner: klass, kind: "static" }, + ["field0"], + state, + ); + + expect(references).toHaveLength(1); + expect(references[0]).toMatchObject({ + node: { type: "Identifier", name: "shared" }, + path: [], + escapes: true, + }); + expect([...state.getterResourceLimits]).toEqual(["getter-expansion"]); +}); diff --git a/src/domain/javascript/javascriptSemanticObjectReferences.ts b/src/domain/javascript/javascriptSemanticObjectReferences.ts index d16294fb50..41d79ffd4a 100644 --- a/src/domain/javascript/javascriptSemanticObjectReferences.ts +++ b/src/domain/javascript/javascriptSemanticObjectReferences.ts @@ -3,9 +3,6 @@ import { resolveSemanticBindingState, type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; -import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js"; -import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; -import { semanticReferenceSourcesInactive } from "./javascriptSemanticReferenceSources.js"; import { semanticStaticPropertyKey, unwrapJavaScriptExpression, @@ -15,11 +12,18 @@ import { type JavaScriptSemanticPropertyPath as PropertyPath, } from "./javascriptSemanticPropertyPaths.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; +import { + SemanticGetterQuery, + type GetterQueryJob, +} from "./javascriptSemanticGetterQueries.js"; +import { expandSemanticGetterResult } from "./javascriptSemanticGetterResults.js"; export interface JavaScriptSemanticObjectReference { readonly node: t.Node; readonly path: PropertyPath; readonly escapes?: true; + /** A retry omitted suffixes, so escaping constructed children must cover them. */ + readonly widenedGetterResult?: true; /** The allocation owned by this binding, rather than a read of its prior value. */ readonly ownerInitializer?: t.Node; } @@ -229,68 +233,106 @@ export const semanticClassPropertyReferences = ( lookup: ClassPropertyLookup, selection: PropertyPath, state: JavaScriptSemanticAnalysisState, - activeGetters: ReadonlyMap = new Map(), ): readonly JavaScriptSemanticObjectReference[] => { - const root = lookup.owner; - const receiver = lookup.receiver ?? root; - const [key, ...path] = selection; - const propertyName = - key == null || typeof key === "object" ? null : String(key); - const pending: (t.ClassDeclaration | t.ClassExpression)[] = [root]; - const seen = new Set(); - const possibleResults: JavaScriptSemanticObjectReference[] = []; - // Instance fields are own properties, including fields initialized by base - // constructors. They take precedence over every prototype descriptor. - let instanceFields: ReadonlySet = new Set(); - if (lookup.kind === "instance") { - const fields = instanceFieldReferences( - root, - propertyName, - path, - state, - lookup.initializedBefore, - ); - possibleResults.push(...fields.references); - instanceFields = fields.definiteNames; - if (propertyName !== null && instanceFields.has(propertyName)) - return possibleResults; + const query = new SemanticGetterQuery(state); + return query.run( + () => classPropertyReferences(lookup, selection, state, { query }), + (job) => expandGetterResult(job, query), + ); +}; + +class ClassReferenceWalk { + readonly #references: JavaScriptSemanticObjectReference[] = []; + readonly #pending: (t.ClassDeclaration | t.ClassExpression)[]; + readonly #seen = new Set(); + readonly #name: string | null; + readonly #path: PropertyPath; + #fields: ReadonlySet = new Set(); + + constructor( + readonly lookup: ClassPropertyLookup, + selection: PropertyPath, + readonly state: JavaScriptSemanticAnalysisState, + readonly context: { + readonly query: SemanticGetterQuery; + readonly escapes?: boolean; + }, + ) { + const [key, ...path] = selection; + this.#name = key == null || typeof key === "object" ? null : String(key); + this.#path = path; + this.#pending = [lookup.owner]; } - for (let cursor = 0; cursor < pending.length; cursor++) { - const klass = pending[cursor]; - if (klass === undefined || seen.has(klass)) continue; - seen.add(klass); + + #instanceFields(): boolean { + if (this.lookup.kind !== "instance") return false; + // Own fields, including inherited constructor fields, suppress prototype descriptors. + const fields = instanceFieldReferences( + this.lookup.owner, + this.#name, + this.#path, + this.state, + this.lookup.initializedBefore, + ); + for (const reference of fields.references) this.#references.push(reference); + this.#fields = fields.definiteNames; + return this.#name !== null && this.#fields.has(this.#name); + } + + #getter( + klass: t.ClassDeclaration | t.ClassExpression, + getter: t.ClassMethod, + ): void { + const key = semanticStaticPropertyKey(getter.key, getter.computed); + if (key !== null && this.#fields.has(key)) return; + this.context.query.schedule( + { + getter, + owner: klass, + receiver: this.lookup.receiver ?? this.lookup.owner, + }, + this.#path, + this.context.escapes, + ); + } + + #visit(klass: t.ClassDeclaration | t.ClassExpression): void { const descriptors = classPropertyDescriptors( klass, - propertyName, - lookup.kind, - lookup.initializedBefore, + this.#name, + this.lookup.kind, + this.lookup.initializedBefore, ); for (const field of descriptors.fields) if (field.value != null) - possibleResults.push({ node: field.value, path }); - for (const getter of lookup.fieldsOnly ? [] : descriptors.getters) { - const key = semanticStaticPropertyKey(getter.key, getter.computed); - if (key !== null && instanceFields.has(key)) continue; - possibleResults.push( - ...getterResultReferences( - { getter, owner: klass, receiver }, - path, - state, - activeGetters, - ), - ); - } - // An exact own descriptor suppresses inherited lookup. Dynamic computed - // descriptors may or may not match, so retain possible superclass results. - if ( - descriptors.inherits && - klass.superClass !== null && - klass.superClass !== undefined - ) - pending.push(...semanticClassCandidates(klass.superClass, state)); + this.#references.push({ node: field.value, path: this.#path }); + for (const getter of this.lookup.fieldsOnly ? [] : descriptors.getters) + this.#getter(klass, getter); + // Only a definite own descriptor can suppress possible superclass results. + if (descriptors.inherits && klass.superClass != null) + for (const base of semanticClassCandidates(klass.superClass, this.state)) + this.#pending.push(base); } - return possibleResults; -}; + + run(): readonly JavaScriptSemanticObjectReference[] { + if (this.#instanceFields()) return this.#references; + for (let cursor = 0; cursor < this.#pending.length; cursor++) { + const klass = this.#pending[cursor]; + if (klass === undefined || this.#seen.has(klass)) continue; + this.#seen.add(klass); + this.#visit(klass); + } + return this.#references; + } +} + +const classPropertyReferences = ( + lookup: ClassPropertyLookup, + selection: PropertyPath, + state: JavaScriptSemanticAnalysisState, + context: { readonly query: SemanticGetterQuery; readonly escapes?: boolean }, +): readonly JavaScriptSemanticObjectReference[] => + new ClassReferenceWalk(lookup, selection, state, context).run(); const objectReceiverReference = ( object: t.ObjectExpression, @@ -363,15 +405,6 @@ const objectReceiverReference = ( // Getter return expressions use the receiver of the property read. Keep that // identity when translating `this`, private fields, and superclass reads into // the same reference paths used by ordinary member mutations. -interface GetterInvocation { - readonly getter: t.ObjectMethod | t.ClassMethod; - readonly owner: t.ObjectExpression | t.ClassDeclaration | t.ClassExpression; - readonly receiver?: - | t.ObjectExpression - | t.ClassDeclaration - | t.ClassExpression; -} - /** Follow only data fields whose initializers retain a class receiver. */ const privateReceiverProjection = ( receiver: t.ClassDeclaration | t.ClassExpression, @@ -468,276 +501,20 @@ const privateReceiverProjection = ( return false; }; -const getterResultReferences = ( - invocation: GetterInvocation, - path: PropertyPath, - state: JavaScriptSemanticAnalysisState, - activeGetters: ReadonlyMap = new Map(), -): readonly JavaScriptSemanticObjectReference[] => { - const { getter, owner, receiver = owner } = invocation; - const previousLength = activeGetters.get(getter); - if (previousLength !== undefined && previousLength <= path.length) return []; - // Repeated receiver reads may revisit a getter while consuming a finite - // path; recursive getters that preserve or grow that path cannot progress. - const active = new Map(activeGetters).set(getter, path.length); - const isStatic = t.isClassMethod(getter) && getter.static; - interface AliasVisit { - readonly identity: string; - readonly pathLength: number; - readonly parent: AliasVisit | undefined; - } - const pending: { - readonly node: t.Node; - readonly path: PropertyPath; - readonly aliases: AliasVisit | undefined; - readonly escapes?: true; - readonly privateRead?: { - readonly node: t.MemberExpression | t.OptionalMemberExpression; - readonly name: string; - readonly path: PropertyPath; - }; - }[] = semanticCallableResultExpressions(getter).flatMap(({ node }) => - node === null ? [] : [{ node, path, aliases: undefined }], - ); - const references: JavaScriptSemanticObjectReference[] = []; - const expandedAliases = new Map>(); - const getterScope = state.scopeByNode.get(getter)?.scopeId; - for (let cursor = 0; cursor < pending.length; cursor++) { - const current = pending[cursor]; - if (current === undefined) break; - const { path: remaining } = current; - const expression = unwrapJavaScriptExpression(current.node).node; - const retain = (reference: JavaScriptSemanticObjectReference): void => { - references.push( - current.escapes ? { ...reference, escapes: true } : reference, - ); - }; - const retainPrivateRead = (): void => { - if (current.privateRead !== undefined) - retain({ - node: current.privateRead.node, - path: current.escapes ? [] : current.privateRead.path, - }); - }; - if (t.isIdentifier(expression)) { - const binding = resolveSemanticBindingState( - state, - expression, - expression.name, - ); - let scope = - binding === undefined - ? undefined - : state.scopesById.get(binding.scopeId); - while (scope !== undefined && scope.scopeId !== getterScope) { - if (scope.kind === "function" || scope.parentScopeId === null) { - scope = undefined; - break; - } - scope = state.scopesById.get(scope.parentScopeId); - } - if ( - binding !== undefined && - scope !== undefined && - binding.referenceInitializers.every( - (initializer) => initializer.copyKind === undefined, - ) - ) { - const identity = JSON.stringify([ - binding.bindingId, - expression.start, - expression.end, - ]); - const known = expandedAliases.get(identity); - const key = JSON.stringify([ - remaining, - current.privateRead?.name, - current.privateRead?.path, - current.escapes, - ]); - let cyclic = false; - let growing = false; - if (known !== undefined) - for ( - let visit = current.aliases; - visit !== undefined; - visit = visit.parent - ) - if (visit.identity === identity) { - cyclic = true; - growing = remaining.length > visit.pathLength; - break; - } - if (cyclic) { - // A repeated alias can select arbitrarily deep children. Revisit its - // roots as escapes rather than enumerating iterations or dropping them. - if (growing && !current.escapes) - pending.push({ - ...current, - path: [], - aliases: undefined, - escapes: true, - }); - continue; - } - if (known?.has(key)) continue; - const selections = known ?? new Set(); - selections.add(key); - expandedAliases.set(identity, selections); - const aliases = { - identity, - pathLength: remaining.length, - parent: current.aliases, - }; - // Unknown read ordering retains possible aliases for effect reachability. - const { binding: origins } = semanticBindingAtRead( - binding, - expression, - state.parentsByNode, - ); - for (const initializer of [ - ...origins.initializers, - ...origins.referenceInitializers.filter( - (initializer) => - !semanticReferenceSourcesInactive(initializer, state), - ), - ]) - pending.push({ - ...current, - node: initializer.node, - path: [...initializer.projection, ...remaining], - aliases, - }); - continue; - } - } - if (t.isThisExpression(expression)) { - // An outer binding or a nested ordinary function has a different receiver. - let ownerNode = state.parentsByNode.get(expression); - while ( - ownerNode !== undefined && - !t.isClass(ownerNode) && - !(t.isFunction(ownerNode) && !t.isArrowFunctionExpression(ownerNode)) - ) - ownerNode = state.parentsByNode.get(ownerNode); - if (ownerNode !== getter) continue; - if (current.privateRead !== undefined && t.isClass(owner)) { - if ( - !t.isClass(receiver) || - !privateReceiverProjection( - receiver, - owner, - remaining, - isStatic, - state, - ) - ) { - retainPrivateRead(); - continue; - } - for (const member of owner.body.body) - if ( - t.isClassPrivateProperty(member) && - member.static === isStatic && - member.key.id.name === current.privateRead.name && - member.value != null - ) - retain({ - node: member.value, - path: current.escapes ? [] : current.privateRead.path, - }); - continue; - } - for (const reference of t.isClass(receiver) - ? semanticClassPropertyReferences( - { owner: receiver, kind: isStatic ? "static" : "instance" }, - remaining, - state, - active, - ) - : objectReceiverReference(receiver, remaining, state)) - retain(reference); - continue; - } - if ( - t.isMemberExpression(expression) || - t.isOptionalMemberExpression(expression) - ) { - if ( - current.privateRead !== undefined && - t.isPrivateName(expression.property) - ) { - retainPrivateRead(); - continue; - } - if (t.isPrivateName(expression.property) && t.isClass(owner)) { - pending.push({ - ...current, - node: expression.object, - path: [], - privateRead: { - node: expression, - name: expression.property.id.name, - path: remaining, - }, - }); - continue; - } - const key = semanticStaticPropertyKey( - expression.property, - expression.computed, - ); - if ( - t.isSuper(expression.object) && - t.isClass(owner) && - owner.superClass != null - ) { - for (const base of semanticClassCandidates(owner.superClass, state)) - for (const reference of semanticClassPropertyReferences( - { - owner: base, - receiver: t.isClass(receiver) ? receiver : owner, - kind: isStatic ? "static" : "prototype", - }, - [key, ...remaining], - state, - active, - )) - retain(reference); - continue; - } - pending.push({ - ...current, - node: expression.object, - path: [key, ...remaining], - }); - } else if (t.isConditionalExpression(expression)) - pending.push( - { ...current, node: expression.consequent }, - { ...current, node: expression.alternate }, - ); - else if (t.isLogicalExpression(expression)) - pending.push( - { ...current, node: expression.left }, - { ...current, node: expression.right }, - ); - else if (t.isSequenceExpression(expression)) { - const last = expression.expressions.at(-1); - if (last !== undefined) pending.push({ ...current, node: last }); - } else if (t.isAssignmentExpression(expression, { operator: "=" })) - pending.push({ ...current, node: expression.right }); - else if ( - t.isAssignmentExpression(expression) && - ["&&=", "||=", "??="].includes(expression.operator) - ) - pending.push( - { ...current, node: expression.left }, - { ...current, node: expression.right }, - ); - else if (current.privateRead !== undefined) retainPrivateRead(); - else retain({ node: expression, path: remaining }); - } - return references; +const expandGetterResult = ( + job: GetterQueryJob, + query: SemanticGetterQuery, +): void => { + const state = query.state; + expandSemanticGetterResult(job, query, { + classReferences: (lookup, path, escapes) => + classPropertyReferences(lookup, path, state, { query, escapes }), + objectReferences: (receiver, path) => + objectReceiverReference(receiver, path, state), + privateReceiver: (receiver, owner, path, isStatic) => + privateReceiverProjection(receiver, owner, path, isStatic, state), + bases: (node) => semanticClassCandidates(node, state), + }); }; type ObjectLiteralMember = t.ObjectExpression["properties"][number]; @@ -814,127 +591,160 @@ export const semanticObjectPropertyReferences = ( path: PropertyPath, state: JavaScriptSemanticAnalysisState, ): readonly JavaScriptSemanticObjectReference[] => { - const [key, ...remaining] = path; - const references: JavaScriptSemanticObjectReference[] = []; - const overwritten = new Set(); - const dataOverwritten = new Set(); - const accessorNames = new Set(); - const getterNames = new Set(); - const setterNames = new Set(); - for (const property of relevantPropertiesInReverse(node, key)) { - if (t.isSpreadElement(property)) { - const selected = key ?? null; - if (typeof selected !== "object" && overwritten.has(String(selected))) - continue; - references.push({ - node: property.argument, - path: [ - typeof selected === "object" - ? { - ...selected, - excludedKeys: [ - ...new Set([ - ...(selected?.excludedKeys ?? []), - ...overwritten, - ]), - ].sort(compareUnicodeCodePoints), - } - : selected, - ...remaining, - ], - }); - if ( - selected !== null && - typeof selected !== "object" && - t.isObjectExpression(property.argument) && - objectExpressionDefinesKey(property.argument, String(selected)) - ) { - overwritten.add(String(selected)); - dataOverwritten.add(String(selected)); - } - continue; - } - const name = semanticStaticPropertyKey(property.key, property.computed); + const query = new SemanticGetterQuery(state); + return query.run( + () => objectPropertyReferences(node, path, query), + (job) => expandGetterResult(job, query), + ); +}; + +class ObjectReferenceWalk { + readonly #references: JavaScriptSemanticObjectReference[] = []; + readonly #overwritten = new Set(); + readonly #data = new Set(); + readonly #accessors = new Set(); + readonly #getters = new Set(); + readonly #setters = new Set(); + readonly #key: PropertyPath[number] | undefined; + readonly #remaining: PropertyPath; + + constructor( + readonly node: t.ObjectExpression, + path: PropertyPath, + readonly query: SemanticGetterQuery, + ) { + [this.#key, ...this.#remaining] = path; + } + + #spreadKey(): PropertyPath[number] { + const selected = this.#key ?? null; + return typeof selected !== "object" + ? selected + : { + ...selected, + excludedKeys: [ + ...new Set([ + ...(selected?.excludedKeys ?? []), + ...this.#overwritten, + ]), + ].sort(compareUnicodeCodePoints), + }; + } + + #spread(property: t.SpreadElement): void { + const selected = this.#key ?? null; + if (typeof selected !== "object" && this.#overwritten.has(String(selected))) + return; + this.#references.push({ + node: property.argument, + path: [this.#spreadKey(), ...this.#remaining], + }); if ( - t.isObjectProperty(property) && - !property.computed && - !property.shorthand && - name === "__proto__" + selected !== null && + typeof selected !== "object" && + t.isObjectExpression(property.argument) && + objectExpressionDefinesKey(property.argument, String(selected)) ) { - const selected = key ?? null; - if ( - (selected === null || typeof selected === "object") && - t.isExpression(property.value) - ) { - references.push({ - node: property.value, - path: [selected, ...remaining], - }); - } else if ( - typeof selected === "string" && - !objectExpressionDefinesKey(node, selected) && - t.isExpression(property.value) - ) { - references.push({ - node: property.value, - path: [selected, ...remaining], - }); - } - continue; - } - const isGetter = t.isObjectMethod(property) && property.kind === "get"; - const isSetter = t.isObjectMethod(property) && property.kind === "set"; - if ( - name !== null && - (isGetter || isSetter - ? dataOverwritten.has(name) || - (isGetter ? getterNames.has(name) : setterNames.has(name)) - : dataOverwritten.has(name) || accessorNames.has(name)) - ) - continue; - if (name === null || semanticPropertyPathKeyMatches(key ?? null, name)) { - if (t.isObjectMethod(property) && property.kind === "get") { - references.push( - ...getterResultReferences( - { getter: property, owner: node }, - remaining, - state, - ), - ); - } else if (t.isObjectMethod(property) && property.kind === "set") { - // A setter contributes no readable reference. A paired getter is - // handled independently when it appears earlier in this object. - } else { - references.push({ - node: t.isObjectProperty(property) ? property.value : property, - path: remaining, - }); - } - } - if (name !== null) { - if (isGetter) { - getterNames.add(name); - accessorNames.add(name); - overwritten.add(name); - } else if (isSetter) { - setterNames.add(name); - accessorNames.add(name); - overwritten.add(name); - } else if ( - !( - t.isObjectProperty(property) && - !property.computed && - !property.shorthand && - name === "__proto__" - ) - ) { - overwritten.add(name); - dataOverwritten.add(name); - } + this.#overwritten.add(String(selected)); + this.#data.add(String(selected)); } } - return references; -}; + + #prototype( + property: t.ObjectMethod | t.ObjectProperty, + name: string | null, + ): boolean { + if ( + !t.isObjectProperty(property) || + property.computed || + property.shorthand || + name !== "__proto__" + ) + return false; + const selected = this.#key ?? null; + if ( + t.isExpression(property.value) && + (selected === null || + typeof selected === "object" || + (typeof selected === "string" && + !objectExpressionDefinesKey(this.node, selected))) + ) + this.#references.push({ + node: property.value, + path: [selected, ...this.#remaining], + }); + return true; + } + + #read( + property: t.ObjectMethod | t.ObjectProperty, + name: string | null, + ): void { + if ( + name !== null && + !semanticPropertyPathKeyMatches(this.#key ?? null, name) + ) + return; + if (t.isObjectMethod(property)) { + if (property.kind === "get") + this.query.schedule( + { getter: property, owner: this.node }, + this.#remaining, + ); + // A paired getter remains readable even when its setter appears later. + if (property.kind === "get" || property.kind === "set") return; + } + this.#references.push({ + node: t.isObjectProperty(property) ? property.value : property, + path: this.#remaining, + }); + } + + #record(name: string, kind: "get" | "set" | "data"): void { + this.#overwritten.add(name); + if (kind === "data") this.#data.add(name); + else { + this.#accessors.add(name); + (kind === "get" ? this.#getters : this.#setters).add(name); + } + } + + #visit(property: ObjectLiteralMember): void { + if (t.isSpreadElement(property)) { + this.#spread(property); + return; + } + const name = semanticStaticPropertyKey(property.key, property.computed); + if (this.#prototype(property, name)) return; + const kind = + t.isObjectMethod(property) && property.kind !== "method" + ? property.kind + : "data"; + if ( + name !== null && + (kind === "data" + ? this.#data.has(name) || this.#accessors.has(name) + : this.#data.has(name) || + (kind === "get" ? this.#getters : this.#setters).has(name)) + ) + return; + this.#read(property, name); + if (name !== null) this.#record(name, kind); + } + + run(): readonly JavaScriptSemanticObjectReference[] { + for (const property of relevantPropertiesInReverse(this.node, this.#key)) + this.#visit(property); + return this.#references; + } +} + +const objectPropertyReferences = ( + node: t.ObjectExpression, + path: PropertyPath, + query: SemanticGetterQuery, +): readonly JavaScriptSemanticObjectReference[] => + new ObjectReferenceWalk(node, path, query).run(); const objectExpressionDefinesKey = ( root: t.ObjectExpression, diff --git a/src/domain/javascript/javascriptSemanticResourceLimits.ts b/src/domain/javascript/javascriptSemanticResourceLimits.ts index 8938a6e249..adbd43d124 100644 --- a/src/domain/javascript/javascriptSemanticResourceLimits.ts +++ b/src/domain/javascript/javascriptSemanticResourceLimits.ts @@ -7,6 +7,8 @@ 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. */ +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; @@ -17,6 +19,8 @@ const RESOURCE_LIMIT_REASON: Record = { "Primitive string-byte budget exceeded (maximum 1048576 estimated JSON bytes).", "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).", }; /** Check UTF-16 length against worst-case JSON escaping without flattening. */ diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index bd5f88f9ec..f65a520124 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -81,6 +81,8 @@ export interface JavaScriptSemanticAnalysisState { readonly staticImportResolutionMode: JavaScriptModuleResolutionMode; /** Bounds encountered during this analysis, independent of later projections. */ readonly valueResourceLimits: Set; + /** Getter query bounds encountered while resolving object and class properties. */ + readonly getterResourceLimits: Set; readonly parentsByNode: WeakMap; readonly scopes: JavaScriptSemanticScopeState[]; readonly scopesById: Map; diff --git a/src/domain/javascript/javascriptSemanticValueTypes.ts b/src/domain/javascript/javascriptSemanticValueTypes.ts index 67d4ee06f5..bb545d8662 100644 --- a/src/domain/javascript/javascriptSemanticValueTypes.ts +++ b/src/domain/javascript/javascriptSemanticValueTypes.ts @@ -5,7 +5,8 @@ export type JavaScriptSemanticPrimitive = string | number | boolean | null; export type JavaScriptSemanticResourceLimit = | "primitive-candidates" | "primitive-bytes" - | "expression-depth"; + | "expression-depth" + | "getter-expansion"; type JavaScriptSemanticObjectValue = { readonly status: "object"; diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts index 4dab222d4f..0c045ca1d4 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts @@ -1,7 +1,32 @@ import { expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js"; -import { javaScriptWorkerStaticAnalysisSchema } from "./JavaScriptAnalysisWorkerSchemas.js"; +import { + javaScriptWorkerModuleHeaderSchema, + javaScriptWorkerModuleRecordSchema, + javaScriptWorkerStaticAnalysisSchema, +} from "./JavaScriptAnalysisWorkerSchemas.js"; +import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js"; +import { getterExpansionSource } from "../../../tests/fixtures/javascriptGetterExpansion.js"; + +it("transfers getter expansion limits and unknown bindings across the worker boundary", () => { + const ir = analyzeJavaScriptSemantics(getterExpansionSource()); + const header = { coverage: ir.coverage, limitations: ir.limitations }; + expect( + javaScriptWorkerModuleHeaderSchema.parse( + JSON.parse(JSON.stringify(header)) as unknown, + ), + ).toEqual(header); + for (const value of ir.bindings) { + const record = { kind: "binding", value }; + expect( + javaScriptWorkerModuleRecordSchema.parse( + JSON.parse(JSON.stringify(record)) as unknown, + ), + ).toEqual(record); + } + expect(ir.coverage.resourceLimits).toEqual(["getter-expansion"]); +}); it("retains candidate endpoint interpretation across the worker boundary", () => { const facts = analyzeJavaScriptStaticSource(` diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index 63ad24b629..867d5dc4d5 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -417,6 +417,7 @@ const resourceLimit = z.enum([ "primitive-candidates", "primitive-bytes", "expression-depth", + "getter-expansion", ]); const primitive = z.union([z.string(), z.number(), z.boolean(), z.null()]); const property: z.ZodType = z.lazy(() => diff --git a/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts index 860424c6df..c893dddd93 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts @@ -7,6 +7,13 @@ import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; import { parseJavaScriptApplicationGraph } from "../../../../src/domain/javascript/javascriptApplicationGraph.js"; import { compareJavaScriptApplicationVersions } from "../../../../src/domain/javascript/javascriptApplicationVersionComparison.js"; +import { semanticResourceLimitReason } from "../../../../src/domain/javascript/javascriptSemanticResourceLimits.js"; +import { getterExpansionSource } from "../../../fixtures/javascriptGetterExpansion.js"; +import { scanCanonicalArtifactInventory } from "../../../fixtures/artifactInventory.js"; +import { readJavaScriptArtifactFiles } from "../../../support/javascriptApplicationScope.js"; +import { createJavaScriptArtifactReader } from "../../../../src/artifacts/javascript/JavaScriptArtifactReader.js"; +import { analyzeJavaScriptArtifactFiles } from "../../../../src/application/javascript/JavaScriptArtifactAnalysis.js"; +import { buildJavaScriptArtifactGraph } from "../../../../src/application/javascript/JavaScriptArtifactGraphBuilder.js"; import { findExportNode, @@ -16,6 +23,64 @@ import { writeFixtureFiles, } from "../../../support/javascriptApplicationFixture.js"; +const relationshipGraph = async (root: string) => { + const snapshot = await scanCanonicalArtifactInventory(root, {}); + const reader = createJavaScriptArtifactReader(root, "directory"); + try { + const files = await readJavaScriptArtifactFiles(reader, snapshot); + return buildJavaScriptArtifactGraph( + snapshot, + files, + analyzeJavaScriptArtifactFiles(files), + ); + } finally { + await reader.close(); + } +}; + +it.each([ + ["getter-expansion", getterExpansionSource()], + [ + "primitive-candidates", + `const result = ${Array.from({ length: 20 }, () => '(true ? "a" : "b")').join(" + ")};`, + ], +] as const)( + "deduplicates %s limitations on import and export relationships", + async (limit, source) => { + const root = await createTestTempDirectory("rea-module-limit-evidence-"); + await writeFixtureFiles(root, { + "entry.js": `import imported from "./dep.js"; ${source} export { result };`, + "dep.js": "export default 42;", + }); + const graph = await relationshipGraph(root); + const module = findSourceModule(graph, "entry.js"); + const relationships = graph.edges.filter( + ({ source_node_id, relation, properties }) => + source_node_id === module?.node_id && + (relation === "exposes" || + (relation === "imports" && properties.module_link_kind === "import")), + ); + expect(relationships.map(({ relation }) => relation).sort()).toEqual([ + "exposes", + "imports", + ]); + for (const { evidence } of relationships) { + expect( + evidence.limitations.filter( + (reason) => reason === semanticResourceLimitReason(limit), + ), + ).toEqual([semanticResourceLimitReason(limit)]); + expect(evidence.coverage.status).toBe("partial"); + expect(evidence.limitations).toContain( + "Static syntax does not prove that this relationship executes at runtime.", + ); + expect(evidence.limitations).toContain( + "Values and aliases were recovered from inert syntax only; no JavaScript was executed.", + ); + } + }, +); + describe("cross-version source module identity", () => { it("keeps a source module unchanged when only a sibling file changes", async () => { const root = await createTestTempDirectory("rea-source-module-sibling-"); diff --git a/tests/fixtures/javascriptGetterExpansion.ts b/tests/fixtures/javascriptGetterExpansion.ts new file mode 100644 index 0000000000..b9029b39e3 --- /dev/null +++ b/tests/fixtures/javascriptGetterExpansion.ts @@ -0,0 +1,13 @@ +/** Dynamic receiver keys enumerate permutations of these getter names. */ +export const getterExpansionSource = (count = 10): string => ` + const shared = { value: 1 }; + class Request { + ${Array.from( + { length: count }, + (_, index) => `get field${index}() { return this[key].field${index}; }`, + ).join("\n")} + get state() { return shared; } + } + consume(new Request().field0); + const result = shared.value; +`;