From 81fa51805b1a494c500020dbb047a8edf6725fd5 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Fri, 16 Oct 2026 11:37:26 +0300 Subject: [PATCH 1/2] Preserve independent bodies of classes with unsupported definitions --- .../jacodb/ets/test/EtsTsPartialClassTest.kt | 109 ++++++++++ jacodb-ets/ts-frontend/README.md | 20 +- .../ts-frontend/src/lowering/astUtils.ts | 2 +- .../ts-frontend/src/lowering/classBuilder.ts | 63 +++++- .../ts-frontend/src/lowering/exprLowering.ts | 86 +++++++- .../ts-frontend/src/lowering/fileBuilder.ts | 5 +- jacodb-ets/ts-frontend/test/classes.spec.ts | 29 +-- .../test/partial-class-bodies.spec.ts | 186 ++++++++++++++++++ 8 files changed, 465 insertions(+), 35 deletions(-) create mode 100644 jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsPartialClassTest.kt create mode 100644 jacodb-ets/ts-frontend/test/partial-class-bodies.spec.ts 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 new file mode 100644 index 000000000..40ab32fa9 --- /dev/null +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsPartialClassTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2022 UnitTestBot contributors (utbot.org) + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jacodb.ets.test + +import kotlin.io.path.createTempDirectory +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.jacodb.ets.dto.AssignStmtDto +import org.jacodb.ets.dto.EtsFileDto +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.utils.DEFAULT_ARK_CLASS_NAME +import org.jacodb.ets.utils.DEFAULT_ARK_METHOD_NAME +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.generateEtsIR +import org.junit.jupiter.api.Test + +class EtsTsPartialClassTest { + private fun frontend(source: String): EtsFileDto { + val input = createTempDirectory("ts-partial-class").resolve("test.ts") + input.writeText(source) + + val output = generateEtsIR( + input, + isProject = false, + timeout = null, + provider = EtsIrProvider.TS_FRONTEND, + ) + return EtsFileDto.loadFromJson(output.readText()) + } + + @Test + fun `ordinary bodies survive native frontend and model conversion with unsupported lifecycle`() { + val dto = frontend( + """ + class Base { [Symbol.iterator]() { return [][Symbol.iterator](); } } + export class Counter extends Base { + own = 7; + increment(value: number) { return value + this.own; } + } + export class Registry extends Map { + positive(value: number) { return value > 0; } + } + """.trimIndent(), + ) + + val model = dto.toEtsFile() + for (name in listOf("Counter", "Registry")) { + val clazz = model.classes.single { it.name == name } + for (methodName in listOf("constructor", "%instInit", "%statInit")) { + val lifecycle = clazz.methods.single { it.name == methodName } + assertTrue(lifecycle.cfg.stmts.any { it is EtsRawStmt }) + } + } + val counter = dto.classes.single { it.signature.name == "Counter" } + val body = counter.methods.single { it.signature.name == "increment" } + .body!!.cfg.blocks.flatMap { it.stmts } + assertTrue(body.isNotEmpty()) + assertFalse(body.filterIsInstance().any { it.right is RawValueDto }) + assertEquals("Base", counter.superClassName) + assertTrue(dto.exportInfos.none { it.exportName == "Counter" || it.exportName == "Registry" }) + + val module = dto.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME } + val statements = module.methods.single { it.signature.name == DEFAULT_ARK_METHOD_NAME } + .body!!.cfg.blocks.flatMap { it.stmts } + assertEquals(3, statements.filterIsInstance().count { it.kind == "UnsupportedStmt" }) + } + + @Test + fun `native frontend retains explicit barriers for unresolved base operations`() { + val dto = frontend( + """ + export class Element extends HTMLElement { + own = 1; + independent(value: number) { return value + this.own; } + dependent() { return this.remove(); } + } + """.trimIndent(), + ) + val clazz = dto.classes.single { it.signature.name == "Element" } + + val independent = clazz.methods.single { it.signature.name == "independent" } + .body!!.cfg.blocks.flatMap { it.stmts }.filterIsInstance() + val dependent = clazz.methods.single { it.signature.name == "dependent" } + .body!!.cfg.blocks.flatMap { it.stmts }.filterIsInstance() + assertFalse(independent.any { it.right is RawValueDto }) + assertTrue(dependent.any { it.right is RawValueDto }) + assertEquals("HTMLElement", dto.toEtsFile().classes.single { it.name == "Element" }.superClass?.name) + } +} diff --git a/jacodb-ets/ts-frontend/README.md b/jacodb-ets/ts-frontend/README.md index 33b6df679..1f0845765 100644 --- a/jacodb-ets/ts-frontend/README.md +++ b/jacodb-ets/ts-frontend/README.md @@ -107,13 +107,19 @@ computed methods. Anonymous-class method metadata alone cannot install a fresh callable own property at each object evaluation in source order. Data properties containing function values and getter descriptors keep their existing lowering paths. -Class declarations with computed property, method, or accessor names use a -whole-declaration raw fallback, as do classes inheriting from such a declaration. -EtsIR class fields cannot represent the definition-time evaluation of those -keys or their runtime names. An `extends` value that does not resolve directly -to a class declaration also uses this fallback; its runtime base cannot be -fixed from the source name alone. Class expressions with heritage remain -unsupported by the existing expression fallback. +Class declarations with computed names or an unresolved runtime base retain +their ordinary named method bodies for independent analysis on an existing +symbolic receiver. Their module declaration, constructor, and instance/static +initializers remain explicitly unsupported. They do not supply a runtime class +value or runtime exports, and computed members do not become invented fields. +Member lookup depending on the missing base/prototype is unsupported; direct +access to declared own data fields is retained only when computed string keys +cannot redefine them. No construction or definition-time effects are claimed. +These restrictions also apply transitively through base declarations. Class +field initializer closures retain lifted body metadata, but the unsupported +initializer does not create their captured environments; this does not make +them standalone analysis entries. Class expressions with heritage retain their +existing expression fallback. Default derived constructors forward fixed parameters through constructor-free ancestors. When an inherited constructor has a rest parameter, the synthetic constructor emits an unsupported statement: the current call IR cannot forward diff --git a/jacodb-ets/ts-frontend/src/lowering/astUtils.ts b/jacodb-ets/ts-frontend/src/lowering/astUtils.ts index 6ce52b7a1..58f9eafe2 100644 --- a/jacodb-ets/ts-frontend/src/lowering/astUtils.ts +++ b/jacodb-ets/ts-frontend/src/lowering/astUtils.ts @@ -30,7 +30,7 @@ export function memberName(name: ts.PropertyName | ts.BindingName | ts.EntityNam return COMPUTED_MEMBER_NAME; } -/** Reject unsupported members, unsupported bases, and runtime base values without a stable class declaration. */ +/** 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(); const reasonOf = (current: ts.ClassDeclaration): string | undefined => { diff --git a/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts index 73930899e..6059aa90d 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 } from "./astUtils"; +import { buildParameters, decoratorsOf, memberName, modifiersOf, parameterType, returnTypeOf, unsupportedClassDeclarationReason } from "./astUtils"; import { unsupportedStmt } from "./diagnostics"; import { LoweringError, constant } from "./exprLowering"; import { LoweringContext, MethodContext } from "./methodBuilder"; @@ -60,6 +60,9 @@ export class ClassBuilder { const signature = nameOverride === undefined ? convertedSignature : { ...convertedSignature, name: nameOverride }; + const definitionFailure = ts.isClassDeclaration(decl) + ? unsupportedClassDeclarationReason(decl, this.ctx.checker) + : undefined; const superClass = this.superClassInfo(decl); const instanceFields: ts.PropertyDeclaration[] = []; @@ -69,6 +72,12 @@ export class ClassBuilder { let ctorDecl: ts.ConstructorDeclaration | undefined; for (const member of decl.members) { + const hasComputedName = member.name !== undefined && ts.isComputedPropertyName(member.name); + if (hasComputedName) { + // Keep independent bodies without inventing storage for an unevaluated key. + continue; + } + if (ts.isPropertyDeclaration(member)) { fields.push(this.buildField(signature, member)); if (member.initializer !== undefined) { @@ -105,13 +114,28 @@ export class ClassBuilder { } } - methods.push(this.buildInstInit(signature, instanceFields)); - methods.push(this.buildStatInit(signature, staticInitializers)); - methods.push( - ctorDecl !== undefined - ? this.buildConstructor(signature, ctorDecl, superClass?.signature) - : this.synthesizeDefaultConstructor(signature, decl, superClass), - ); + if (definitionFailure === undefined) { + methods.push(this.buildInstInit(signature, instanceFields)); + methods.push(this.buildStatInit(signature, staticInitializers)); + methods.push( + ctorDecl !== undefined + ? this.buildConstructor(signature, ctorDecl, superClass?.signature) + : this.synthesizeDefaultConstructor(signature, decl, superClass), + ); + } else { + this.ctx.diagnostics.warn(decl, `class bodies retained without definition semantics: ${definitionFailure}`); + // Lower initializers to retain their lifted closure bodies, then discard + // the executable initialization. No captured environment is constructed. + const initializers = [ + this.buildInstInit(signature, instanceFields), + this.buildStatInit(signature, staticInitializers), + ]; + for (const initializer of initializers) { + initializer.body = this.unsupportedClassLifecycle(signature, decl, initializer.signature.name).body; + methods.push(initializer); + } + methods.push(this.unsupportedClassLifecycle(signature, decl, CONSTRUCTOR_NAME, ctorDecl)); + } const result: ClassDto = { signature, @@ -130,6 +154,29 @@ export class ClassBuilder { return result; } + private unsupportedClassLifecycle( + declaringClass: ClassSignatureDto, + declaration: ts.ClassLikeDeclaration, + name: string, + constructor?: ts.ConstructorDeclaration, + ): MethodDto { + const m = new MethodContext(this.ctx, declaringClass, name); + m.emitPrologue([]); + m.withOrigin(declaration, () => m.cfg.emit(unsupportedStmt(declaration))); + + const parameters = constructor === undefined ? [] : buildParameters(this.ctx, constructor).parameters; + const returnType: TypeDto = name === CONSTRUCTOR_NAME + ? { _: "ClassType", signature: declaringClass } + : VOID_TYPE; + return { + signature: { declaringClass, name, parameters, returnType }, + modifiers: name === STATIC_INIT_METHOD_NAME ? Modifier.STATIC : 0, + decorators: [], + isStrictMode: true, + body: m.build(), + }; + } + // ------------------------------------------------------------------ // Interfaces // ------------------------------------------------------------------ diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index e890addc8..acc70e71e 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -484,6 +484,7 @@ export class ExprLowerer { } const instance = this.lowerToLocal(node.expression); + this.ensureSupportedClassMemberLookup(node); const isObjectLiteralAccessor = this.m.converter.symbolOf(node.name)?.declarations?.some((declaration) => ts.isGetAccessorDeclaration(declaration) && ts.isObjectLiteralExpression(declaration.parent), ); @@ -526,10 +527,12 @@ export class ExprLowerer { throw new LoweringError("computed namespace constructor access has no runtime namespace object in EtsIR"); } const instance = this.snapshotToLocal(node.expression); + const key = this.propertyAccessKey(this.lowerToImmediate(node.argumentExpression), instance); + this.ensureSupportedClassMemberLookup(node); return { _: "PropertyRef", instance, - key: this.propertyAccessKey(this.lowerToImmediate(node.argumentExpression), instance), + key, type: this.safeTypeOf(node), }; } @@ -678,6 +681,7 @@ export class ExprLowerer { } if (ts.isPropertyAccessExpression(node)) { + this.ensureSupportedClassMemberLookup(node); const fieldType = this.safeTypeOf(node); const isObjectLiteralAccessor = this.m.converter.symbolOf(node.name)?.declarations?.some((declaration) => ts.isGetAccessorDeclaration(declaration) && ts.isObjectLiteralExpression(declaration.parent), @@ -701,10 +705,12 @@ export class ExprLowerer { this.lowerToImmediate(node.argumentExpression); throw new LoweringError("computed namespace constructor access has no runtime namespace object in EtsIR"); } + const key = this.propertyAccessKey(this.lowerToImmediate(node.argumentExpression), instance); + this.ensureSupportedClassMemberLookup(node); return { _: "PropertyRef", instance, - key: this.propertyAccessKey(this.lowerToImmediate(node.argumentExpression), instance), + key, type: this.safeTypeOf(node), }; } @@ -761,6 +767,7 @@ export class ExprLowerer { if (!this.m.checker.isArrayType(receiverType) && !this.m.checker.isTupleType(receiverType)) { const instance = this.snapshotToLocal(node.expression); const key = this.snapshotToLocal(node.argumentExpression); + this.ensureSupportedClassMemberLookup(node); // Reference evaluation retains the raw key; Get and Put each convert it. return { _: "PropertyRef", instance, key, type: this.safeTypeOf(node) }; @@ -1146,6 +1153,7 @@ export class ExprLowerer { // `super.m(...)` — instance call on `this` with the superclass as declaring class. if (ts.isPropertyAccessExpression(callee) && callee.expression.kind === ts.SyntaxKind.SuperKeyword) { + this.ensureSupportedClassMemberLookup(callee); const isAccessorCall = this.m.converter.symbolOf(callee.name)?.declarations?.some(ts.isGetAccessorDeclaration); if (isAccessorCall) { throw new LoweringError("super getter call needs superclass-relative property lookup"); @@ -1168,6 +1176,7 @@ export class ExprLowerer { // `this.m()` inside a static method targets a static member of the // current class, just like `C.m()`. if (callee.expression.kind === ts.SyntaxKind.ThisKeyword && this.m.isStaticMethod) { + this.ensureSupportedClassMemberLookup(callee); return { _: "StaticCallExpr", method: this.methodSignatureForCall(node, methodName, this.m.declaringClass), @@ -1222,10 +1231,12 @@ export class ExprLowerer { } const receiver = this.snapshotToLocal(callee.expression); + const key = this.propertyAccessKey(this.lowerToImmediate(callee.argumentExpression), receiver); + this.ensureSupportedClassMemberLookup(callee); const ptr = this.materialize({ _: "PropertyRef", instance: receiver, - key: this.propertyAccessKey(this.lowerToImmediate(callee.argumentExpression), receiver), + key, type: this.safeTypeOf(callee), }, this.safeTypeOf(callee)); return { @@ -1550,6 +1561,7 @@ export class ExprLowerer { const key = ts.isPropertyAccessExpression(tag) ? constant(tag.name.text, STRING_TYPE) : this.propertyAccessKey(this.lowerToImmediate(tag.argumentExpression), receiver); + this.ensureSupportedClassMemberLookup(tag); ptr = this.materialize({ _: "PropertyRef", instance: receiver, @@ -1995,6 +2007,74 @@ export class ExprLowerer { return this.m.converter.typeOfNode(node); } + /** Method entry does not prove that an unsupported base/prototype was constructed. */ + private ensureSupportedClassMemberLookup(node: ts.PropertyAccessExpression | ts.ElementAccessExpression): void { + const receiver = unwrapTransparentExpression(node.expression); + const receiverType = this.m.checker.getNonNullableType(this.m.checker.getTypeAtLocation(receiver)); + const declarations = this.receiverClassDeclarations(receiverType); + if (node.expression.kind === ts.SyntaxKind.SuperKeyword) { + let enclosing: ts.Node | undefined = node.parent; + while (enclosing !== undefined && !ts.isClassDeclaration(enclosing)) enclosing = enclosing.parent; + if (enclosing !== undefined && ts.isClassDeclaration(enclosing)) declarations.add(enclosing); + } + + const property = ts.isPropertyAccessExpression(node) ? this.m.converter.symbolOf(node.name) : undefined; + for (const declaration of declarations) { + const definitionFailure = unsupportedClassDeclarationReason(declaration, this.m.checker); + if (definitionFailure === undefined) continue; + + const isOwnDataField = property?.declarations?.some((member) => + ts.isPropertyDeclaration(member) + && member.parent === declaration + && !ts.isComputedPropertyName(member.name), + ) ?? false; + const needsDefinition = !isOwnDataField || this.hasUnrepresentedStringKey(declaration); + if (needsDefinition) { + throw new LoweringError(`member lookup depends on unsupported class definition: ${definitionFailure}`); + } + } + } + + private receiverClassDeclarations(type: ts.Type): Set { + const declarations = new Set(); + const visited = new Set(); + const collect = (current: ts.Type): void => { + if (visited.has(current)) return; + visited.add(current); + + for (const declaration of current.getSymbol()?.declarations ?? []) { + if (ts.isClassDeclaration(declaration)) declarations.add(declaration); + } + if (current.isUnionOrIntersection()) current.types.forEach(collect); + + const constraint = this.m.checker.getBaseConstraintOfType(current); + if (constraint !== undefined) collect(constraint); + const apparent = this.m.checker.getApparentType(current); + if (apparent !== current) collect(apparent); + }; + + collect(type); + return declarations; + } + + private hasUnrepresentedStringKey(declaration: ts.ClassDeclaration, visited = new Set()): boolean { + if (visited.has(declaration)) return false; + visited.add(declaration); + + const hasStringKey = declaration.members.some((member) => { + if (member.name === undefined || !ts.isComputedPropertyName(member.name)) return false; + const keyType = this.m.checker.getTypeAtLocation(member.name.expression); + return (keyType.flags & ts.TypeFlags.ESSymbolLike) === 0; + }); + if (hasStringKey) return true; + + const extended = declaration.heritageClauses + ?.find((clause) => clause.token === ts.SyntaxKind.ExtendsKeyword)?.types[0]; + if (extended === undefined) return false; + const base = classLikeDeclarationOf(extended.expression, this.m.checker); + return base !== undefined && ts.isClassDeclaration(base) && this.hasUnrepresentedStringKey(base, visited); + } + private importedScopeFunctionField(node: ts.PropertyAccessExpression): StaticFieldRefDto | undefined { const receiver = unwrapTransparentExpression(node.expression); if (!ts.isIdentifier(node.name) || !ts.isIdentifier(receiver)) return undefined; diff --git a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts index 678cacfed..d11729021 100644 --- a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts +++ b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts @@ -394,9 +394,8 @@ class FileBuilder { methods.push(this.classBuilder.buildMethodFromDecl(defaultClassSignature, statement, name)); } } else if (ts.isClassDeclaration(statement)) { - if (unsupportedClassDeclarationReason(statement, this.ctx.checker) === undefined) { - classes.push(this.classBuilder.buildClass(statement, anonymousDefaultExportBinding(statement))); - } + // Class definition can be unsupported while individual method bodies remain useful. + classes.push(this.classBuilder.buildClass(statement, anonymousDefaultExportBinding(statement))); } else if (ts.isInterfaceDeclaration(statement)) { classes.push(this.classBuilder.buildInterface(statement)); } else if (ts.isEnumDeclaration(statement)) { diff --git a/jacodb-ets/ts-frontend/test/classes.spec.ts b/jacodb-ets/ts-frontend/test/classes.spec.ts index ff18518c8..8c8be7803 100644 --- a/jacodb-ets/ts-frontend/test/classes.spec.ts +++ b/jacodb-ets/ts-frontend/test/classes.spec.ts @@ -32,8 +32,16 @@ function constructorCallOf(stmt: StmtDto): InstanceCallExprDto | undefined { return value?._ === "InstanceCallExpr" && value.method.name === "constructor" ? value : undefined; } +function expectUnsupportedLifecycle(clazz: ClassDto): void { + for (const name of ["constructor", "%instInit", "%statInit"]) { + const statements = methodOf(clazz, name).body!.cfg.blocks.flatMap((block) => block.stmts); + expect(statements).toContainEqual(expect.objectContaining({ _: "UnsupportedStmt", kindName: "ClassDeclaration" })); + expect(statements.some((statement) => constructorCallOf(statement) !== undefined)).toBe(false); + } +} + describe("class lowering", () => { - it("keeps computed class declarations wholly raw before claiming class storage", () => { + it("keeps computed class definition and lifecycle raw while retaining body declarations", () => { const { file, diagnostics } = lower(` let evaluations = 0; function key(): "dynamic" { evaluations++; return "dynamic"; } @@ -59,7 +67,7 @@ describe("class lowering", () => { .toEqual(rejected.map((name) => expect.stringContaining(`class ${name}`))); expect(defaultBody.stmtOrigins?.filter((origin) => origin.source.nodeKind === "ClassDeclaration")) .toHaveLength(rejected.length); - expect(file.classes.filter((clazz) => rejected.includes(clazz.signature.name))).toEqual([]); + for (const name of rejected) expectUnsupportedLifecycle(classByName(file, name)); expect(file.classes.flatMap((clazz) => clazz.fields).some((field) => field.signature.name === "%computed")) .toBe(false); expect(file.exportInfos.some((info) => info.exportName === "Constant")).toBe(false); @@ -76,7 +84,7 @@ describe("class lowering", () => { .toHaveLength(rejected.length); }); - it("rejects derived declarations transitively when a computed base has no class DTO", () => { + it("keeps derived lifecycle unsupported transitively without dropping class bodies", () => { const source = ` export class Base { ["x"] = 1; } export class Derived extends Base { own = 2; } @@ -96,12 +104,8 @@ describe("class lowering", () => { expect(rawClasses).toHaveLength(rejected.length); expect(rawOrigins.map((origin) => source.slice(origin.source.startOffset, origin.source.endOffset))) .toEqual(rejected.map((name) => expect.stringContaining(`class ${name}`))); - expect(file.classes.filter((clazz) => rejected.includes(clazz.signature.name))).toEqual([]); + for (const name of rejected) expectUnsupportedLifecycle(classByName(file, name)); expect(file.exportInfos.filter((info) => rejected.includes(info.exportName))).toEqual([]); - expect(file.classes.flatMap((clazz) => clazz.methods).some((method) => - method.signature.name === "constructor" - && rejected.includes(method.signature.declaringClass.name), - )).toBe(false); expect(methodOf(classByName(file, "%dflt"), "refer").body!.cfg.blocks.flatMap((block) => block.stmts)) .toContainEqual(expect.objectContaining({ _: "AssignStmt", right: expect.objectContaining({ _: "UnsupportedValue" }) })); expect(classByName(file, "OrdinaryDerived").superClassName).toBe("OrdinaryBase"); @@ -109,7 +113,7 @@ describe("class lowering", () => { .toHaveLength(rejected.length); }); - it("rejects a derived declaration through an imported base alias", () => { + it("retains bodies through an imported base alias while rejecting its lifecycle", () => { const { file, diagnostics } = lowerProject({ "/base.ts": `export class Base { ["x"] = 1; }`, "/consumer.ts": ` @@ -126,11 +130,10 @@ describe("class lowering", () => { .filter((stmt) => stmt._ === "UnsupportedStmt" && stmt.kindName === "ClassDeclaration"); expect(rawClasses).toHaveLength(2); - expect(file.classes.some((clazz) => clazz.signature.name === "Derived" || clazz.signature.name === "Grandchild")) - .toBe(false); + for (const name of ["Derived", "Grandchild"]) expectUnsupportedLifecycle(classByName(file, name)); expect(file.exportInfos.some((info) => info.exportName === "Derived")).toBe(false); expect(classByName(file, "OrdinaryDerived").superClassName).toBe("OrdinaryBase"); - expect(diagnostics.messages.filter((message) => message.includes("base class"))).toHaveLength(2); + expect(diagnostics.messages.filter((message) => message.includes("unsupported statement: base class"))).toHaveLength(2); }); it("keeps local value and mutable extends aliases raw instead of inventing a fixed base", () => { @@ -156,7 +159,7 @@ describe("class lowering", () => { expect(rawClasses).toHaveLength(rejected.length); expect(rawOrigins.map((origin) => source.slice(origin.source.startOffset, origin.source.endOffset))) .toEqual(rejected.map((name) => expect.stringContaining(`class ${name}`))); - expect(file.classes.filter((clazz) => rejected.includes(clazz.signature.name))).toEqual([]); + for (const name of rejected) expectUnsupportedLifecycle(classByName(file, name)); expect(file.exportInfos.filter((info) => rejected.includes(info.exportName))).toEqual([]); expect(file.classes.some((clazz) => clazz.signature.name.startsWith("%ACExpr"))).toBe(false); expect(defaultBody.cfg.blocks.flatMap((block) => block.stmts)) diff --git a/jacodb-ets/ts-frontend/test/partial-class-bodies.spec.ts b/jacodb-ets/ts-frontend/test/partial-class-bodies.spec.ts new file mode 100644 index 000000000..9aa72434b --- /dev/null +++ b/jacodb-ets/ts-frontend/test/partial-class-bodies.spec.ts @@ -0,0 +1,186 @@ +import * as ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { ClassDto, EtsFileDto, MethodDto } from "../src/dto/model"; +import { StmtDto } from "../src/dto/stmts"; +import { serializeEtsFile } from "../src/serialize"; +import { executor } from "./execute"; +import { lower } from "./util"; + +function classNamed(file: EtsFileDto, name: string): ClassDto { + const result = file.classes.find((candidate) => candidate.signature.name === name); + if (result === undefined) throw new Error(`Missing class ${name}`); + return result; +} + +function methodNamed(clazz: ClassDto, name: string): MethodDto { + const method = clazz.methods.find((candidate) => candidate.signature.name === name); + if (method === undefined) throw new Error(`Missing method ${name}`); + return method; +} + +function statements(method: MethodDto): StmtDto[] { + return method.body?.cfg.blocks.flatMap((block) => block.stmts) ?? []; +} + +function hasUnsupported(method: MethodDto): boolean { + return statements(method).some((statement) => statement._ === "UnsupportedStmt" + || (statement._ === "AssignStmt" && statement.right._ === "UnsupportedValue")); +} + +describe("independent bodies of classes with unsupported definitions", () => { + it.each([ + { name: "computed ancestor", setup: "class Base { [Symbol.iterator]() { return [][Symbol.iterator](); } }", 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" }, + { name: "conditional native or SSR base", setup: "class Fallback {}", base: "(globalThis.HTMLElement ?? Fallback)" }, + { name: "IIFE-produced base", setup: "const Base = (() => { const Base = function () {}; Base.prototype.extra = 1; return Base; })();", base: "Base" }, + ])("keeps an independent body but no executable lifecycle: $name", ({ setup, base, runtimeBase }) => { + const { file } = lower(` + ${setup} + export class Target extends ${base} { + own = 2; + score(value: number): number { return value < 0 ? -value : value + 1; } + } + export function reference() { return Target; } + export function construct() { return new Target(); } + `); + const roundTrip = JSON.parse(serializeEtsFile(file)) as EtsFileDto; + const target = classNamed(roundTrip, "Target"); + const moduleClass = classNamed(roundTrip, "%dflt"); + + expect(hasUnsupported(methodNamed(target, "score"))).toBe(false); + expect(target.superClassName).toBe(runtimeBase ?? base); + expect(target.fields.map((field) => field.signature.name)).toContain("own"); + for (const name of ["constructor", "%instInit", "%statInit"]) { + expect(hasUnsupported(methodNamed(target, name))).toBe(true); + } + expect(hasUnsupported(methodNamed(moduleClass, "%dflt"))).toBe(true); + expect(hasUnsupported(methodNamed(moduleClass, "reference"))).toBe(true); + expect(hasUnsupported(methodNamed(moduleClass, "construct"))).toBe(true); + expect(file.exportInfos.some((info) => info.exportName === "Target")).toBe(false); + expect(target.fields.some((field) => field.signature.name === "%computed")).toBe(false); + }); + + it("executes a retained pure body like ordinary TypeScript without claiming module construction", () => { + const source = ` + let definitions = 0; + function key(): symbol { definitions++; return Symbol.iterator; } + class Base { [key()]() { return []; } } + export class Target extends Base { + score(value: number): number { + const magnitude = value < 0 ? -value : value; + return magnitude === 0 ? 10 : magnitude + 1; + } + } + export function definitionCount() { return definitions; } + `; + const { file } = lower(source); + const bodyOnlyFile = { ...file, classes: [classNamed(file, "Target")] }; + const oracle = executor(bodyOnlyFile); + const javascript = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS }, + }).outputText; + const native = new Function("exports", `${javascript}; return exports;`)({}); + + for (const value of [-7, 0, 9]) { + expect(oracle.call("score", value)).toBe(native.Target.prototype.score.call({}, value)); + } + expect(native.definitionCount()).toBe(1); + expect(hasUnsupported(methodNamed(classNamed(file, "%dflt"), "%dflt"))).toBe(true); + }); + + it("allows own data fields while stopping inherited and prototype-dependent operations", () => { + const { file, diagnostics } = lower(` + export class Target extends HTMLElement { + own = 4; + readOwn() { return this.own; } + writeOwn(value: number) { this.own = value; return ++this.own; } + readBase() { return this.id; } + callBase() { return this.remove(); } + callSuper() { return super.remove(); } + computed(key: string) { return this[key]; } + optional(value: Target | undefined) { return value?.id; } + static throughStaticThis() { return this.inherited(); } + } + `); + const target = classNamed(file, "Target"); + + for (const name of ["readOwn", "writeOwn"]) expect(hasUnsupported(methodNamed(target, name))).toBe(false); + for (const name of ["readBase", "callBase", "callSuper", "computed", "optional", "throughStaticThis"]) { + expect(hasUnsupported(methodNamed(target, name))).toBe(true); + } + expect(methodNamed(target, "optional").body!.cfg.blocks.some((block) => + block.stmts.some((statement) => statement._ === "IfStmt"), + )).toBe(true); + expect(diagnostics.messages.some((message) => message.includes("member lookup depends on unsupported class definition"))).toBe(true); + }); + + it("does not claim that an unknown computed string key cannot overwrite a data field", () => { + const { file } = lower(` + declare function key(): string; + class Base { [key()] = 99; } + class Target extends Base { own = 4; read() { return this.own; } } + `); + + expect(hasUnsupported(methodNamed(classNamed(file, "Target"), "read"))).toBe(true); + }); + + it("keeps symbol members distinct from ordinary string-named data fields", () => { + const { file } = lower(` + const tag: unique symbol = Symbol("tag"); + class Base { [tag] = 99; } + class Target extends Base { own = 4; read() { return this.own; } } + `); + + expect(hasUnsupported(methodNamed(classNamed(file, "Target"), "read"))).toBe(false); + }); + + it("retains earlier expression effects before a dependent computed lookup", () => { + const { file } = lower(` + class Target extends HTMLElement {} + export function read(value: Target, key: () => string) { return value[key()]; } + `); + const body = statements(methodNamed(classNamed(file, "%dflt"), "read")); + const keyCall = body.findIndex((statement) => statement._ === "AssignStmt" && statement.right._ === "PtrCallExpr"); + const unsupported = body.findIndex((statement) => statement._ === "AssignStmt" && statement.right._ === "UnsupportedValue"); + + expect(keyCall).toBeGreaterThanOrEqual(0); + expect(unsupported).toBeGreaterThan(keyCall); + }); + + it("retains lifted field closure bodies without constructing their captured environment", () => { + const { file } = lower(` + class Base { [Symbol.iterator]() { return []; } } + class Target extends Base { + own = 4; + calculate = (value: number) => this.own + value; + } + `); + const target = classNamed(file, "Target"); + const lifted = target.methods.filter((method) => method.signature.name.startsWith("%AM")); + + expect(lifted).toHaveLength(1); + expect(hasUnsupported(lifted[0])).toBe(false); + expect(hasUnsupported(methodNamed(target, "%instInit"))).toBe(true); + expect(methodNamed(target, "%instInit").body!.locals.some((local) => + local.type._ === "LexicalEnvType" || local.type._ === "LexicalEnvTypeRef", + )).toBe(false); + }); + + it("does not hide unsupported dependencies behind union intersection or constrained parameter types", () => { + const { file } = lower(` + class Target extends HTMLElement { own = 1; } + export function constrained(value: T) { return value.id; } + export function union(value: Target | { id: string }) { return value.id; } + export function intersection(value: Target & { extra: number }) { return value.id; } + export function optional(value: Target | undefined) { return value?.id; } + export function cast(value: Target) { return (value as any).id; } + `); + const module = classNamed(file, "%dflt"); + + for (const name of ["constrained", "union", "intersection", "optional", "cast"]) { + expect(hasUnsupported(methodNamed(module, name))).toBe(true); + } + }); +}); From 844a6cda329c20ac49c4f64d91d9649015fb0b8b Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Fri, 16 Oct 2026 11:42:41 +0300 Subject: [PATCH 2/2] Name expected values in partial class regression assertions --- .../org/jacodb/ets/test/EtsTsPartialClassTest.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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 40ab32fa9..8a1817865 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 @@ -36,7 +36,7 @@ import org.junit.jupiter.api.Test class EtsTsPartialClassTest { private fun frontend(source: String): EtsFileDto { - val input = createTempDirectory("ts-partial-class").resolve("test.ts") + val input = createTempDirectory(prefix = "ts-partial-class").resolve("test.ts") input.writeText(source) val output = generateEtsIR( @@ -76,13 +76,13 @@ class EtsTsPartialClassTest { .body!!.cfg.blocks.flatMap { it.stmts } assertTrue(body.isNotEmpty()) assertFalse(body.filterIsInstance().any { it.right is RawValueDto }) - assertEquals("Base", counter.superClassName) + assertEquals(expected = "Base", actual = counter.superClassName) assertTrue(dto.exportInfos.none { it.exportName == "Counter" || it.exportName == "Registry" }) val module = dto.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME } val statements = module.methods.single { it.signature.name == DEFAULT_ARK_METHOD_NAME } .body!!.cfg.blocks.flatMap { it.stmts } - assertEquals(3, statements.filterIsInstance().count { it.kind == "UnsupportedStmt" }) + assertEquals(expected = 3, actual = statements.filterIsInstance().count { it.kind == "UnsupportedStmt" }) } @Test @@ -104,6 +104,9 @@ class EtsTsPartialClassTest { .body!!.cfg.blocks.flatMap { it.stmts }.filterIsInstance() assertFalse(independent.any { it.right is RawValueDto }) assertTrue(dependent.any { it.right is RawValueDto }) - assertEquals("HTMLElement", dto.toEtsFile().classes.single { it.name == "Element" }.superClass?.name) + assertEquals( + expected = "HTMLElement", + actual = dto.toEtsFile().classes.single { it.name == "Element" }.superClass?.name, + ) } }