From 0fac32c8c511c6228c3d6897262dc0924635af44 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Fri, 16 Oct 2026 19:52:09 +0300 Subject: [PATCH] [TS] Retain classes with constant computed member keys --- .../main/kotlin/org/jacodb/ets/dto/Convert.kt | 14 +- .../kotlin/org/jacodb/ets/dto/Signatures.kt | 2 + .../kotlin/org/jacodb/ets/model/Signatures.kt | 2 + .../jacodb/ets/test/EtsTsPartialClassTest.kt | 47 ++++- jacodb-ets/ts-frontend/src/dto/signatures.ts | 3 + .../ts-frontend/src/lowering/astUtils.ts | 71 ++++++- .../ts-frontend/src/lowering/classBuilder.ts | 48 ++++- jacodb-ets/ts-frontend/test/classes.spec.ts | 8 +- .../test/computed-class-members.spec.ts | 184 ++++++++++++++++++ .../test/partial-class-bodies.spec.ts | 5 +- 10 files changed, 357 insertions(+), 27 deletions(-) create mode 100644 jacodb-ets/ts-frontend/test/computed-class-members.spec.ts diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt index b7e8dcdfa..0840e8eba 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt @@ -530,11 +530,7 @@ class EtsMethodBuilder( args = args.map { ensureLocal(it.toEtsEntity()) }, type = type.toEtsType(), isSuperCall = isSuperCall, - wellKnownSymbol = when (wellKnownSymbol) { - null -> null - "iterator" -> EtsWellKnownSymbol.ITERATOR - else -> error("Unknown intrinsic symbol key: $wellKnownSymbol") - }, + wellKnownSymbol = wellKnownSymbol.toEtsWellKnownSymbol(), ) is StaticCallExprDto -> EtsStaticCallExpr( @@ -861,6 +857,7 @@ fun FieldSignatureDto.toEtsFieldSignature(): EtsFieldSignature { enclosingClass = declaringClass.toEtsClassSignature(), name = name, type = type.toEtsType(), + wellKnownSymbol = wellKnownSymbol.toEtsWellKnownSymbol(), ) } @@ -878,9 +875,16 @@ fun MethodSignatureDto.toEtsMethodSignature(): EtsMethodSignature { ) }, returnType = returnType.toEtsType(), + wellKnownSymbol = wellKnownSymbol.toEtsWellKnownSymbol(), ) } +private fun String?.toEtsWellKnownSymbol(): EtsWellKnownSymbol? = when (this) { + null -> null + "iterator" -> EtsWellKnownSymbol.ITERATOR + else -> error("Unknown intrinsic symbol key: $this") +} + fun LocalSignatureDto.toEtsLocalSignature(): EtsLocalSignature { return EtsLocalSignature( name = name, diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Signatures.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Signatures.kt index 42479d9b3..b10064643 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Signatures.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Signatures.kt @@ -44,6 +44,7 @@ data class FieldSignatureDto( val declaringClass: ClassSignatureDto, val name: String, val type: TypeDto, + val wellKnownSymbol: String? = null, ) @Serializable @@ -53,6 +54,7 @@ data class MethodSignatureDto( val name: String, val parameters: List, val returnType: TypeDto, + val wellKnownSymbol: String? = null, ) @Serializable diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Signatures.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Signatures.kt index 7b5202817..cfdb50ee4 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Signatures.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Signatures.kt @@ -116,6 +116,7 @@ data class EtsFieldSignature( val enclosingClass: EtsClassSignature, val name: String, val type: EtsType, + val wellKnownSymbol: EtsWellKnownSymbol? = null, ) { override fun toString(): String { return "${enclosingClass.name}::$name: $type" @@ -128,6 +129,7 @@ data class EtsMethodSignature( val name: String, val parameters: List, val returnType: EtsType, + val wellKnownSymbol: EtsWellKnownSymbol? = null, ) { override fun toString(): String { val params = parameters.joinToString() diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsPartialClassTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsPartialClassTest.kt index 8a1817865..af44c1575 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsPartialClassTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsPartialClassTest.kt @@ -21,6 +21,7 @@ import kotlin.io.path.readText import kotlin.io.path.writeText import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertTrue import org.jacodb.ets.dto.AssignStmtDto import org.jacodb.ets.dto.EtsFileDto @@ -28,6 +29,7 @@ import org.jacodb.ets.dto.RawStmtDto import org.jacodb.ets.dto.RawValueDto import org.jacodb.ets.dto.toEtsFile import org.jacodb.ets.model.EtsRawStmt +import org.jacodb.ets.model.EtsWellKnownSymbol import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME import org.jacodb.ets.utils.DEFAULT_ARK_METHOD_NAME import org.jacodb.ets.utils.EtsIrProvider @@ -48,11 +50,54 @@ class EtsTsPartialClassTest { return EtsFileDto.loadFromJson(output.readText()) } + @Test + fun `constant computed members retain executable lifecycle and exact identities after model conversion`() { + val dto = frontend( + """ + export class Counter { + ['value'] = 7; + [1e2] = 11; + [-0] = 13; + [Symbol.iterator]() { return 17; } + ['Symbol.iterator']() { return 19; } + increment(value: number) { return value + this.value; } + } + export class Fields { + [Symbol.iterator] = 1; + ['Symbol.iterator'] = 2; + } + export function construct() { return new Counter(); } + """.trimIndent(), + ) + + val model = dto.toEtsFile() + val counter = model.classes.single { it.name == "Counter" } + val symbolMethod = counter.methods.single { it.signature.wellKnownSymbol == EtsWellKnownSymbol.ITERATOR } + val stringMethod = counter.methods.single { + it.name == "Symbol.iterator" && it.signature.wellKnownSymbol == null + } + val fields = model.classes.single { it.name == "Fields" }.fields + val symbolField = fields.single { it.signature.wellKnownSymbol == EtsWellKnownSymbol.ITERATOR } + val stringField = fields.single { it.signature.wellKnownSymbol == null } + val module = model.classes.single { it.name == DEFAULT_ARK_CLASS_NAME } + + assertEquals(expected = listOf("value", "100", "0"), actual = counter.fields.map { it.name }) + assertNotEquals(illegal = stringMethod.signature, actual = symbolMethod.signature) + assertEquals(expected = "Symbol.iterator", actual = symbolMethod.name) + assertNotEquals(illegal = stringField.signature, actual = symbolField.signature) + for (method in counter.methods) { + assertFalse(method.cfg.stmts.any { it is EtsRawStmt }, message = "Unexpected unsupported body: ${method.name}") + } + assertTrue(dto.exportInfos.any { it.exportName == "Counter" }) + assertFalse(module.methods.single { it.name == "construct" }.cfg.stmts.any { it is EtsRawStmt }) + } + @Test fun `ordinary bodies survive native frontend and model conversion with unsupported lifecycle`() { val dto = frontend( """ - class Base { [Symbol.iterator]() { return [][Symbol.iterator](); } } + const tag: unique symbol = Symbol("tag"); + class Base { [tag]() { return []; } } export class Counter extends Base { own = 7; increment(value: number) { return value + this.own; } diff --git a/jacodb-ets/ts-frontend/src/dto/signatures.ts b/jacodb-ets/ts-frontend/src/dto/signatures.ts index dcdaa02a8..cd0800c53 100644 --- a/jacodb-ets/ts-frontend/src/dto/signatures.ts +++ b/jacodb-ets/ts-frontend/src/dto/signatures.ts @@ -46,6 +46,8 @@ export interface FieldSignatureDto { declaringClass: ClassSignatureDto; name: string; type: TypeDto; + /** Symbol keys remain distinct from string-named lookalikes. */ + wellKnownSymbol?: "iterator"; } export interface MethodParameterDto { @@ -61,6 +63,7 @@ export interface MethodSignatureDto { name: string; parameters: MethodParameterDto[]; returnType: TypeDto; + wellKnownSymbol?: "iterator"; } export interface LocalSignatureDto { diff --git a/jacodb-ets/ts-frontend/src/lowering/astUtils.ts b/jacodb-ets/ts-frontend/src/lowering/astUtils.ts index 58f9eafe2..0b1b57ff2 100644 --- a/jacodb-ets/ts-frontend/src/lowering/astUtils.ts +++ b/jacodb-ets/ts-frontend/src/lowering/astUtils.ts @@ -17,19 +17,67 @@ /** Shared AST helpers used across the lowering modules. */ import * as ts from "typescript"; -import { COMPUTED_MEMBER_NAME, DEFAULT_ARK_CLASS_NAME, Modifier, PATTERN_PARAMETER_PREFIX } from "../dto/constants"; +import { COMPUTED_MEMBER_NAME, CONSTRUCTOR_NAME, DEFAULT_ARK_CLASS_NAME, INSTANCE_INIT_METHOD_NAME, Modifier, PATTERN_PARAMETER_PREFIX, STATIC_INIT_METHOD_NAME } from "../dto/constants"; import { DecoratorDto } from "../dto/model"; import { MethodParameterDto, MethodSignatureDto } from "../dto/signatures"; import { FunctionTypeDto, TypeDto, UNKNOWN_TYPE } from "../dto/types"; import type { LoweringContext } from "./methodBuilder"; export function memberName(name: ts.PropertyName | ts.BindingName | ts.EntityName): string { - if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name) || ts.isPrivateIdentifier(name)) { + const literal = ts.isComputedPropertyName(name) ? name.expression : name; + if (ts.isNumericLiteral(literal)) return String(Number(literal.text)); + if (ts.isStringLiteral(literal) || ts.isNoSubstitutionTemplateLiteral(literal)) return literal.text; + if (ts.isIdentifier(name) || ts.isPrivateIdentifier(name)) { return name.text; } return COMPUTED_MEMBER_NAME; } +export interface ClassMemberKey { + name: string; + wellKnownSymbol?: "iterator"; +} + +/** Literal keys and the unshadowed standard-library iterator key. */ +export function classMemberKey(name: ts.PropertyName, checker: ts.TypeChecker): ClassMemberKey | undefined { + if (!ts.isComputedPropertyName(name)) return { name: memberName(name) }; + + const expression = name.expression; + const isLiteral = ts.isStringLiteral(expression) + || ts.isNoSubstitutionTemplateLiteral(expression) + || ts.isNumericLiteral(expression); + if (isLiteral) return literalClassMemberKey(memberName(name)); + + const isNegativeNumber = ts.isPrefixUnaryExpression(expression) + && expression.operator === ts.SyntaxKind.MinusToken + && ts.isNumericLiteral(expression.operand); + if (isNegativeNumber) return literalClassMemberKey(String(-Number(expression.operand.text))); + + const isIteratorAccess = ts.isPropertyAccessExpression(expression) + && expression.name.text === "iterator" + && ts.isIdentifier(expression.expression) + && expression.expression.text === "Symbol"; + if (!isIteratorAccess) return undefined; + + const symbolDeclarations = resolvedSymbolOf(expression.expression, checker)?.declarations ?? []; + const iteratorDeclarations = resolvedSymbolOf(expression.name, checker)?.declarations ?? []; + const isLibraryDeclaration = (declaration: ts.Declaration): boolean => { + const source = declaration.getSourceFile(); + return source.isDeclarationFile && source.hasNoDefaultLib + && /(?:^|[/\\])lib\.es\d+(?:\.\w+)*\.d\.ts$/.test(source.fileName); + }; + const isIntrinsic = symbolDeclarations.length > 0 && iteratorDeclarations.length > 0 + && symbolDeclarations.every(isLibraryDeclaration) && iteratorDeclarations.every(isLibraryDeclaration); + if (!isIntrinsic) return undefined; + + return { name: "Symbol.iterator", wellKnownSymbol: "iterator" }; +} + +function literalClassMemberKey(name: string): ClassMemberKey | undefined { + const isLifecycleName = [CONSTRUCTOR_NAME, INSTANCE_INIT_METHOD_NAME, STATIC_INIT_METHOD_NAME].includes(name); + return isLifecycleName ? undefined : { name }; +} + /** Definition semantics that prevent constructing a class value, independently of its retained method bodies. */ export function unsupportedClassDeclarationReason(decl: ts.ClassDeclaration, checker: ts.TypeChecker): string | undefined { const visited = new Set(); @@ -37,9 +85,22 @@ export function unsupportedClassDeclarationReason(decl: ts.ClassDeclaration, che if (visited.has(current)) return undefined; visited.add(current); - const hasComputedMember = current.members.some((member) => - member.name !== undefined && ts.isComputedPropertyName(member.name)); - if (hasComputedMember) return "computed class member needs definition-time key evaluation"; + const hasUnsupportedComputedMember = current.members.some((member) => { + if (member.name === undefined || !ts.isComputedPropertyName(member.name)) return false; + + const key = classMemberKey(member.name, checker); + if (key === undefined) return true; + + const hasAccessorCollision = current.members.some((other) => { + if (!ts.isGetAccessorDeclaration(other) && !ts.isSetAccessorDeclaration(other)) return false; + + const otherKey = classMemberKey(other.name, checker); + return otherKey?.name === key.name && otherKey?.wellKnownSymbol === key.wellKnownSymbol + && (modifiersOf(other) & Modifier.STATIC) === (modifiersOf(member) & Modifier.STATIC); + }); + return hasAccessorCollision; + }); + if (hasUnsupportedComputedMember) return "computed class member needs definition-time key evaluation"; const extendsType = current.heritageClauses ?.find((clause) => clause.token === ts.SyntaxKind.ExtendsKeyword) diff --git a/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts index 6059aa90d..4d573677d 100644 --- a/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts +++ b/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts @@ -40,7 +40,7 @@ import { import { BodyDto, ClassDto, FieldDto, MethodDto } from "../dto/model"; import { ClassSignatureDto, UNKNOWN_FILE_SIGNATURE } from "../dto/signatures"; import { ClassTypeDto, TypeDto, NUMBER_TYPE, STRING_TYPE, UNKNOWN_TYPE, VOID_TYPE } from "../dto/types"; -import { buildParameters, decoratorsOf, memberName, modifiersOf, parameterType, returnTypeOf, unsupportedClassDeclarationReason } from "./astUtils"; +import { buildParameters, classMemberKey, ClassMemberKey, decoratorsOf, memberName, modifiersOf, parameterType, returnTypeOf, unsupportedClassDeclarationReason } from "./astUtils"; import { unsupportedStmt } from "./diagnostics"; import { LoweringError, constant } from "./exprLowering"; import { LoweringContext, MethodContext } from "./methodBuilder"; @@ -69,17 +69,25 @@ export class ClassBuilder { const staticInitializers: (ts.PropertyDeclaration | ts.ClassStaticBlockDeclaration)[] = []; const fields: FieldDto[] = []; const methods: MethodDto[] = []; + const computedMethods = new Set(); let ctorDecl: ts.ConstructorDeclaration | undefined; for (const member of decl.members) { - const hasComputedName = member.name !== undefined && ts.isComputedPropertyName(member.name); - if (hasComputedName) { + const key = member.name === undefined ? undefined : classMemberKey(member.name, this.ctx.checker); + const hasUnsupportedComputedName = member.name !== undefined && ts.isComputedPropertyName(member.name) + && (key === undefined || key.name === CONSTRUCTOR_NAME); + if (hasUnsupportedComputedName) { // Keep independent bodies without inventing storage for an unevaluated key. continue; } if (ts.isPropertyDeclaration(member)) { - fields.push(this.buildField(signature, member)); + const field = this.buildField(signature, member); + const previous = fields.findIndex((candidate) => candidate.signature.name === field.signature.name + && candidate.signature.wellKnownSymbol === field.signature.wellKnownSymbol + && (candidate.modifiers & Modifier.STATIC) === (field.modifiers & Modifier.STATIC)); + if (previous >= 0) fields.splice(previous, 1); + fields.push(field); if (member.initializer !== undefined) { if (isStatic(member)) { staticInitializers.push(member); @@ -102,7 +110,22 @@ export class ClassBuilder { ts.isGetAccessorDeclaration(member) || ts.isSetAccessorDeclaration(member) ) { - methods.push(this.buildMethodFromDecl(signature, member)); + const method = this.buildMethodFromDecl(signature, member); + const sameKey = (candidate: MethodDto): boolean => candidate.signature.name === method.signature.name + && candidate.signature.wellKnownSymbol === method.signature.wellKnownSymbol + && (candidate.modifiers & Modifier.STATIC) === (method.modifiers & Modifier.STATIC); + const replacesComputedKey = ts.isComputedPropertyName(member.name) + || methods.some((candidate) => computedMethods.has(candidate) && sameKey(candidate)); + const replacesMethod = definitionFailure === undefined && replacesComputedKey + && ts.isMethodDeclaration(member) && member.body !== undefined; + if (replacesMethod) { + // Runtime definitions replace earlier bodies; overload declarations are retained. + for (let index = methods.length - 1; index >= 0; index--) { + if (methods[index].body !== undefined && sameKey(methods[index])) methods.splice(index, 1); + } + } + if (ts.isComputedPropertyName(member.name)) computedMethods.add(method); + methods.push(method); } else if (ts.isIndexSignatureDeclaration(member)) { fields.push(this.buildIndexSignatureField(signature, member)); } else if (ts.isSemicolonClassElement(member)) { @@ -344,13 +367,14 @@ export class ClassBuilder { decl: ClassMemberDecl | ts.FunctionDeclaration, nameOverride?: string, ): MethodDto { - const name = nameOverride ?? (decl.name !== undefined ? memberName(decl.name) : ""); + const key = decl.name === undefined ? { name: "" } : this.memberKey(decl.name); + const name = nameOverride ?? key.name; const { parameters, prologueParams } = buildParameters(this.ctx, decl); const returnType = returnTypeOf(this.ctx, decl); const method: MethodDto = { isStrictMode: isStrictMode(decl), - signature: { declaringClass, name, parameters, returnType }, + signature: { declaringClass, ...key, name, parameters, returnType }, modifiers: modifiersOf(decl), decorators: decoratorsOf(decl), }; @@ -639,7 +663,7 @@ export class ClassBuilder { left: { _: "InstanceFieldRef", instance: thisLocal, - field: { declaringClass, name: memberName(field.name), type: fieldType }, + field: { declaringClass, ...this.memberKey(field.name), type: fieldType }, }, right: lowerer.expr.lowerToImmediate(field.initializer!), }); @@ -671,7 +695,7 @@ export class ClassBuilder { _: "StaticFieldRef", field: { declaringClass, - name: memberName(initializer.name), + ...this.memberKey(initializer.name), type: this.fieldType(initializer), }, }, @@ -700,7 +724,7 @@ export class ClassBuilder { return { signature: { declaringClass, - name: memberName(decl.name), + ...this.memberKey(decl.name), type: this.fieldType(decl), }, modifiers: modifiersOf(decl), @@ -710,6 +734,10 @@ export class ClassBuilder { }; } + private memberKey(name: ts.PropertyName): ClassMemberKey { + return classMemberKey(name, this.ctx.checker) ?? { name: memberName(name) }; + } + private buildParameterPropertyField( declaringClass: ClassSignatureDto, p: ts.ParameterDeclaration, diff --git a/jacodb-ets/ts-frontend/test/classes.spec.ts b/jacodb-ets/ts-frontend/test/classes.spec.ts index 8c8be7803..7f7f8b922 100644 --- a/jacodb-ets/ts-frontend/test/classes.spec.ts +++ b/jacodb-ets/ts-frontend/test/classes.spec.ts @@ -45,7 +45,7 @@ describe("class lowering", () => { const { file, diagnostics } = lower(` let evaluations = 0; function key(): "dynamic" { evaluations++; return "dynamic"; } - export class Constant { ["x"] = 1; } + export class Constant { [String("x")] = 1; } class SideEffect { [key()] = 2; } class StaticKey { static [key()] = 3; } class NoInitializer { [key()]: number; } @@ -86,7 +86,7 @@ describe("class lowering", () => { it("keeps derived lifecycle unsupported transitively without dropping class bodies", () => { const source = ` - export class Base { ["x"] = 1; } + export class Base { [String("x")] = 1; } export class Derived extends Base { own = 2; } export class Grandchild extends Derived {} class OrdinaryBase { value = 3; } @@ -115,7 +115,7 @@ describe("class lowering", () => { it("retains bodies through an imported base alias while rejecting its lifecycle", () => { const { file, diagnostics } = lowerProject({ - "/base.ts": `export class Base { ["x"] = 1; }`, + "/base.ts": `export class Base { [String("x")] = 1; }`, "/consumer.ts": ` import { Base as ImportedBase } from "./base"; export class Derived extends ImportedBase {} @@ -138,7 +138,7 @@ describe("class lowering", () => { it("keeps local value and mutable extends aliases raw instead of inventing a fixed base", () => { const source = ` - class Base { ["x"] = 1; } + class Base { [String("x")] = 1; } const Alias = Base; export class Derived extends Alias {} export class Grandchild extends Derived {} diff --git a/jacodb-ets/ts-frontend/test/computed-class-members.spec.ts b/jacodb-ets/ts-frontend/test/computed-class-members.spec.ts new file mode 100644 index 000000000..3e8359b2a --- /dev/null +++ b/jacodb-ets/ts-frontend/test/computed-class-members.spec.ts @@ -0,0 +1,184 @@ +import * as ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { ClassDto, EtsFileDto, MethodDto } from "../src/dto/model"; +import { serializeEtsFile } from "../src/serialize"; +import { executor } from "./execute"; +import { lower } from "./util"; + +function classNamed(file: EtsFileDto, name: string): ClassDto { + const clazz = file.classes.find((candidate) => candidate.signature.name === name); + if (clazz === undefined) throw new Error(`Missing class ${name}`); + return clazz; +} + +function hasUnsupported(method: MethodDto): boolean { + return method.body?.cfg.blocks.some((block) => block.stmts.some((statement) => + statement._ === "UnsupportedStmt" + || (statement._ === "AssignStmt" && statement.right._ === "UnsupportedValue"), + )) ?? false; +} + +function nativeExports(source: string): any { + const javascript = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS }, + }).outputText; + return new Function("exports", `${javascript}; return exports;`)({}); +} + +describe("constant computed class members", () => { + it.each([ + { declaration: "values", name: "values" }, + { declaration: "['values']", name: "values" }, + { declaration: "[`values`]", name: "values" }, + { declaration: "[1e2]", name: "100" }, + { declaration: "[-3]", name: "-3" }, + { declaration: "[-0]", name: "0" }, + { declaration: "[Symbol.iterator]", name: "Symbol.iterator", wellKnownSymbol: "iterator" }, + ])("retains lifecycle and ordinary bodies for $declaration", ({ declaration, name, wellKnownSymbol }) => { + const source = ` + export class Counter { + ${declaration}(value: number) { return value - 1; } + score(value: number) { return value < 0 ? -value : value + 1; } + } + export function construct() { return new Counter(); } + `; + const { file, diagnostics } = lower(source); + const roundTrip = JSON.parse(serializeEtsFile(file)) as EtsFileDto; + const clazz = classNamed(roundTrip, "Counter"); + const key = wellKnownSymbol === "iterator" ? Symbol.iterator : name; + const native = nativeExports(source); + const run = executor({ ...roundTrip, classes: [clazz] }); + + expect(clazz.methods.find((method) => method.signature.name === name)?.signature.wellKnownSymbol) + .toBe(wellKnownSymbol); + expect(clazz.methods.every((method) => !hasUnsupported(method))).toBe(true); + expect(classNamed(roundTrip, "%dflt").methods.every((method) => !hasUnsupported(method))).toBe(true); + expect(file.exportInfos.some((info) => info.exportName === "Counter")).toBe(true); + expect(diagnostics.messages).toEqual([]); + for (const value of [-3, 0, 8]) { + expect(run.call("score", value)).toBe(native.Counter.prototype.score.call({}, value)); + expect(run.call(name, value)).toBe(native.Counter.prototype[key].call({}, value)); + } + }); + + it("retains symbol and string lookalikes as different member identities", () => { + const source = ` + export class Keys { + [Symbol.iterator]() { return 7; } + ['Symbol.iterator']() { return 9; } + } + `; + const { file } = lower(source); + const roundTrip = JSON.parse(serializeEtsFile(file)) as EtsFileDto; + const clazz = classNamed(roundTrip, "Keys"); + const members = clazz.methods.filter((method) => method.signature.name === "Symbol.iterator"); + const native = nativeExports(source); + + expect(members).toHaveLength(2); + expect(members.map((method) => method.signature.wellKnownSymbol)).toEqual(["iterator", undefined]); + for (const member of members) { + const run = executor({ ...roundTrip, classes: [{ ...clazz, methods: [member] }] }); + const key = member.signature.wellKnownSymbol === "iterator" ? Symbol.iterator : "Symbol.iterator"; + + expect(run.call("Symbol.iterator")).toBe(native.Keys.prototype[key].call({})); + } + }); + + it("preserves initializer order and numeric property keys", () => { + const source = ` + export class Keys { + static trace = 0; + static ['first'] = (Keys.trace = Keys.trace * 10 + 1); + static [1e2] = (Keys.trace = Keys.trace * 10 + 2); + static [-0] = (Keys.trace = Keys.trace * 10 + 3); + static result() { return Keys.trace; } + } + `; + const { file } = lower(source); + const clazz = classNamed(file, "Keys"); + const run = executor({ ...file, classes: [clazz] }); + + run.call("%statInit"); + + expect(clazz.fields.map((field) => field.signature.name)).toEqual(["trace", "first", "100", "0"]); + expect(run.call("result")).toBe(nativeExports(source).Keys.result()); + expect(run.call("result")).toBe(123); + }); + + it("keeps the last method for a repeated runtime key", () => { + const source = ` + export class Keys { + values() { return 1; } + ['values']() { return 2; } + static ['values']() { return 3; } + } + `; + const { file } = lower(source); + const clazz = classNamed(file, "Keys"); + const methods = clazz.methods.filter((method) => method.signature.name === "values"); + const native = nativeExports(source); + + expect(methods).toHaveLength(2); + expect(executor({ ...file, classes: [{ ...clazz, methods: [methods[0]] }] }).call("values")) + .toBe(native.Keys.prototype.values.call({})); + expect(executor({ ...file, classes: [{ ...clazz, methods: [methods[1]] }] }).call("values")) + .toBe(native.Keys.values()); + }); + + it("shares storage for equal string and numeric keys while retaining both initializers", () => { + const source = ` + export class Keys { + static trace = 0; + static ['100'] = (Keys.trace = Keys.trace * 10 + 1); + static [1e2] = (Keys.trace = Keys.trace * 10 + 2); + static result() { return Keys['100'] + Keys.trace; } + } + `; + const { file } = lower(source); + const clazz = classNamed(file, "Keys"); + const initializers = clazz.methods.find((method) => method.signature.name === "%statInit")! + .body!.cfg.blocks.flatMap((block) => block.stmts); + const assignments = initializers.filter((statement) => statement._ === "AssignStmt" + && statement.left._ === "StaticFieldRef" && statement.left.field.name === "100"); + + expect(clazz.fields.filter((field) => field.signature.name === "100")).toHaveLength(1); + expect(assignments).toHaveLength(2); + expect(nativeExports(source).Keys.result()).toBe(24); + }); + + it.each([ + "get ['values']() { return 1; }", + "get values() { return 1; } set values(value: number) {} ['values']() { return 2; }", + "['values']() { return 2; } get values() { return 1; }", + ])("keeps computed accessor definitions and collisions explicitly unsupported: %s", (members) => { + const { file } = lower(`export class Counter { ${members} }`); + const clazz = classNamed(file, "Counter"); + const constructor = clazz.methods.find((method) => method.signature.name === "constructor")!; + + expect(hasUnsupported(constructor)).toBe(true); + expect(file.exportInfos.some((info) => info.exportName === "Counter")).toBe(false); + }); + + it.each([ + "[key()]() { return 1; }", + "[Symbol.iterator]() { return 1; }", + "['constructor']() { return 1; }", + ])("leaves unsupported definition-time evaluation explicit: %s", (member) => { + const source = ` + let effects = 0; + function key() { effects++; return 'values'; } + const Symbol = { get iterator() { effects++; return 'values'; } }; + export class Counter { ${member} score(value: number) { return value + 1; } } + export function count() { return effects; } + `; + const { file } = lower(source); + const clazz = classNamed(file, "Counter"); + + expect(clazz.methods.find((method) => method.signature.name === "constructor")?.body?.cfg.blocks + .flatMap((block) => block.stmts)).toContainEqual(expect.objectContaining({ _: "UnsupportedStmt" })); + expect(clazz.methods.find((method) => method.signature.name === "score")).toBeDefined(); + expect(clazz.methods.some((method) => method.signature.wellKnownSymbol !== undefined)).toBe(false); + expect(file.exportInfos.some((info) => info.exportName === "Counter")).toBe(false); + expect(nativeExports(source).count()).toBe(member.startsWith("['constructor']") ? 0 : 1); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/partial-class-bodies.spec.ts b/jacodb-ets/ts-frontend/test/partial-class-bodies.spec.ts index 9aa72434b..5e088b491 100644 --- a/jacodb-ets/ts-frontend/test/partial-class-bodies.spec.ts +++ b/jacodb-ets/ts-frontend/test/partial-class-bodies.spec.ts @@ -29,7 +29,7 @@ function hasUnsupported(method: MethodDto): boolean { describe("independent bodies of classes with unsupported definitions", () => { it.each([ - { name: "computed ancestor", setup: "class Base { [Symbol.iterator]() { return [][Symbol.iterator](); } }", base: "Base" }, + { name: "computed ancestor", setup: "class Base { [String('iterator')]() { return []; } }", base: "Base" }, { name: "ambient DOM constructor", setup: "", base: "HTMLElement" }, { name: "ambient Map constructor", setup: "", base: "Map", runtimeBase: "Map" }, { name: "mutable constructor alias", setup: "class Plain {} let Alias: typeof Plain = Plain;", base: "Alias" }, @@ -151,7 +151,8 @@ describe("independent bodies of classes with unsupported definitions", () => { it("retains lifted field closure bodies without constructing their captured environment", () => { const { file } = lower(` - class Base { [Symbol.iterator]() { return []; } } + const tag: unique symbol = Symbol("tag"); + class Base { [tag]() { return []; } } class Target extends Base { own = 4; calculate = (value: number) => this.own + value;