Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* Copyright 2022 UnitTestBot contributors (utbot.org)
* <p>
* 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
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* 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<string, number> {
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<AssignStmtDto>().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<RawStmtDto>().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<AssignStmtDto>()
val dependent = clazz.methods.single { it.signature.name == "dependent" }
.body!!.cfg.blocks.flatMap { it.stmts }.filterIsInstance<AssignStmtDto>()
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,
)
}
}
20 changes: 13 additions & 7 deletions jacodb-ets/ts-frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion jacodb-ets/ts-frontend/src/lowering/astUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ts.ClassDeclaration>();
const reasonOf = (current: ts.ClassDeclaration): string | undefined => {
Expand Down
63 changes: 55 additions & 8 deletions jacodb-ets/ts-frontend/src/lowering/classBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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[] = [];
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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
// ------------------------------------------------------------------
Expand Down
Loading
Loading