diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Constants.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Constants.kt index 1d1b56821..399b59fb5 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Constants.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Constants.kt @@ -33,5 +33,6 @@ const val STATIC_INIT_METHOD_NAME = "%statInit" const val ANONYMOUS_CLASS_PREFIX = "%AC" const val ANONYMOUS_METHOD_PREFIX = "%AM" +const val NAMED_FUNCTION_REFERENCE_PREFIX = "%AMnamed$" const val TEMP_LOCAL_PREFIX = "%" diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt index be2b23284..15d2e9ca5 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt @@ -59,7 +59,6 @@ import org.jacodb.ets.dto.RawValueDto import org.jacodb.ets.dto.RelationOperationDto import org.jacodb.ets.dto.RequireObjectCoercibleExprDto import org.jacodb.ets.dto.ReturnStmtDto -import org.jacodb.ets.dto.StaticCallExprDto import org.jacodb.ets.dto.StaticFieldRefDto import org.jacodb.ets.dto.StringTypeDto import org.jacodb.ets.dto.SymbolTypeDto @@ -497,7 +496,7 @@ class EtsTsFrontendTest { val stmts = defaultClass.methods.single { it.signature.name == DEFAULT_ARK_METHOD_NAME } .body!!.cfg.blocks.flatMap { it.stmts } val callIndex = stmts.indexOfFirst { - it is AssignStmtDto && (it.right as? StaticCallExprDto)?.method?.name == "sideEffect" + it is AssignStmtDto && (it.right as? PtrCallExprDto)?.method?.name == "sideEffect" } val exportIndex = stmts.indexOfFirst { it is AssignStmtDto && (it.left as? StaticFieldRefDto)?.field?.name == "default" @@ -509,6 +508,13 @@ class EtsTsFrontendTest { assertTrue(callIndex >= 0) assertTrue(exportIndex > callIndex) assertTrue(afterIndex > exportIndex) + val call = stmts[callIndex] as AssignStmtDto + val bindingRead = stmts.filterIsInstance() + .single { (it.right as? StaticFieldRefDto)?.field?.name == "sideEffect" } + + assertEquals(bindingRead.left, (call.right as PtrCallExprDto).ptr) + assertTrue(stmts.indexOf(bindingRead) < callIndex) + assertEquals(call.left, (stmts[exportIndex] as AssignStmtDto).right) assertEquals("default", dto.exportInfos.single { it.exportName == "default" }.exportName) val model = dto.toEtsFile() @@ -683,12 +689,18 @@ class EtsTsFrontendTest { val methodDto = roundTripped.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME } .methods.single { it.signature.name == "check" } val assignments = methodDto.body!!.cfg.blocks.flatMap { it.stmts }.filterIsInstance() - val pickCall = assignments.single { (it.right as? StaticCallExprDto)?.method?.name == "pick" } - val argumentCall = assignments.single { (it.right as? StaticCallExprDto)?.method?.name == "argument" } + val pickCall = assignments.single { (it.right as? PtrCallExprDto)?.method?.name == "pick" } + val argumentCall = assignments.single { (it.right as? PtrCallExprDto)?.method?.name == "argument" } + val pickBindingRead = assignments.single { (it.right as? StaticFieldRefDto)?.field?.name == "pick" } + val argumentBindingRead = assignments.single { (it.right as? StaticFieldRefDto)?.field?.name == "argument" } val allocation = assignments.single { it.right is NewExprDto } val allocationValue = allocation.right as NewExprDto assertEquals(pickCall.left as LocalDto, allocationValue.constructorValue as LocalDto) + assertEquals(pickBindingRead.left, (pickCall.right as PtrCallExprDto).ptr) + assertEquals(argumentBindingRead.left, (argumentCall.right as PtrCallExprDto).ptr) + assertTrue(assignments.indexOf(pickBindingRead) < assignments.indexOf(pickCall)) + assertTrue(assignments.indexOf(argumentBindingRead) < assignments.indexOf(argumentCall)) assertTrue(assignments.indexOf(pickCall) < assignments.indexOf(argumentCall)) assertTrue(assignments.indexOf(argumentCall) < assignments.indexOf(allocation)) @@ -696,9 +708,12 @@ class EtsTsFrontendTest { .methods.single { it.name == "check" } val modelAllocation = modelMethod.cfg.stmts.filterIsInstance() .single { it.rhv is EtsNewExpr }.rhv as EtsNewExpr + val modelPickCall = modelMethod.cfg.stmts.filterIsInstance() + .single { (it.rhv as? EtsPtrCallExpr)?.callee?.name == "pick" } val constructorValue = modelAllocation.constructorValue as EtsLocal assertEquals((pickCall.left as LocalDto).name, constructorValue.name) + assertEquals(modelPickCall.lhv, constructorValue) assertEquals(constructorValue, modelAllocation.getOperands().single()) } @@ -760,10 +775,13 @@ class EtsTsFrontendTest { val defaultClass = dto.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME } val checkMethod = defaultClass.methods.single { it.signature.name == "check" } val assignments = checkMethod.body!!.cfg.blocks.flatMap { it.stmts }.filterIsInstance() - val constructorCall = assignments.single { (it.right as? StaticCallExprDto)?.method?.name == "choose" } + val constructorCall = assignments.single { (it.right as? PtrCallExprDto)?.method?.name == "choose" } + val bindingRead = assignments.single { (it.right as? StaticFieldRefDto)?.field?.name == "choose" } val instanceCheck = assignments.single { it.right is InstanceOfExprDto }.right as InstanceOfExprDto assertEquals(constructorCall.left, instanceCheck.checkValue) + assertEquals(bindingRead.left, (constructorCall.right as PtrCallExprDto).ptr) + assertTrue(assignments.indexOf(bindingRead) < assignments.indexOf(constructorCall)) assertEquals(null, instanceCheck.checkType) val model = dto.toEtsFile() @@ -771,8 +789,11 @@ class EtsTsFrontendTest { .methods.single { it.name == "check" } val modelCheck = modelMethod.cfg.stmts.filterIsInstance() .single { it.rhv is EtsInstanceOfExpr }.rhv as EtsInstanceOfExpr + val modelConstructorCall = modelMethod.cfg.stmts.filterIsInstance() + .single { (it.rhv as? EtsPtrCallExpr)?.callee?.name == "choose" } assertEquals((constructorCall.left as LocalDto).name, (modelCheck.checkValue as EtsLocal).name) + assertEquals(modelConstructorCall.lhv, modelCheck.checkValue) assertEquals(null, modelCheck.checkType) } diff --git a/jacodb-ets/ts-frontend/src/dto/constants.ts b/jacodb-ets/ts-frontend/src/dto/constants.ts index 43979d38d..d87f7d8e7 100644 --- a/jacodb-ets/ts-frontend/src/dto/constants.ts +++ b/jacodb-ets/ts-frontend/src/dto/constants.ts @@ -32,6 +32,8 @@ export const INSTANCE_INIT_METHOD_NAME = "%instInit"; export const STATIC_INIT_METHOD_NAME = "%statInit"; export const ANONYMOUS_CLASS_PREFIX = "%AC"; export const ANONYMOUS_METHOD_PREFIX = "%AM"; +/** A value reference to an existing named method, rather than a lifted closure. */ +export const NAMED_FUNCTION_REFERENCE_PREFIX = "%AMnamed$"; export const TEMP_LOCAL_PREFIX = "%"; /** Synthetic name of a destructuring (pattern) parameter; the index keeps names distinct. */ export const PATTERN_PARAMETER_PREFIX = "%pat"; diff --git a/jacodb-ets/ts-frontend/src/lowering/astUtils.ts b/jacodb-ets/ts-frontend/src/lowering/astUtils.ts index 7a68f7ed0..6968bbe4e 100644 --- a/jacodb-ets/ts-frontend/src/lowering/astUtils.ts +++ b/jacodb-ets/ts-frontend/src/lowering/astUtils.ts @@ -17,9 +17,9 @@ /** Shared AST helpers used across the lowering modules. */ import * as ts from "typescript"; -import { COMPUTED_MEMBER_NAME, Modifier, PATTERN_PARAMETER_PREFIX } from "../dto/constants"; +import { COMPUTED_MEMBER_NAME, DEFAULT_ARK_CLASS_NAME, Modifier, PATTERN_PARAMETER_PREFIX } from "../dto/constants"; import { DecoratorDto } from "../dto/model"; -import { MethodParameterDto } from "../dto/signatures"; +import { MethodParameterDto, MethodSignatureDto } from "../dto/signatures"; import { TypeDto, UNKNOWN_TYPE } from "../dto/types"; import type { LoweringContext } from "./methodBuilder"; @@ -137,13 +137,15 @@ function staticMemberStorage( return "unsupported"; } -/** Scope functions, ambient classes, enums, and namespaces have no standalone value reference in EtsIR yet. */ +/** Ambient declarations, enums, and namespaces have no standalone value reference in EtsIR yet. */ function isUnmaterializedValue(identifier: ts.Identifier, checker: ts.TypeChecker): boolean { const symbol = resolvedSymbolOf(identifier, checker); + const hasScopeFunctionBody = scopeFunctionImplementation(symbol) !== undefined; return symbol?.declarations?.some((declaration) => (ts.isClassDeclaration(declaration) && declaration.getSourceFile().isDeclarationFile) || (ts.isFunctionDeclaration(declaration) + && !hasScopeFunctionBody && (ts.isSourceFile(declaration.parent) || ts.isModuleBlock(declaration.parent))) || ts.isEnumDeclaration(declaration) || ts.isModuleDeclaration(declaration), @@ -342,3 +344,31 @@ export function returnTypeOf(ctx: LoweringContext, decl: ts.SignatureDeclaration } return UNKNOWN_TYPE; } + +/** The original body emitted for a file/namespace function declaration. */ +export function scopeFunctionImplementation(symbol: ts.Symbol | undefined): ts.FunctionDeclaration | undefined { + const implementations = symbol?.declarations?.filter((declaration): declaration is ts.FunctionDeclaration => + ts.isFunctionDeclaration(declaration) + && declaration.name !== undefined + && declaration.body !== undefined + && !declaration.getSourceFile().isDeclarationFile + && (ts.isSourceFile(declaration.parent) || ts.isModuleBlock(declaration.parent)), + ); + return implementations?.length === 1 ? implementations[0] : undefined; +} + +export function scopeFunctionSignature(ctx: LoweringContext, decl: ts.FunctionDeclaration): MethodSignatureDto { + const declaringClass: MethodSignatureDto["declaringClass"] = { + name: DEFAULT_ARK_CLASS_NAME, + declaringFile: ctx.fileSignatureFor(decl.getSourceFile()), + }; + const namespace = ctx.converter.namespaceSignatureOf(decl); + if (namespace !== undefined) declaringClass.declaringNamespace = namespace; + + return { + declaringClass, + name: decl.name?.text ?? "default", + parameters: buildParameters(ctx, decl).parameters, + returnType: returnTypeOf(ctx, decl), + }; +} diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 43b23a63e..925a7f412 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -34,7 +34,6 @@ import { ANONYMOUS_METHOD_PREFIX, ClassCategory, CONSTRUCTOR_NAME, - DEFAULT_ARK_CLASS_NAME, PATTERN_PARAMETER_PREFIX, } from "../dto/constants"; import { FieldDto, MethodDto } from "../dto/model"; @@ -64,6 +63,7 @@ import { ImmediateDto, LValueDto, LocalDto, + StaticFieldRefDto, ValueDto, } from "../dto/values"; import { syntaxKindName, unsupportedValue } from "./diagnostics"; @@ -371,7 +371,9 @@ export class ExprLowerer { return constant("undefined", UNDEFINED_TYPE); } const declaration = this.m.converter.symbolOf(node)?.declarations?.find((candidate) => - ts.isClassDeclaration(candidate) || + ts.isClassDeclaration(candidate) + || (ts.isModuleDeclaration(candidate) && isProjectFile(candidate)) + || (ts.isSourceFile(candidate) && !candidate.isDeclarationFile) || (ts.isFunctionDeclaration(candidate) && (ts.isSourceFile(candidate.parent) || ts.isModuleBlock(candidate.parent))), ); @@ -381,13 +383,13 @@ export class ExprLowerer { const signature = this.m.converter.classSignatureOf(declaration); return { _: "ClassValueRef", signature, type: { _: "ClassValueType", signature } }; } - if (declaration !== undefined) { - throw new LoweringError(`runtime value of declaration '${node.text}' is not represented in EtsIR`); - } const captured = this.m.capturedRefForIdentifier(node); if (captured !== undefined) return captured; const moduleField = this.m.moduleFieldForIdentifier(node); if (moduleField !== undefined) return moduleField; + if (declaration !== undefined) { + throw new LoweringError(`runtime value of declaration '${node.text}' is not represented in EtsIR`); + } // Other named references are locals; unresolved globals (e.g. console) // become locals with UnknownType, same as ArkAnalyzer. return this.m.localForIdentifier(node, this.safeTypeOf(node)); @@ -424,6 +426,9 @@ export class ExprLowerer { return this.lowerOptionalChain(node, chain); } + const importedFunction = this.importedScopeFunctionField(node); + if (importedFunction !== undefined) return importedFunction; + // `this.f` inside a STATIC method addresses a static field of the class. if (node.expression.kind === ts.SyntaxKind.ThisKeyword && this.m.isStaticMethod) { return { @@ -535,6 +540,11 @@ export class ExprLowerer { private lowerOptionalChainCallee(root: ts.CallExpression): OptionalChainValue { const callee = unwrapTransparentExpression(root.expression); if (ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) { + if (ts.isPropertyAccessExpression(callee) + && this.importedScopeFunctionField(callee) !== undefined) { + throw new LoweringError("module namespace call receiver is not represented in EtsIR"); + } + const chain = optionalChain(callee); if (chain !== undefined) { // A grouped chain completes before the outer call; carry its receiver through the joins. @@ -689,6 +699,9 @@ export class ExprLowerer { return this.m.localForIdentifier(node, this.safeTypeOf(node)); } if (ts.isPropertyAccessExpression(node)) { + if (this.importedScopeFunctionField(node) !== undefined) { + throw new LoweringError("module namespace function properties are read-only"); + } const ref = this.lowerPropertyAccess(node); if (ref._ === "PropertyRef") { return this.mayReassign(node.expression, laterExpression) @@ -1030,6 +1043,12 @@ export class ExprLowerer { lowerCall(node: ts.CallExpression): ValueDto { const callee = unwrapTransparentExpression(node.expression); + // Namespace calls require a module-object receiver that is not represented in EtsIR. + if (ts.isPropertyAccessExpression(callee) + && this.importedScopeFunctionField(callee) !== undefined) { + throw new LoweringError("module namespace call receiver is not represented in EtsIR"); + } + if (ts.isPropertyAccessExpression(callee) && this.isProjectClassProperty(callee.expression)) { this.evaluateProjectClassPropertyReceiver(callee.expression); throw new LoweringError("call through a mutable class property is not represented in EtsIR"); @@ -1080,6 +1099,7 @@ export class ExprLowerer { if (ts.isPropertyAccessExpression(callee)) { const methodName = callee.name.text; + // `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) { @@ -1111,27 +1131,6 @@ export class ExprLowerer { if (ts.isIdentifier(callee)) { const resolved = this.resolveCalleeDeclaration(node); - if ( - resolved !== undefined - && ts.isFunctionDeclaration(resolved) - && isProjectFile(resolved) - && isScopeFunctionDeclaration(resolved) - ) { - // Free function declared in a project file: method of that file's %dflt class. - const declaringClass: ClassSignatureDto = { - name: DEFAULT_ARK_CLASS_NAME, - declaringFile: this.m.ctx.fileSignatureFor(resolved.getSourceFile()), - }; - const declaringNamespace = this.m.converter.namespaceSignatureOf(resolved); - if (declaringNamespace !== undefined) { - declaringClass.declaringNamespace = declaringNamespace; - } - return { - _: "StaticCallExpr", - method: this.methodSignatureForCall(node, callee.text, declaringClass), - args: this.lowerCallArguments(node), - }; - } if (resolved === undefined && !isDeclaredLocalValue(this.m, callee)) { // Fully unresolved global callee — static call with the UNKNOWN class. return { @@ -1143,10 +1142,11 @@ export class ExprLowerer { // Snapshot a function value before arguments; an argument may mutate // the binding but must not change this call's selected callee. const localCallee = this.snapshotToLocal(callee, node.arguments); + const moduleField = this.m.moduleFieldForIdentifier(callee); return { _: "PtrCallExpr", ptr: localCallee, - method: this.methodSignatureForCall(node, callee.text, UNKNOWN_CLASS_SIGNATURE), + method: this.methodSignatureForCall(node, callee.text, moduleField?.field.declaringClass ?? UNKNOWN_CLASS_SIGNATURE), args: this.lowerCallArguments(node), }; } @@ -1455,13 +1455,17 @@ export class ExprLowerer { ? node.template.templateSpans.map((span) => span.expression) : []; const unsupportedBeforeTag = this.unsupportedExpressionCount; - let ptr: LocalDto | undefined; + let ptr: LocalDto; let receiver: LocalDto | undefined; let declaringClass = UNKNOWN_CLASS_SIGNATURE; let name = "%tag"; - let staticCall = false; if (ts.isPropertyAccessExpression(tag) || ts.isElementAccessExpression(tag)) { + if (ts.isPropertyAccessExpression(tag) + && this.importedScopeFunctionField(tag) !== undefined) { + throw new LoweringError("module namespace call receiver is not represented in EtsIR"); + } + // Property Get and key coercion can themselves mutate the source binding. receiver = this.snapshotToLocal(tag.expression); const key = ts.isPropertyAccessExpression(tag) @@ -1475,20 +1479,10 @@ export class ExprLowerer { }, this.safeTypeOf(tag)); name = ts.isPropertyAccessExpression(tag) ? tag.name.text : "%tag"; } else { - const resolved = this.resolveCalleeDeclaration(node); - if (ts.isIdentifier(tag) && resolved !== undefined && ts.isFunctionDeclaration(resolved) - && isProjectFile(resolved) && isScopeFunctionDeclaration(resolved)) { - declaringClass = { - name: DEFAULT_ARK_CLASS_NAME, - declaringFile: this.m.ctx.fileSignatureFor(resolved.getSourceFile()), - }; - const namespace = this.m.converter.namespaceSignatureOf(resolved); - if (namespace !== undefined) declaringClass.declaringNamespace = namespace; + ptr = this.snapshotToLocal(node.tag, substitutions); + if (ts.isIdentifier(tag)) { name = tag.text; - staticCall = true; - } else { - ptr = this.snapshotToLocal(node.tag, substitutions); - if (ts.isIdentifier(tag)) name = tag.text; + declaringClass = this.m.moduleFieldForIdentifier(tag)?.field.declaringClass ?? UNKNOWN_CLASS_SIGNATURE; } } @@ -1526,8 +1520,6 @@ export class ExprLowerer { const args = [parts, ...this.lowerArguments(substitutions)]; const method = this.methodSignatureForCall(node, name, declaringClass); - if (staticCall) return { _: "StaticCallExpr", method, args }; - if (ptr === undefined) throw new LoweringError("template tag has no callable value"); return receiver === undefined ? { _: "PtrCallExpr", ptr, method, args } : { _: "PtrCallExpr", ptr, receiver, method, args }; @@ -1929,6 +1921,14 @@ export class ExprLowerer { return this.m.converter.typeOfNode(node); } + private importedScopeFunctionField(node: ts.PropertyAccessExpression): StaticFieldRefDto | undefined { + const receiver = unwrapTransparentExpression(node.expression); + if (!ts.isIdentifier(node.name) || !ts.isIdentifier(receiver) + || !this.m.converter.symbolOf(receiver)?.declarations?.some(ts.isSourceFile)) return undefined; + const field = this.m.moduleFieldForIdentifier(node.name); + return field?.field.type._ === "FunctionType" ? field : undefined; + } + /** * If the expression statically refers to a class-like declaration * (class / enum — e.g. `Math`, `E` in `E.A`, `Foo` in `Foo.bar()`), @@ -2167,10 +2167,6 @@ function isProjectFile(decl: ts.Node): boolean { return !decl.getSourceFile().isDeclarationFile; } -function isScopeFunctionDeclaration(decl: ts.FunctionDeclaration): boolean { - return ts.isSourceFile(decl.parent) || ts.isModuleBlock(decl.parent); -} - function optionalChain(node: OptionalChainSegment): OptionalChain | undefined { const reversed: OptionalChainSegment[] = []; let current = node; diff --git a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts index 14a1777af..071706baf 100644 --- a/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts +++ b/jacodb-ets/ts-frontend/src/lowering/fileBuilder.ts @@ -35,12 +35,13 @@ import { ExportTypeValue, ImportType, Modifier, + NAMED_FUNCTION_REFERENCE_PREFIX, } from "../dto/constants"; import { ClassDto, EtsFileDto, ExportInfoDto, FieldDto, ImportInfoDto, MethodDto, NamespaceDto } from "../dto/model"; import { ClassSignatureDto, FileSignatureDto, NamespaceSignatureDto } from "../dto/signatures"; import { VOID_TYPE } from "../dto/types"; import { TypeConverter } from "../types/convert"; -import { classValueDeclarationOf, exportAssignmentSupport, modifiersOf } from "./astUtils"; +import { classValueDeclarationOf, exportAssignmentSupport, modifiersOf, scopeFunctionImplementation, scopeFunctionSignature } from "./astUtils"; import { ClassBuilder } from "./classBuilder"; import { Diagnostics } from "./diagnostics"; import { AnonymousRegistry, LoweringContext, MethodContext } from "./methodBuilder"; @@ -379,13 +380,34 @@ class FileBuilder { return { classes, namespaces }; } - /** Direct scope variables have one storage location shared by all scope methods. */ + /** Direct scope bindings have one storage location shared by all scope methods. */ private registerModuleFields( declaringClass: ClassSignatureDto, statements: readonly ts.Statement[], ): FieldDto[] { const fields: FieldDto[] = []; const registeredSymbols = new Set(); + for (const statement of statements) { + if (!ts.isFunctionDeclaration(statement) || statement.name === undefined || statement.body === undefined) continue; + const symbol = this.ctx.checker.getSymbolAtLocation(statement.name); + if (symbol === undefined || registeredSymbols.has(symbol)) continue; + registeredSymbols.add(symbol); + if (scopeFunctionImplementation(symbol) !== statement) { + this.ctx.diagnostics.warn(statement, `scope function '${statement.name.text}' has ambiguous implementation bodies`); + continue; + } + + const type = { _: "FunctionType" as const, signature: scopeFunctionSignature(this.ctx, statement) }; + const signature = { declaringClass, name: statement.name.text, type }; + this.ctx.moduleFields.set(symbol, signature); + fields.push({ + signature, + modifiers: modifiersOf(statement) | Modifier.STATIC, + decorators: [], + questionToken: false, + exclamationToken: false, + }); + } for (const declaration of scopeVariableDeclarations(statements)) { const declarationList = declaration.parent; const statement = declarationList.parent; @@ -478,6 +500,20 @@ class FileBuilder { ): MethodDto { const m = new MethodContext(this.ctx, declaringClass, DEFAULT_ARK_METHOD_NAME); m.emitPrologue([]); + + // Function declarations are initialized before any ordinary scope statement. + // The value points at the original method body; no second body is lowered. + for (const statement of statements) { + if (!ts.isFunctionDeclaration(statement) || statement.name === undefined || statement.body === undefined) continue; + const field = m.moduleFieldForIdentifier(statement.name); + if (field === undefined) continue; + const reference = m.getOrCreateLocal( + `${NAMED_FUNCTION_REFERENCE_PREFIX}${statement.name.text}`, + { _: "FunctionType", signature: scopeFunctionSignature(this.ctx, statement) }, + ); + m.withOrigin(statement, () => m.cfg.emit({ _: "AssignStmt", left: field, right: reference })); + } + const lowerer = new StmtLowerer(m); for (const statement of statements) { lowerer.lowerStatement(statement); diff --git a/jacodb-ets/ts-frontend/src/lowering/methodBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/methodBuilder.ts index 191d23671..d006ca5f2 100644 --- a/jacodb-ets/ts-frontend/src/lowering/methodBuilder.ts +++ b/jacodb-ets/ts-frontend/src/lowering/methodBuilder.ts @@ -21,6 +21,7 @@ import { ClassSignatureDto, FieldSignatureDto, FileSignatureDto } from "../dto/s import { ClassTypeDto, LexicalEnvTypeDto, TypeDto, UNKNOWN_TYPE } from "../dto/types"; import { ClosureFieldRefDto, LocalDto, StaticFieldRefDto, ValueDto } from "../dto/values"; import { TypeConverter } from "../types/convert"; +import { scopeFunctionImplementation, scopeFunctionSignature } from "./astUtils"; import type { BuiltParameters } from "./astUtils"; import { CfgBuilder } from "./cfg"; import { Diagnostics, syntaxKindName } from "./diagnostics"; @@ -48,7 +49,7 @@ export interface LoweringContext { diagnostics: Diagnostics; anonymous: AnonymousRegistry; buildClassExpression?: (node: ts.ClassExpression, name: string) => ClassDto; - /** File/namespace variables represented as static fields of the owning %dflt class. */ + /** File/namespace bindings represented as static fields of the owning %dflt class. */ moduleFields: Map; } @@ -219,8 +220,18 @@ export class MethodContext { return this.converter.symbolOf(node); } - /** Derive storage for an imported scope variable from its declaration. */ + /** Derive storage for an imported scope binding from its declaration. */ private moduleFieldFromSymbol(symbol: ts.Symbol): FieldSignatureDto | undefined { + const functionDeclaration = scopeFunctionImplementation(symbol); + if (functionDeclaration !== undefined) { + const signature = scopeFunctionSignature(this.ctx, functionDeclaration); + return { + declaringClass: signature.declaringClass, + name: signature.name, + type: { _: "FunctionType", signature }, + }; + } + const identifier = symbol.declarations ?.map(declaredBindingIdentifier) .find((candidate): candidate is ts.Identifier => candidate !== undefined); diff --git a/jacodb-ets/ts-frontend/test/array-and-params-semantics.spec.ts b/jacodb-ets/ts-frontend/test/array-and-params-semantics.spec.ts index 4a1ae458b..0455b3897 100644 --- a/jacodb-ets/ts-frontend/test/array-and-params-semantics.spec.ts +++ b/jacodb-ets/ts-frontend/test/array-and-params-semantics.spec.ts @@ -89,18 +89,19 @@ describe("array iterator behavior after JSON round trip", () => { }); it("evaluates the source once and drains it before the following element", () => { - const source = "declare function mark(n: number): number; declare function get(): Iterable; function copy() { return [mark(0), ...get(), mark(9)]; }"; + const source = "function copy(mark: (n: number) => number, get: () => Iterable) { return [mark(0), ...get(), mark(9)]; }"; const observed = (consumer: "ir" | "native") => { const order: any[] = []; - const externals = { + const callbacks = { mark(n: number) { order.push(n); return n; }, get() { order.push("source"); return { *[Symbol.iterator]() { order.push("first"); yield 1; order.push("second"); yield 2; order.push("done"); } }; }, }; - const functions = consumers(source, "copy", externals); - return { result: functions[consumer](), order }; + const functions = consumers(source, "copy"); + + return { result: functions[consumer](callbacks.mark, callbacks.get), order }; }; expect(observed("ir")).toEqual(observed("native")); @@ -110,14 +111,15 @@ describe("array iterator behavior after JSON round trip", () => { it("closes on a default initializer throw and preserves the original exception", () => { const original = new Error("binding default"); const { ir, native } = consumers( - "declare function fail(): never; function tail(input: any) { const [a = fail(), ...rest] = input; return rest; }", - "tail", { fail: () => { throw original; } }, + "function tail(input: any, fail: () => never) { const [a = fail(), ...rest] = input; return rest; }", + "tail", ); + const fail = () => { throw original; }; const run = (collect: any) => { let closed = 0; const input = { [Symbol.iterator]: () => ({ next: () => ({ value: undefined, done: false }), return() { closed++; throw new Error("close"); } }) }; - expect(() => collect(input)).toThrow(original); + expect(() => collect(input, fail)).toThrow(original); expect(closed).toBe(1); }; diff --git a/jacodb-ets/ts-frontend/test/array-rest-semantics.spec.ts b/jacodb-ets/ts-frontend/test/array-rest-semantics.spec.ts index 5461c52e5..a11262e1b 100644 --- a/jacodb-ets/ts-frontend/test/array-rest-semantics.spec.ts +++ b/jacodb-ets/ts-frontend/test/array-rest-semantics.spec.ts @@ -72,14 +72,15 @@ describe("array iterator behavior after JSON round trip", () => { it("closes on a default initializer throw and preserves the original exception", () => { const original = new Error("binding default"); const { ir, native } = consumers( - "declare function fail(): never; function tail(input: any) { const [a = fail(), ...rest] = input; return rest; }", - "tail", { fail: () => { throw original; } }, + "function tail(input: any, fail: () => never) { const [a = fail(), ...rest] = input; return rest; }", + "tail", ); + const fail = () => { throw original; }; const run = (collect: any) => { let closed = 0; const input = { [Symbol.iterator]: () => ({ next: () => ({ value: undefined, done: false }), return() { closed++; throw new Error("close"); } }) }; - expect(() => collect(input)).toThrow(original); + expect(() => collect(input, fail)).toThrow(original); expect(closed).toBe(1); }; diff --git a/jacodb-ets/ts-frontend/test/array-spread-semantics.spec.ts b/jacodb-ets/ts-frontend/test/array-spread-semantics.spec.ts index 2ee404ab6..5d33341ea 100644 --- a/jacodb-ets/ts-frontend/test/array-spread-semantics.spec.ts +++ b/jacodb-ets/ts-frontend/test/array-spread-semantics.spec.ts @@ -62,18 +62,19 @@ describe("array iterator behavior after JSON round trip", () => { }); it("evaluates the source once and drains it before the following element", () => { - const source = "declare function mark(n: number): number; declare function get(): Iterable; function copy() { return [mark(0), ...get(), mark(9)]; }"; + const source = "function copy(mark: (n: number) => number, get: () => Iterable) { return [mark(0), ...get(), mark(9)]; }"; const observed = (consumer: "ir" | "native") => { const order: any[] = []; - const externals = { + const callbacks = { mark(n: number) { order.push(n); return n; }, get() { order.push("source"); return { *[Symbol.iterator]() { order.push("first"); yield 1; order.push("second"); yield 2; order.push("done"); } }; }, }; - const functions = consumers(source, "copy", externals); - return { result: functions[consumer](), order }; + const functions = consumers(source, "copy"); + + return { result: functions[consumer](callbacks.mark, callbacks.get), order }; }; expect(observed("ir")).toEqual(observed("native")); diff --git a/jacodb-ets/ts-frontend/test/cfg.spec.ts b/jacodb-ets/ts-frontend/test/cfg.spec.ts index 9d0ed4f3b..5f29ce492 100644 --- a/jacodb-ets/ts-frontend/test/cfg.spec.ts +++ b/jacodb-ets/ts-frontend/test/cfg.spec.ts @@ -362,7 +362,7 @@ describe("control flow lowering", () => { const blocks = blocksOf(methodByName(file, "f")); const entry = blocks[0]; const callBlock = blocks.find((block) => block.stmts.some( - (stmt) => stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr" && stmt.right.method.name === "sideEffect", + (stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "sideEffect", )); expect(lastStmt(entry)).toMatchObject({ _: "IfStmt" }); expect(callBlock).toBeDefined(); @@ -381,7 +381,7 @@ describe("control flow lowering", () => { const blocks = blocksOf(methodByName(file, "f")); const entry = blocks[0]; const callBlock = blocks.find((block) => block.stmts.some( - (stmt) => stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr" && stmt.right.method.name === "sideEffect", + (stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "sideEffect", )); expect(lastStmt(entry)).toMatchObject({ _: "IfStmt" }); expect(callBlock).toBeDefined(); diff --git a/jacodb-ets/ts-frontend/test/classes.spec.ts b/jacodb-ets/ts-frontend/test/classes.spec.ts index 728a9fcdd..b13109564 100644 --- a/jacodb-ets/ts-frontend/test/classes.spec.ts +++ b/jacodb-ets/ts-frontend/test/classes.spec.ts @@ -186,11 +186,11 @@ describe("class lowering", () => { const signatureStmts = singleBlockStmts(methodOf(defaultClass, "createBySignature")); const signatureAliasStmts = singleBlockStmts(methodOf(defaultClass, "createSignatureAlias")); - expect(stmts.filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr" + expect(stmts.filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "constructorValue")).toHaveLength(1); expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue")).toBe(false); expect(aliasStmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue")).toBe(false); - expect(signatureStmts.filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr" + expect(signatureStmts.filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "constructorBySignature")).toHaveLength(1); for (const methodStmts of [signatureStmts, signatureAliasStmts]) { expect(methodStmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue")).toBe(false); @@ -256,9 +256,9 @@ describe("class lowering", () => { (stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr", ))!; const stmts = block.stmts; - const pickIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr" + const pickIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "pick"); - const argumentIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr" + const argumentIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "argument"); const allocationIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr"); const pickCall = stmts[pickIndex]!; @@ -623,7 +623,7 @@ describe("class lowering", () => { const marks = stmts.flatMap((stmt) => { const call = stmt._ === "CallStmt" ? stmt.expr - : stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr" + : stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr" ? stmt.right : undefined; return call?.method.name === "mark" ? [call.args[0]] : []; @@ -769,7 +769,7 @@ describe("namespace lowering", () => { for (const methodName of ["create", "marker", "invoke"]) { const stmts = singleBlockStmts(methodOf(defaultClass, methodName)); const callIndices = stmts.flatMap((stmt, index) => - stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr" + stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "getN" ? [index] : [], ); const unsupportedIndex = stmts.findIndex( @@ -909,9 +909,9 @@ describe("namespace lowering", () => { const effectStmts = singleBlockStmts(methodOf(defaultClass, "withEffects")); const receiverIndex = effectStmts.findIndex((stmt) => stmt._ === "AssignStmt" - && stmt.right._ === "StaticCallExpr" && stmt.right.method.name === "getN"); + && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "getN"); const keyIndex = effectStmts.findIndex((stmt) => stmt._ === "AssignStmt" - && stmt.right._ === "StaticCallExpr" && stmt.right.method.name === "getKey"); + && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "getKey"); const unsupportedIndex = effectStmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue"); expect(receiverIndex).toBeGreaterThanOrEqual(0); @@ -1001,9 +1001,9 @@ describe("namespace lowering", () => { const computedStmts = singleBlockStmts(methodOf(defaultClass, "computed")); const receiverIndex = computedStmts.findIndex((stmt) => stmt._ === "AssignStmt" - && stmt.right._ === "StaticCallExpr" && stmt.right.method.name === "getHolder"); + && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "getHolder"); const keyIndex = computedStmts.findIndex((stmt) => stmt._ === "AssignStmt" - && stmt.right._ === "StaticCallExpr" && stmt.right.method.name === "getKey"); + && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "getKey"); const propertyIndex = computedStmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PropertyRef"); const conversionIndex = computedStmts.findIndex((stmt) => stmt._ === "AssignStmt" diff --git a/jacodb-ets/ts-frontend/test/computed-new.spec.ts b/jacodb-ets/ts-frontend/test/computed-new.spec.ts index e29dbe904..bbf9dfd8a 100644 --- a/jacodb-ets/ts-frontend/test/computed-new.spec.ts +++ b/jacodb-ets/ts-frontend/test/computed-new.spec.ts @@ -69,7 +69,7 @@ describe("computed constructors", () => { const roundTrip = JSON.parse(serializeEtsFile(file)); const stmts = singleBlockStmts(methodByName(roundTrip, "make")); const functionCalls = stmts.flatMap((stmt, index) => stmt._ === "AssignStmt" - && stmt.right._ === "StaticCallExpr" ? [{ name: stmt.right.method.name, index }] : []); + && stmt.right._ === "PtrCallExpr" ? [{ name: stmt.right.method.name, index }] : []); const readIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef"); const allocationIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr" && stmt.right.constructorValue?._ === "Local"); diff --git a/jacodb-ets/ts-frontend/test/corpus.spec.ts b/jacodb-ets/ts-frontend/test/corpus.spec.ts index 7cf51c63d..083d0ceeb 100644 --- a/jacodb-ets/ts-frontend/test/corpus.spec.ts +++ b/jacodb-ets/ts-frontend/test/corpus.spec.ts @@ -38,14 +38,17 @@ describe("corpus: realistic TS/JS programs lower cleanly", () => { const source = fs.readFileSync(path.join(FIXTURES_DIR, fixture), "utf-8"); // lower() throws on any invariant violation. const { file, diagnostics } = lower(source, "corpus", fixture); - const expectedUnsupported = fixture === "geometry.ts" ? 3 : 0; + const expectedUnsupported = fixture === "geometry.ts" ? 6 : 0; expect(countRawFallbacks(file)).toBe(expectedUnsupported); expect(diagnostics.messages).toHaveLength(expectedUnsupported); if (fixture === "geometry.ts") { - expect(diagnostics.messages.every((message) => message.includes( + expect(diagnostics.messages.filter((message) => message.includes( "constructor read from a mutable class property is not represented", - ))).toBe(true); + ))).toHaveLength(3); + expect(diagnostics.messages.filter((message) => message.includes( + "runtime value of declaration 'Geometry' is not represented", + ))).toHaveLength(3); } // Sanity: every method body has at least the prologue and a terminator. diff --git a/jacodb-ets/ts-frontend/test/execute.ts b/jacodb-ets/ts-frontend/test/execute.ts index 89db6ac34..50a991668 100644 --- a/jacodb-ets/ts-frontend/test/execute.ts +++ b/jacodb-ets/ts-frontend/test/execute.ts @@ -1,3 +1,4 @@ +import { NAMED_FUNCTION_REFERENCE_PREFIX } from "../src/dto/constants"; import { EtsFileDto, MethodDto } from "../src/dto/model"; import { ValueDto } from "../src/dto/values"; @@ -8,6 +9,8 @@ import { ValueDto } from "../src/dto/values"; export function executor(file: EtsFileDto, externals: Record = {}) { const fields = new Map(); const methods = file.classes.flatMap((clazz) => clazz.methods); + const templates = new Map(); + let initialized = false; const run = (method: MethodDto, args: any[], thisValue?: any): any => { if (method.body === undefined) throw new Error(`No body for ${method.signature.name}`); @@ -18,7 +21,29 @@ export function executor(file: EtsFileDto, externals: Record = {}) let caught: any; const value = (v: ValueDto): any => { switch (v._) { - case "Local": return locals.get(v.name); + case "Local": { + if (v.name.startsWith(NAMED_FUNCTION_REFERENCE_PREFIX) && !locals.has(v.name) + && v.type._ === "FunctionType") { + const signature = v.type.signature; + const target = methods.find((candidate) => candidate.signature.name === signature.name + && JSON.stringify(candidate.signature.declaringClass) === JSON.stringify(signature.declaringClass)); + if (target === undefined) throw new Error(`Unknown named function ${signature.name}`); + locals.set(v.name, function (this: any, ...callArgs: any[]): any { + return run(target, callArgs, this); + }); + } + return locals.get(v.name); + } + case "TemplateObjectExpr": { + let template = templates.get(v.siteId); + if (template === undefined) { + const cooked = v.cooked.map((part) => part === null ? undefined : part); + Object.defineProperty(cooked, "raw", { value: Object.freeze([...v.raw]) }); + template = Object.freeze(cooked); + templates.set(v.siteId, template); + } + return template; + } case "Constant": switch (v.type._) { case "UndefinedType": return undefined; @@ -130,15 +155,20 @@ export function executor(file: EtsFileDto, externals: Record = {}) throw new Error("Concrete oracle step limit exceeded"); }; + const initialize = (): void => { + if (initialized) return; + const method = methods.find((candidate) => candidate.signature.name === "%dflt"); + if (method !== undefined) run(method, []); + initialized = true; + }; + return { call(name: string, ...args: any[]): any { + initialize(); const method = methods.find((candidate) => candidate.signature.name === name); if (method === undefined) throw new Error(`No method ${name}`); return run(method, args); }, - initialize(): void { - const method = methods.find((candidate) => candidate.signature.name === "%dflt"); - if (method !== undefined) run(method, []); - }, + initialize, }; } diff --git a/jacodb-ets/ts-frontend/test/export-default.spec.ts b/jacodb-ets/ts-frontend/test/export-default.spec.ts index e62480dd7..8024ae851 100644 --- a/jacodb-ets/ts-frontend/test/export-default.spec.ts +++ b/jacodb-ets/ts-frontend/test/export-default.spec.ts @@ -159,7 +159,7 @@ describe("export default expressions", () => { const defaultClass = file.classes.find((clazz) => clazz.signature.name === "%dflt")!; const calls = stmts.filter((stmt) => stmt._ === "AssignStmt" - && stmt.right._ === "StaticCallExpr" && stmt.right.method.name === "sideEffect"); + && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "sideEffect"); const exportWrite = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.left._ === "StaticFieldRef" && stmt.left.field.name === "default"); const laterWrite = stmts.find((stmt) => stmt._ === "AssignStmt" @@ -319,16 +319,16 @@ describe("export default expressions", () => { expect(exports.default).toMatchObject({ value: 7 }); }); - it("checks constructor arguments for unmaterialized declaration values", () => { + it("materializes a named function used as a constructor argument", () => { const { file, diagnostics } = lower(` class Box { constructor(value: unknown) {} } function factory(): number { return 1; } export default new Box(factory); `); - expect(diagnostics.messages).toContainEqual(expect.stringContaining("has no EtsIR value reference")); + expect(diagnostics.messages).toEqual([]); expect(file.classes.find((clazz) => clazz.signature.name === "%dflt")!.fields - .some((field) => field.signature.name === "default")).toBe(false); + .some((field) => field.signature.name === "default")).toBe(true); }); it.each([ @@ -390,20 +390,22 @@ describe("export default expressions", () => { expect(defaultClass.fields.some((field) => field.signature.name === "default")).toBe(false); }); - it("marks a bare declaration reference unsupported until EtsIR can represent its value", () => { + it("exports the current named function binding", () => { const { file, diagnostics } = lower(` function factory(): number { return 1; } export default factory; `); const stmts = defaultMethod(file).body!.cfg.blocks.flatMap((block) => block.stmts); - expect(diagnostics.messages).toContainEqual(expect.stringContaining("has no EtsIR value reference")); - expect(stmts).toContainEqual(expect.objectContaining({ _: "UnsupportedStmt", kindName: "ExportAssignment" })); + expect(diagnostics.messages).toEqual([]); + expect(stmts).toContainEqual(expect.objectContaining({ + right: expect.objectContaining({ _: "StaticFieldRef", field: expect.objectContaining({ name: "factory" }) }), + })); expect(file.classes.find((clazz) => clazz.signature.name === "%dflt")!.fields - .some((field) => field.signature.name === "default")).toBe(false); + .some((field) => field.signature.name === "default")).toBe(true); }); - it("rejects a declaration value captured by an exported closure", () => { + it("reads a named function through its shared binding inside an exported closure", () => { const { file, diagnostics } = lower(` function factory(): number { return 1; } export default () => factory; @@ -411,10 +413,15 @@ describe("export default expressions", () => { const stmts = defaultMethod(file).body!.cfg.blocks.flatMap((block) => block.stmts); - expect(diagnostics.messages).toContainEqual(expect.stringContaining("has no EtsIR value reference")); - expect(stmts).toContainEqual(expect.objectContaining({ _: "UnsupportedStmt", kindName: "ExportAssignment" })); + expect(diagnostics.messages).toEqual([]); + expect(stmts.some((stmt) => stmt._ === "UnsupportedStmt")).toBe(false); + expect(file.classes.flatMap((clazz) => clazz.methods) + .flatMap((method) => method.body?.cfg.blocks.flatMap((block) => block.stmts) ?? [])) + .toContainEqual(expect.objectContaining({ + right: expect.objectContaining({ _: "StaticFieldRef", field: expect.objectContaining({ name: "factory" }) }), + })); expect(file.classes.find((clazz) => clazz.signature.name === "%dflt")!.fields - .some((field) => field.signature.name === "default")).toBe(false); + .some((field) => field.signature.name === "default")).toBe(true); }); it("keeps direct calls inside an exported closure supported", () => { @@ -427,7 +434,7 @@ describe("export default expressions", () => { expect(methods.flatMap((method) => method.body?.cfg.blocks.flatMap((block) => block.stmts) ?? [])) .toContainEqual(expect.objectContaining({ _: "AssignStmt", - right: expect.objectContaining({ _: "StaticCallExpr", method: expect.objectContaining({ name: "factory" }) }), + right: expect.objectContaining({ _: "PtrCallExpr", method: expect.objectContaining({ name: "factory" }) }), })); expect(file.exportInfos).toContainEqual(expect.objectContaining({ exportName: "default" })); }); @@ -452,7 +459,7 @@ describe("export default expressions", () => { "<() => number>factory", "factory!", "factory satisfies () => number", - ])("keeps wrapped function reference %s unsupported", (expression) => { + ])("preserves a wrapped function reference in %s", (expression) => { const { file, diagnostics } = lower(` function factory(): number { return 1; } export default ${expression}; @@ -460,10 +467,12 @@ describe("export default expressions", () => { const stmts = defaultMethod(file).body!.cfg.blocks.flatMap((block) => block.stmts); - expect(diagnostics.messages).toContainEqual(expect.stringContaining("has no EtsIR value reference")); - expect(stmts).toContainEqual(expect.objectContaining({ _: "UnsupportedStmt", kindName: "ExportAssignment" })); + expect(diagnostics.messages).toEqual([]); + expect(stmts).toContainEqual(expect.objectContaining({ + right: expect.objectContaining({ _: "StaticFieldRef", field: expect.objectContaining({ name: "factory" }) }), + })); expect(file.classes.find((clazz) => clazz.signature.name === "%dflt")!.fields - .some((field) => field.signature.name === "default")).toBe(false); + .some((field) => field.signature.name === "default")).toBe(true); }); it.each([ @@ -471,7 +480,7 @@ describe("export default expressions", () => { "[factory]", "({ factory })", "true ? factory : factory", - ])("reports an unsupported declaration value inside %s", (expression) => { + ])("preserves a named function value inside %s", (expression) => { const { file, diagnostics } = lower(` function factory(): number { return 1; } export default ${expression}; @@ -479,9 +488,9 @@ describe("export default expressions", () => { const stmts = defaultMethod(file).body!.cfg.blocks.flatMap((block) => block.stmts); - expect(diagnostics.messages).toContainEqual(expect.stringContaining("has no EtsIR value reference")); - expect(stmts).toContainEqual(expect.objectContaining({ _: "UnsupportedStmt", kindName: "ExportAssignment" })); + expect(diagnostics.messages).toEqual([]); + expect(stmts.some((stmt) => stmt._ === "UnsupportedStmt")).toBe(false); expect(stmts.some((stmt) => stmt._ === "AssignStmt" - && stmt.left._ === "StaticFieldRef" && stmt.left.field.name === "default")).toBe(false); + && stmt.left._ === "StaticFieldRef" && stmt.left.field.name === "default")).toBe(true); }); }); diff --git a/jacodb-ets/ts-frontend/test/lowering.spec.ts b/jacodb-ets/ts-frontend/test/lowering.spec.ts index 91ff07aee..e3aa63793 100644 --- a/jacodb-ets/ts-frontend/test/lowering.spec.ts +++ b/jacodb-ets/ts-frontend/test/lowering.spec.ts @@ -131,7 +131,7 @@ describe("straight-line lowering", () => { }); }); - it("lowers free project function calls to StaticCallExpr on %dflt", () => { + it("lowers free project function calls through their shared binding", () => { const source = ` function add(a: number, b: number): number { return a + b; } let s = add(1, 2); @@ -140,7 +140,7 @@ describe("straight-line lowering", () => { const stmts = singleBlockStmts(defaultMethod(file)); const sAssign = assignmentTo(stmts, "s"); expect(sAssign!.right).toMatchObject({ - _: "StaticCallExpr", + _: "PtrCallExpr", method: { declaringClass: { name: "%dflt", declaringFile: { projectName: "proj", fileName: "test.ts" } }, name: "add", @@ -168,7 +168,7 @@ describe("straight-line lowering", () => { const defaultClass = namespace.classes!.find((clazz) => clazz.signature.name === "%dflt")!; const twiceArea = defaultClass.methods.find((method) => method.signature.name === "twiceArea")!; const call = singleBlockStmts(twiceArea).find( - (stmt): stmt is AssignStmtDto => stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr", + (stmt): stmt is AssignStmtDto => stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr", ); expect(call).toMatchObject({ diff --git a/jacodb-ets/ts-frontend/test/named-functions.spec.ts b/jacodb-ets/ts-frontend/test/named-functions.spec.ts new file mode 100644 index 000000000..72bd3c5aa --- /dev/null +++ b/jacodb-ets/ts-frontend/test/named-functions.spec.ts @@ -0,0 +1,298 @@ +import * as ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { Modifier, NAMED_FUNCTION_REFERENCE_PREFIX } from "../src/dto/constants"; +import { MethodDto } from "../src/dto/model"; +import { AssignStmtDto, StmtDto } from "../src/dto/stmts"; +import { defaultMethod, lower, lowerProject, methodByName } from "./util"; + +function statements(method: MethodDto): StmtDto[] { + return method.body!.cfg.blocks.flatMap((block) => block.stmts); +} + +function nativeResult(source: string, expression: string): unknown { + const js = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS }, + }).outputText; + return new Function("exports", `${js}\nreturn ${expression};`)({}); +} + +describe("named function bindings", () => { + it("hoists one mutable binding pointing at the original method before source statements", () => { + const source = ` + const alias = named; + const before = named(2); + function named(value: number): number { return value + 1; } + class Example { check(): boolean { return typeof named === "function" && named === named; } } + `; + const { file, diagnostics } = lower(source); + const defaultClass = file.classes.find((clazz) => clazz.signature.name === "%dflt")!; + const binding = defaultClass.fields.find((field) => field.signature.name === "named")!; + const stmts = statements(defaultMethod(file)); + const initialization = stmts.find((stmt): stmt is AssignStmtDto => stmt._ === "AssignStmt" + && stmt.left._ === "StaticFieldRef" && stmt.left.field.name === "named")!; + const readIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "StaticFieldRef" && stmt.right.field.name === "named"); + + expect(binding.modifiers & Modifier.STATIC).toBe(Modifier.STATIC); + expect(binding.modifiers & Modifier.CONST).toBe(0); + expect(initialization.right).toEqual({ + _: "Local", name: `${NAMED_FUNCTION_REFERENCE_PREFIX}named`, + type: { _: "FunctionType", signature: methodByName(file, "named").signature }, + }); + expect(stmts.indexOf(initialization)).toBeLessThan(readIndex); + expect(defaultClass.methods.map((method) => method.signature.name)).toEqual(["%dflt", "named"]); + expect(statements(methodByName(file, "check")).filter((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "StaticFieldRef" && stmt.right.field.name === "named")).toHaveLength(3); + expect(diagnostics.messages).toEqual([]); + expect(nativeResult(source, "[before, alias === named, new Example().check()]")).toEqual([3, true, true]); + }); + + it("uses the same binding for aliases, reassignment and direct calls, selected before arguments", () => { + const source = ` + function original(value: number): number { return value + 1; } + function replacement(value: number): number { return value + 10; } + function check(): number { + const alias = original; + const selected = original((original = replacement, 2)); + return selected * 100 + alias(2) * 10 + original(2); + } + `; + const { file, diagnostics } = lower(source); + const stmts = statements(methodByName(file, "check")); + const reads = stmts.filter((stmt): stmt is AssignStmtDto => stmt._ === "AssignStmt" + && stmt.right._ === "StaticFieldRef" && stmt.right.field.name === "original"); + const mutationIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" + && stmt.left._ === "StaticFieldRef" && stmt.left.field.name === "original"); + const calls = stmts.filter((stmt): stmt is AssignStmtDto => stmt._ === "AssignStmt" + && stmt.right._ === "PtrCallExpr"); + + expect(reads).toHaveLength(3); + expect(stmts.indexOf(reads[1])).toBeLessThan(mutationIndex); + expect(stmts.indexOf(calls[0])).toBeGreaterThan(mutationIndex); + expect(calls[0].right).toMatchObject({ ptr: reads[1].left }); + expect(calls[1].right).toMatchObject({ ptr: { name: "alias" } }); + expect(calls[2].right).toMatchObject({ ptr: reads[2].left }); + expect(diagnostics.messages).toEqual([]); + expect(nativeResult(source, "check()")).toBe(342); + }); + + it("keeps parameter and block bindings separate from the scope function", () => { + const source = ` + function named(): number { return 1; } + function parameter(named: () => number): number { return named(); } + function block(): number { + const before = named(); + { const named = () => 2; return before + named(); } + } + `; + const { file, diagnostics } = lower(source); + const parameter = statements(methodByName(file, "parameter")); + const block = statements(methodByName(file, "block")); + + expect(parameter).toContainEqual(expect.objectContaining({ + right: expect.objectContaining({ _: "PtrCallExpr", ptr: expect.objectContaining({ name: "named" }) }), + })); + expect(parameter.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "StaticFieldRef")).toBe(false); + expect(block.filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "StaticFieldRef" + && stmt.right.field.name === "named")).toHaveLength(1); + expect(block).toContainEqual(expect.objectContaining({ + right: expect.objectContaining({ _: "PtrCallExpr", ptr: expect.objectContaining({ name: "named" }) }), + })); + expect(diagnostics.messages).toEqual([]); + expect(nativeResult(source, "[parameter(() => 5), block()]")).toEqual([5, 3]); + }); + + it("preserves live module state and emits a captured nested closure body once", () => { + const source = ` + let state = 1; + function factory(seed: number): () => number { + let captured = seed; + return () => { captured++; return captured + state; }; + } + const alias = factory; + state = 10; + `; + const { file, diagnostics } = lower(source); + const methods = file.classes.flatMap((clazz) => clazz.methods); + const closure = methodByName(file, "%AM0$factory"); + + expect(methods.filter((method) => method.signature.name === "factory")).toHaveLength(1); + expect(methods.filter((method) => method.signature.name.startsWith("%AM"))).toHaveLength(1); + expect(statements(closure)).toContainEqual(expect.objectContaining({ + right: expect.objectContaining({ _: "StaticFieldRef", field: expect.objectContaining({ name: "state" }) }), + })); + expect(statements(closure).some((stmt) => stmt._ === "AssignStmt" && stmt.left._ === "ClosureFieldRef")).toBe(true); + expect(diagnostics.messages).toEqual([]); + expect(nativeResult(source, "alias(2)()")).toBe(13); + }); + + it("qualifies imported same-named functions by their original file", () => { + const sources = { + "/first.ts": "export function named(): number { return 1; }", + "/second.ts": "export function named(): number { return 2; }", + "/consumer.ts": ` + import { named as first } from "./first"; + import { named as second } from "./second"; + export function check(): number { const alias = first; return alias() + second(); } + `, + }; + const { file, diagnostics } = lowerProject(sources, "/consumer.ts"); + const reads = statements(methodByName(file, "check")).filter((stmt): stmt is AssignStmtDto => + stmt._ === "AssignStmt" && stmt.right._ === "StaticFieldRef"); + + expect(reads.map((stmt) => stmt.right)).toMatchObject([ + { field: { name: "named", declaringClass: { declaringFile: { fileName: "/first.ts" } } } }, + { field: { name: "named", declaringClass: { declaringFile: { fileName: "/second.ts" } } } }, + ]); + for (const stmt of reads) { + if (stmt.right._ !== "StaticFieldRef") throw new Error("expected binding read"); + const type = stmt.right.field.type; + expect(type).toMatchObject({ _: "FunctionType", signature: { declaringClass: stmt.right.field.declaringClass } }); + } + expect(diagnostics.messages).toEqual([]); + }); + + it("initializes an overload binding from its body signature exactly once", () => { + const source = ` + function named(value: number): number; + function named(value: string): string; + function named(value: number | string): number | string { return value; } + const alias = named; + `; + const { file, diagnostics } = lower(source); + const defaultClass = file.classes.find((clazz) => clazz.signature.name === "%dflt")!; + const implementation = defaultClass.methods.find((method) => method.signature.name === "named" && method.body)!; + const initializes = statements(defaultMethod(file)).filter((stmt) => stmt._ === "AssignStmt" + && stmt.left._ === "StaticFieldRef" && stmt.left.field.name === "named"); + + expect(defaultClass.fields.filter((field) => field.signature.name === "named")).toHaveLength(1); + expect(initializes).toHaveLength(1); + expect(initializes[0]).toMatchObject({ right: { type: { _: "FunctionType", signature: implementation.signature } } }); + expect(diagnostics.messages).toEqual([]); + expect(nativeResult(source, "[alias(2), alias('value')]")).toEqual([2, "value"]); + }); + + it("keeps bodyless ambient function values explicitly unsupported", () => { + const { file, diagnostics } = lower("declare function named(): number; const alias = named;"); + const defaultClass = file.classes.find((clazz) => clazz.signature.name === "%dflt")!; + + expect(defaultClass.fields.some((field) => field.signature.name === "named")).toBe(false); + expect(statements(defaultMethod(file))).toContainEqual(expect.objectContaining({ + right: expect.objectContaining({ _: "UnsupportedValue", kindName: "Identifier" }), + })); + expect(diagnostics.messages).toContainEqual(expect.stringContaining("runtime value of declaration")); + }); + + it("rejects ambiguous duplicate implementation bodies instead of inventing function identity", () => { + const { file, diagnostics } = lower(` + function named(): number { return 1; } + function named(): number { return 2; } + const alias = named; + `); + const defaultClass = file.classes.find((clazz) => clazz.signature.name === "%dflt")!; + + expect(defaultClass.fields.some((field) => field.signature.name === "named")).toBe(false); + expect(statements(defaultMethod(file))).toContainEqual(expect.objectContaining({ + right: expect.objectContaining({ _: "UnsupportedValue", kindName: "Identifier" }), + })); + expect(diagnostics.messages).toContainEqual(expect.stringContaining("ambiguous implementation bodies")); + }); + + it("uses the namespace owner for internal reads and pointer calls", () => { + const source = `namespace Scope { + export function named(): number { return 3; } + export function check(): boolean { const alias = named; return alias === named && named() === 3; } + }`; + const { file, diagnostics } = lower(source); + const defaultClass = file.namespaces[0].classes!.find((clazz) => clazz.signature.name === "%dflt")!; + const check = defaultClass.methods.find((method) => method.signature.name === "check")!; + const reads = statements(check).filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "StaticFieldRef"); + + expect(reads).toHaveLength(3); + expect(reads).toEqual(expect.arrayContaining([expect.objectContaining({ + right: expect.objectContaining({ field: expect.objectContaining({ + declaringClass: expect.objectContaining({ declaringNamespace: expect.objectContaining({ name: "Scope" }) }), + }) }), + })])); + expect(statements(check).some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr")).toBe(true); + expect(diagnostics.messages).toEqual([]); + expect(nativeResult(source, "Scope.check()")).toBe(true); + }); + + it("keeps namespace object properties distinct from the internal declaration binding", () => { + const source = `namespace Scope { + export function named(): number { return 1; } + export function replace(): void { named = other; } + function other(): number { return 2; } + } + Scope.replace(); + const result = Scope.named();`; + const { file, diagnostics } = lower(source); + const stmts = statements(defaultMethod(file)); + + expect(nativeResult(source, "result")).toBe(1); + expect(stmts).toContainEqual(expect.objectContaining({ + right: expect.objectContaining({ _: "UnsupportedValue", kindName: "Identifier", text: "Scope" }), + })); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr")).toBe(false); + expect(diagnostics.messages).toContainEqual(expect.stringContaining("runtime value of declaration 'Scope'")); + }); + + it("reads an imported namespace function through its live module binding and calls retained aliases", () => { + const sources = { + "/module.ts": ` + export function named(): number { return 1; } + export function replace(): void { named = replacement; } + function replacement(): number { return 2; } + `, + "/consumer.ts": ` + import * as namespace from "./module"; + import { replace } from "./module"; + export function check(): number { + const alias = namespace.named; + replace(); + const current = namespace.named; + return alias() * 10 + current(); + } + `, + }; + const { file, diagnostics } = lowerProject(sources, "/consumer.ts"); + const stmts = statements(methodByName(file, "check")); + const reads = stmts.filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "StaticFieldRef"); + + expect(reads).toHaveLength(3); + expect(reads.map((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "StaticFieldRef" && stmt.right.field.name)) + .toEqual(["named", "replace", "named"]); + expect(stmts.filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr" + || stmt._ === "CallStmt" && stmt.expr._ === "PtrCallExpr")).toHaveLength(3); + expect(diagnostics.messages).toEqual([]); + }); + + it("rejects namespace calls whose receiver differs from calling a retained alias", () => { + const sources = { + "/module.ts": "export function named(): boolean { return this === undefined; }", + "/consumer.ts": ` + import * as namespace from "./module"; + export function check(): boolean[] { + const alias = namespace.named; + return [namespace.named(), alias(), namespace.named?.(), (namespace.named)()]; + } + `, + }; + const { file, diagnostics } = lowerProject(sources, "/consumer.ts"); + const stmts = statements(methodByName(file, "check")); + const module = {}; + const compilerOptions = { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS }; + new Function("exports", ts.transpileModule(sources["/module.ts"], { compilerOptions }).outputText)(module); + const consumer: { check?: () => boolean[] } = {}; + new Function("exports", "require", ts.transpileModule(sources["/consumer.ts"], { compilerOptions }).outputText)( + consumer, () => module, + ); + + expect(consumer.check!()).toEqual([false, true, false, false]); + expect(stmts.filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue" + && stmt.right.kindName === "CallExpression")).toHaveLength(3); + expect(stmts.filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr")).toHaveLength(1); + expect(diagnostics.messages.filter((message) => message.includes("module namespace call receiver"))).toHaveLength(3); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/object-bindings-computed.spec.ts b/jacodb-ets/ts-frontend/test/object-bindings-computed.spec.ts index 6f635ad34..641c6d246 100644 --- a/jacodb-ets/ts-frontend/test/object-bindings-computed.spec.ts +++ b/jacodb-ets/ts-frontend/test/object-bindings-computed.spec.ts @@ -54,7 +54,7 @@ describe("computed object binding keys", () => { const { file, diagnostics } = lower(source); const stmts = methodByName(file, "pick").body!.cfg.blocks.flatMap((block) => block.stmts); const callIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" - && stmt.right._ === "StaticCallExpr" && stmt.right.method.name === "change"); + && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "change"); const readIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PropertyRef"); const read = stmts[readIndex] as AssignStmtDto; diff --git a/jacodb-ets/ts-frontend/test/object-computed.spec.ts b/jacodb-ets/ts-frontend/test/object-computed.spec.ts index 32447fb72..e505e3873 100644 --- a/jacodb-ets/ts-frontend/test/object-computed.spec.ts +++ b/jacodb-ets/ts-frontend/test/object-computed.spec.ts @@ -54,7 +54,7 @@ describe("computed object keys", () => { const { file, diagnostics } = lower(source); const stmts = methodByName(file, "read").body!.cfg.blocks.flatMap((block) => block.stmts); const keyCallIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" - && stmt.right._ === "StaticCallExpr" && stmt.right.method.name === "takeKey"); + && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "takeKey"); const storeIndex = stmts.findIndex((stmt) => stmt._ === "DefineDataPropertyStmt"); const keyStore = stmts[storeIndex] as DefineDataPropertyStmtDto; @@ -107,7 +107,7 @@ describe("computed object keys", () => { const { file, diagnostics } = lower(source); const stmts = methodByName(file, "pick").body!.cfg.blocks.flatMap((block) => block.stmts); const callIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" - && stmt.right._ === "StaticCallExpr" && stmt.right.method.name === "change"); + && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "change"); const readIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PropertyRef"); const read = stmts[readIndex] as AssignStmtDto; diff --git a/jacodb-ets/ts-frontend/test/regressions.spec.ts b/jacodb-ets/ts-frontend/test/regressions.spec.ts index 5016214f3..1da71e4fc 100644 --- a/jacodb-ets/ts-frontend/test/regressions.spec.ts +++ b/jacodb-ets/ts-frontend/test/regressions.spec.ts @@ -130,7 +130,7 @@ describe("default parameter initializers", () => { : [], ); const defaults = stmts.flatMap((stmt, index) => - stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr" && stmt.right.method.name === "record" + stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "record" ? [{ index, label: stmt.right.args[0] }] : [], ); @@ -185,7 +185,7 @@ describe("rest parameter calls", () => { right: expect.objectContaining({ _: "ParameterRef", index: 0, type: expect.objectContaining({ _: "ArrayType" }) }), })); const callArgs = allStmts(methodByName(roundTrip, "sample")) - .filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr" + .filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "count") .map((stmt) => stmt.right.args.length); expect(callArgs).toEqual([0, 2]); diff --git a/jacodb-ets/ts-frontend/test/spread-calls.spec.ts b/jacodb-ets/ts-frontend/test/spread-calls.spec.ts index 75434391c..f87b0bf53 100644 --- a/jacodb-ets/ts-frontend/test/spread-calls.spec.ts +++ b/jacodb-ets/ts-frontend/test/spread-calls.spec.ts @@ -8,7 +8,7 @@ function assertSpreadArgumentOrder(stmts: StmtDto[], methodName: string): void { const expansion = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "SpreadExpansionExpr"); const reads = stmts.filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef"); const call = stmts.find((stmt) => stmt._ === "AssignStmt" - && (stmt.right._ === "StaticCallExpr" || stmt.right._ === "InstanceCallExpr") + && (stmt.right._ === "PtrCallExpr" || stmt.right._ === "InstanceCallExpr") && stmt.right.method.name === methodName); expect(reads).toHaveLength(2); @@ -21,6 +21,7 @@ function assertSpreadArgumentOrder(stmts: StmtDto[], methodName: string): void { expect(call).toMatchObject({ _: "AssignStmt", right: { + _: methodName === "constructor" ? "InstanceCallExpr" : "PtrCallExpr", args: reads.map((stmt) => stmt._ === "AssignStmt" ? stmt.left : undefined), }, }); @@ -126,7 +127,7 @@ describe("fixed-size spread arguments", () => { const { file, diagnostics } = lower(source); const stmts = singleBlockStmts(methodByName(file, "value")); const calls = stmts.flatMap((stmt, index) => stmt._ === "AssignStmt" - && stmt.right._ === "StaticCallExpr" ? [{ name: stmt.right.method.name, index, args: stmt.right.args }] : []); + && stmt.right._ === "PtrCallExpr" ? [{ name: stmt.right.method.name, index, args: stmt.right.args }] : []); const reads = stmts.flatMap((stmt, index) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef" ? [index] : []); @@ -152,7 +153,8 @@ describe("fixed-size spread arguments", () => { const { file, diagnostics } = lower(source); const stmts = singleBlockStmts(methodByName(file, "sum")); - expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr" + expect(stmts.some((stmt) => stmt._ === "AssignStmt" + && (stmt.right._ === "PtrCallExpr" || stmt.right._ === "StaticCallExpr") && stmt.right.method.name === "add")).toBe(false); expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue" && stmt.right.kindName === "CallExpression")).toBe(true); @@ -202,7 +204,7 @@ describe("computed constructor arrays", () => { const roundTrip = JSON.parse(serializeEtsFile(file)); const stmts = singleBlockStmts(methodByName(roundTrip, "make")); const functionCalls = stmts.flatMap((stmt, index) => stmt._ === "AssignStmt" - && stmt.right._ === "StaticCallExpr" ? [{ name: stmt.right.method.name, index }] : []); + && stmt.right._ === "PtrCallExpr" ? [{ name: stmt.right.method.name, index }] : []); const readIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef"); const allocationIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr" && stmt.right.constructorValue?._ === "Local"); diff --git a/jacodb-ets/ts-frontend/test/spread-ordinary.spec.ts b/jacodb-ets/ts-frontend/test/spread-ordinary.spec.ts index 6ddf1ef9c..e3830b255 100644 --- a/jacodb-ets/ts-frontend/test/spread-ordinary.spec.ts +++ b/jacodb-ets/ts-frontend/test/spread-ordinary.spec.ts @@ -8,7 +8,7 @@ function assertSpreadArgumentOrder(stmts: StmtDto[], methodName: string): void { const expansion = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "SpreadExpansionExpr"); const reads = stmts.filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef"); const call = stmts.find((stmt) => stmt._ === "AssignStmt" - && (stmt.right._ === "StaticCallExpr" || stmt.right._ === "InstanceCallExpr") + && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === methodName); expect(reads).toHaveLength(2); @@ -21,6 +21,7 @@ function assertSpreadArgumentOrder(stmts: StmtDto[], methodName: string): void { expect(call).toMatchObject({ _: "AssignStmt", right: { + _: "PtrCallExpr", args: reads.map((stmt) => stmt._ === "AssignStmt" ? stmt.left : undefined), }, }); @@ -81,7 +82,7 @@ describe("ordinary call spreads", () => { const { file, diagnostics } = lower(source); const stmts = singleBlockStmts(methodByName(file, "value")); const calls = stmts.flatMap((stmt, index) => stmt._ === "AssignStmt" - && stmt.right._ === "StaticCallExpr" ? [{ name: stmt.right.method.name, index, args: stmt.right.args }] : []); + && stmt.right._ === "PtrCallExpr" ? [{ name: stmt.right.method.name, index, args: stmt.right.args }] : []); const reads = stmts.flatMap((stmt, index) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef" ? [index] : []); diff --git a/jacodb-ets/ts-frontend/test/state-and-order.spec.ts b/jacodb-ets/ts-frontend/test/state-and-order.spec.ts index 0c82fb2bd..8e3494d33 100644 --- a/jacodb-ets/ts-frontend/test/state-and-order.spec.ts +++ b/jacodb-ets/ts-frontend/test/state-and-order.spec.ts @@ -70,7 +70,7 @@ describe("call evaluation order", () => { const { file } = lower(source); const stmts = flattened(methodByName(file, "check")); const getNIndices = stmts.flatMap((stmt, index) => - stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr" + stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "getN" ? [index] : [], ); const unsupportedIndex = stmts.findIndex( @@ -102,7 +102,7 @@ describe("call evaluation order", () => { const { file } = lower(source); const stmts = flattened(methodByName(file, "check")); const calls = stmts.flatMap((stmt, index) => - stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr" + stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr" ? [{ index, name: stmt.right.method.name, result: stmt.left }] : [], ); @@ -146,21 +146,21 @@ describe("call evaluation order", () => { && stmt.right.name === "A")).toBe(false); }); - it("marks an unrepresented constructor declaration unsupported", () => { + it("preserves the current named function value in an instanceof check", () => { const { file, diagnostics } = lower(` function Constructor() {} function check(value: object): boolean { return value instanceof Constructor; } `); const stmts = flattened(methodByName(file, "check")); - expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue")).toBe(true); - expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "InstanceOfExpr")).toBe(false); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue")).toBe(false); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "InstanceOfExpr")).toBe(true); + expect(stmts).toContainEqual(expect.objectContaining({ + right: expect.objectContaining({ _: "StaticFieldRef", field: expect.objectContaining({ name: "Constructor" }) }), + })); expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "Local" && stmt.right.name === "Constructor")).toBe(false); - expect(diagnostics.messages).toEqual(expect.arrayContaining([ - expect.stringContaining("runtime value of declaration 'Constructor' is not represented"), - expect.stringContaining("instanceof constructor value cannot be represented"), - ])); + expect(diagnostics.messages).toEqual([]); }); it("does not call a function with an unbounded spread argument", () => { @@ -175,7 +175,7 @@ describe("call evaluation order", () => { `); const stmts = flattened(methodByName(file, "check")); const calls = stmts.flatMap((stmt, index) => - stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr" + stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr" ? [{ index, name: stmt.right.method.name }] : [], ); @@ -326,7 +326,7 @@ describe("expression evaluation snapshots", () => { && stmt.right.value === "2", ); const call = stmts.find( - (stmt) => stmt._ === "CallStmt" && stmt.expr._ === "StaticCallExpr" && stmt.expr.method.name === "sink", + (stmt) => stmt._ === "CallStmt" && stmt.expr._ === "PtrCallExpr" && stmt.expr.method.name === "sink", ); expect(snapshot).toBeDefined(); expect(stmts.indexOf(snapshot!)).toBeLessThan(stmts.indexOf(mutation!)); @@ -345,7 +345,7 @@ describe("expression evaluation snapshots", () => { `); const stmts = flattened(methodByName(file, "f")); const call = stmts.find( - (stmt) => stmt._ === "CallStmt" && stmt.expr._ === "StaticCallExpr" && stmt.expr.method.name === "sink", + (stmt) => stmt._ === "CallStmt" && stmt.expr._ === "PtrCallExpr" && stmt.expr.method.name === "sink", ) as Extract; const firstArgument = call.expr.args[0] as { name: string }; const snapshot = stmts.find( diff --git a/jacodb-ets/ts-frontend/test/tagged-templates.spec.ts b/jacodb-ets/ts-frontend/test/tagged-templates.spec.ts index c4907299f..50a716226 100644 --- a/jacodb-ets/ts-frontend/test/tagged-templates.spec.ts +++ b/jacodb-ets/ts-frontend/test/tagged-templates.spec.ts @@ -2,6 +2,7 @@ import * as ts from "typescript"; import { describe, expect, it } from "vitest"; import { serializeEtsFile } from "../src/serialize"; import { lower, methodByName, singleBlockStmts } from "./util"; +import { executor } from "./execute"; function runTypeScript(source: string, expression: string): unknown { const js = ts.transpileModule(source, { @@ -19,7 +20,7 @@ describe("tagged templates", () => { const roundTrip = JSON.parse(serializeEtsFile(file)); const stmts = singleBlockStmts(methodByName(roundTrip, "value")); const creation = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "TemplateObjectExpr"); - const call = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr"); + const call = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr"); expect(creation).toMatchObject({ right: { cooked: ["hello"], raw: ["hello"] } }); expect(call).toMatchObject({ right: { method: { name: "first" }, args: [creation?._ === "AssignStmt" ? creation.left : undefined] } }); @@ -43,10 +44,11 @@ describe("tagged templates", () => { const stmts = singleBlockStmts(methodByName(JSON.parse(serializeEtsFile(file)), "value")); const propertyIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PropertyRef"); const templateIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "TemplateObjectExpr"); - const substitutionIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr" + const substitutionIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "substitution"); const property = stmts[propertyIndex]; - const call = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr"); + const call = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr" + && stmt.right.method.name === "tag"); expect(propertyIndex).toBeGreaterThanOrEqual(0); expect(templateIndex).toBeGreaterThan(propertyIndex); @@ -99,4 +101,36 @@ describe("tagged templates", () => { expect(diagnostics.messages).toEqual([]); expect(runTypeScript(source, "exports.value()")).toBe("undefined\\unicode"); }); + + it("reads the current named tag and selects it before a substitution reassigns the binding", () => { + const source = ` + function tag(parts: TemplateStringsArray, value: number): number { return value + 1; } + function replacement(parts: TemplateStringsArray, value: number): number { return value + 10; } + function reset(): number { tag = replacement; return 2; } + export function check(): number { + const alias = tag; + const selected = tag\`text\${reset()}\`; + return selected * 100 + alias\`text\${2}\` * 10 + tag\`text\${2}\`; + } + `; + const { file, diagnostics } = lower(source); + const serialized = JSON.parse(serializeEtsFile(file)); + const stmts = singleBlockStmts(methodByName(serialized, "check")); + const selected = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PtrCallExpr" + && stmt.right.method.name === "tag")!; + if (selected._ !== "AssignStmt" || selected.right._ !== "PtrCallExpr") throw new Error("Missing tag call"); + const selectedPtr = selected.right.ptr; + const selectionIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" + && stmt.left._ === "Local" && stmt.left.name === selectedPtr.name); + const mutationIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "PtrCallExpr" && stmt.right.method.name === "reset"); + + expect(selectionIndex).toBeGreaterThanOrEqual(0); + expect(mutationIndex).toBeGreaterThan(selectionIndex); + expect(stmts.indexOf(selected)).toBeGreaterThan(mutationIndex); + expect(diagnostics.messages).toEqual([]); + expect(executor(serialized).call("check")).toBe(342); + expect(runTypeScript(source, "exports.check()")).toBe(342); + }); + });