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..8a1817865
--- /dev/null
+++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsPartialClassTest.kt
@@ -0,0 +1,112 @@
+/*
+ * 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(prefix = "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(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(expected = 3, actual = 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(
+ expected = "HTMLElement",
+ actual = 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);
+ }
+ });
+});