diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsArrayParameterTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsArrayParameterTest.kt index 2431ad28b..f145df058 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsArrayParameterTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsArrayParameterTest.kt @@ -22,6 +22,7 @@ import org.jacodb.ets.dto.EtsFileDto import org.jacodb.ets.dto.dtoModule import org.jacodb.ets.dto.toEtsFile import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsIfStmt import org.jacodb.ets.model.EtsNewArrayExpr import org.jacodb.ets.model.EtsPtrCallExpr import org.jacodb.ets.utils.EtsIrProvider @@ -63,4 +64,13 @@ class EtsTsArrayParameterTest { assertTrue(values.any { it is EtsNewArrayExpr }) assertTrue(values.filterIsInstance().any { it.callee.name == "next" && it.receiver != null }) } + + @Test + fun `parameter defaults survive frontend JSON and model conversion`() { + val dto = roundTrip("export function value(a = 3, b = a + 1): number { return b; }") + + val method = dto.toEtsFile().classes.flatMap { it.methods }.single { it.name == "value" } + + assertTrue(method.cfg.stmts.any { it is EtsIfStmt }) + } } diff --git a/jacodb-ets/ts-frontend/src/lowering/astUtils.ts b/jacodb-ets/ts-frontend/src/lowering/astUtils.ts index 48a3c3ca9..7a68f7ed0 100644 --- a/jacodb-ets/ts-frontend/src/lowering/astUtils.ts +++ b/jacodb-ets/ts-frontend/src/lowering/astUtils.ts @@ -289,7 +289,13 @@ export function parameterType(ctx: LoweringContext, p: ts.ParameterDeclaration): export interface BuiltParameters { parameters: MethodParameterDto[]; - prologueParams: { name: string; type: TypeDto; identifier?: ts.Identifier; pattern?: ts.BindingPattern }[]; + prologueParams: { + name: string; + type: TypeDto; + identifier?: ts.Identifier; + pattern?: ts.BindingPattern; + initializer?: ts.Expression; + }[]; } export function buildParameters(ctx: LoweringContext, decl: ts.SignatureDeclarationBase): BuiltParameters { @@ -316,6 +322,7 @@ export function buildParameters(ctx: LoweringContext, decl: ts.SignatureDeclarat type, identifier: ts.isIdentifier(p.name) ? p.name : undefined, pattern: ts.isObjectBindingPattern(p.name) || ts.isArrayBindingPattern(p.name) ? p.name : undefined, + initializer: p.initializer, }); } return { parameters, prologueParams }; diff --git a/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts index d141bd7fb..612d7363d 100644 --- a/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts +++ b/jacodb-ets/ts-frontend/src/lowering/classBuilder.ts @@ -311,9 +311,10 @@ export class ClassBuilder { if (decl.body !== undefined) { const isStaticMethod = (modifiersOf(decl) & Modifier.STATIC) !== 0; const m = new MethodContext(this.ctx, declaringClass, name, isStaticMethod); - m.emitPrologue(prologueParams); const lowerer = new StmtLowerer(m); - this.lowerParameterPatterns(lowerer, m, prologueParams); + m.emitPrologue(prologueParams, (parameter, local) => + lowerer.lowerParameterBinding(parameter, local), + ); lowerer.lowerStatements(decl.body.statements); method.body = m.build(); } @@ -343,9 +344,10 @@ export class ClassBuilder { const classType: ClassTypeDto = { _: "ClassType", signature: declaringClass }; const m = new MethodContext(this.ctx, declaringClass, CONSTRUCTOR_NAME); - m.emitPrologue(prologueParams); const lowerer = new StmtLowerer(m); - this.lowerParameterPatterns(lowerer, m, prologueParams); + m.emitPrologue(prologueParams, (parameter, local) => + lowerer.lowerParameterBinding(parameter, local), + ); const thisLocal = m.getOrCreateLocal("this", classType); const emitInitializers = (): void => { // Parameter properties are initialized at constructor entry (after @@ -420,21 +422,6 @@ export class ClassBuilder { }; } - private lowerParameterPatterns( - lowerer: StmtLowerer, - m: MethodContext, - parameters: { name: string; type: TypeDto; pattern?: ts.BindingPattern }[], - ): void { - for (const parameter of parameters) { - if (parameter.pattern !== undefined) { - lowerer.lowerParameterBindingPattern( - parameter.pattern, - m.getOrCreateLocal(parameter.name, parameter.type), - ); - } - } - } - private emitParameterProperties( m: MethodContext, thisLocal: ReturnType, diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 40ac7b06d..4634295ec 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -68,7 +68,7 @@ import { } from "../dto/values"; import { syntaxKindName, unsupportedValue } from "./diagnostics"; import { MethodContext } from "./methodBuilder"; -import { IteratorLowerer } from "./iteratorLowering"; +import { IteratorLowerer, throwRuntimeError } from "./iteratorLowering"; const RELATION_BY_SYNTAX: Partial> = { [ts.SyntaxKind.EqualsEqualsToken]: "==", @@ -126,7 +126,10 @@ export class LoweringError extends Error {} export type FunctionBodyLowerer = ( m: MethodContext, body: ts.ConciseBody, - parameters?: BuiltParameters["prologueParams"], + emitPrologue: (afterParameter: ( + parameter: BuiltParameters["prologueParams"][number], + local: LocalDto, + ) => void) => void, ) => void; type OptionalChainSegment = ts.PropertyAccessExpression | ts.ElementAccessExpression | ts.CallExpression; @@ -325,6 +328,10 @@ export class ExprLowerer { // ------------------------------------------------------------------ private lowerIdentifier(node: ts.Identifier): ValueDto { + if (this.m.isUninitializedParameter(node)) { + throwRuntimeError(this.m, "ReferenceError", `Cannot access '${node.text}' before initialization`); + return constant("undefined", UNDEFINED_TYPE); + } const nonFiniteNumber = this.builtInNonFiniteNumber(node); if (nonFiniteNumber !== undefined) { return constant(nonFiniteNumber, NUMBER_TYPE); @@ -637,6 +644,9 @@ export class ExprLowerer { /** Assignment target. */ lowerLValue(node: ts.Expression, laterExpression?: ts.Expression): LValueDto { + if (ts.isIdentifier(node) && this.m.isUninitializedParameter(node)) { + throwRuntimeError(this.m, "ReferenceError", `Cannot access '${node.text}' before initialization`); + } if (ts.isParenthesizedExpression(node)) { return this.lowerLValue(node.expression, laterExpression); } @@ -829,6 +839,15 @@ export class ExprLowerer { /** `x = e`, `x += e`, obj.f = e, arr[i] = e; returns the assigned value. */ lowerAssignment(node: ts.BinaryExpression): ValueDto { const opKind = node.operatorToken.kind; + const targetNode = unwrapTransparentExpression(node.left); + if (opKind === ts.SyntaxKind.EqualsToken + && ts.isIdentifier(targetNode) && this.m.isUninitializedParameter(targetNode)) { + // Resolving the binding is allowed; PutValue checks the TDZ after the RHS. + const rhs = this.lowerToImmediate(node.right); + throwRuntimeError(this.m, "ReferenceError", `Cannot access '${targetNode.text}' before initialization`); + return rhs; + } + const target = this.lowerLValue(node.left, node.right); if ( @@ -1616,7 +1635,11 @@ export class ExprLowerer { const { parameters, prologueParams } = buildParameters(this.m.ctx, node); const returnType = returnTypeOf(this.m.ctx, node); const baseSignature: MethodSignatureDto = { declaringClass, name, parameters, returnType }; - const captures = collectCapturedIdentifiers(node, this.m.checker) + const capturedIdentifiers = collectCapturedIdentifiers(node, this.m.checker); + if (capturedIdentifiers.some((identifier) => this.m.isUninitializedParameter(identifier))) { + throw new LoweringError("closure capturing an uninitialized parameter requires a runtime temporal-dead-zone cell"); + } + const captures = capturedIdentifiers .filter((identifier) => this.m.moduleFieldForIdentifier(identifier) === undefined) .map((identifier) => this.m.captureForIdentifier(identifier, this.safeTypeOf(identifier))); @@ -1661,12 +1684,15 @@ export class ExprLowerer { name, ts.isArrowFunction(node) && this.m.isStaticMethod, ); - if (environment === undefined) { - closureContext.emitPrologue(prologueParams); - } else { - closureContext.emitClosurePrologue(environment.name, environment.type, captures, prologueParams, lexicalThis); - } - this.lowerFunctionBody(closureContext, node.body, prologueParams); + this.lowerFunctionBody(closureContext, node.body, (afterParameter) => { + if (environment === undefined) { + closureContext.emitPrologue(prologueParams, afterParameter); + } else { + closureContext.emitClosurePrologue( + environment.name, environment.type, captures, prologueParams, afterParameter, lexicalThis, + ); + } + }); registry.methods.push({ signature, modifiers: modifiersOf(node), @@ -1753,9 +1779,10 @@ export class ExprLowerer { returnType: returnTypeOf(this.m.ctx, property), }; const methodContext = new MethodContext(this.m.ctx, signature, methodName); - methodContext.emitPrologue(prologueParams); if (property.body !== undefined) { - this.lowerFunctionBody(methodContext, property.body, prologueParams); + this.lowerFunctionBody(methodContext, property.body, (afterParameter) => + methodContext.emitPrologue(prologueParams, afterParameter), + ); } methods.push({ signature: methodSignature, @@ -2229,6 +2256,7 @@ function collectCapturedIdentifiers( } ts.forEachChild(node, visit); }; + for (const parameter of closure.parameters) visit(parameter); visit(closure.body); // Declaration nodes may be visited after their first reference, so filter at the end. for (const symbol of [...captures.keys()]) { diff --git a/jacodb-ets/ts-frontend/src/lowering/methodBuilder.ts b/jacodb-ets/ts-frontend/src/lowering/methodBuilder.ts index d851ba0c3..191d23671 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 type { BuiltParameters } from "./astUtils"; import { CfgBuilder } from "./cfg"; import { Diagnostics, syntaxKindName } from "./diagnostics"; @@ -72,6 +73,7 @@ export class MethodContext { private readonly localNameCounters = new Map(); private tempCount = 0; private closureEnvCount = 0; + private readonly uninitializedParameters = new Set(); constructor( readonly ctx: LoweringContext, @@ -289,8 +291,42 @@ export class MethodContext { return this.getOrCreateLocal(name, type); } + /** Parameter bindings are initially in the temporal dead zone, including patterns. */ + private beginParameterInitialization(parameters: BuiltParameters["prologueParams"]): void { + const addBinding = (name: ts.BindingName): void => { + if (ts.isIdentifier(name)) { + const symbol = this.symbolForIdentifier(name); + if (symbol !== undefined) this.uninitializedParameters.add(symbol); + } else { + for (const element of name.elements) { + if (ts.isBindingElement(element)) addBinding(element.name); + } + } + }; + for (const parameter of parameters) { + if (parameter.identifier !== undefined) addBinding(parameter.identifier); + if (parameter.pattern !== undefined) addBinding(parameter.pattern); + } + } + + isUninitializedParameter(identifier: ts.Identifier): boolean { + const symbol = this.symbolForIdentifier(identifier); + return symbol !== undefined && this.uninitializedParameters.has(symbol); + } + + markParameterInitialized(identifier: ts.Identifier): void { + const symbol = this.symbolForIdentifier(identifier); + if (symbol !== undefined) this.uninitializedParameters.delete(symbol); + } + /** Emit the standard prologue: parameter assignments, then `this := ThisRef`. */ - emitPrologue(parameters: { name: string; type: TypeDto; identifier?: ts.Identifier }[]): void { + emitPrologue( + parameters: BuiltParameters["prologueParams"], + afterParameter?: (parameter: BuiltParameters["prologueParams"][number], local: LocalDto) => void, + ): void { + this.beginParameterInitialization(parameters); + const earlyThis = parameters.some((parameter) => parameter.initializer !== undefined || parameter.pattern !== undefined); + if (earlyThis) this.emitThisAssignment(); parameters.forEach((param, index) => { const local = param.identifier !== undefined ? this.localForIdentifier(param.identifier, param.type) @@ -300,8 +336,11 @@ export class MethodContext { left: local, right: { _: "ParameterRef", index, type: param.type }, }); + afterParameter?.(param, local); + if (param.identifier !== undefined) this.markParameterInitialized(param.identifier); }); - this.emitThisAssignment(); + this.uninitializedParameters.clear(); + if (!earlyThis) this.emitThisAssignment(); } /** @@ -312,7 +351,8 @@ export class MethodContext { environmentName: string, environmentType: LexicalEnvTypeDto, captures: ClosureCapture[], - parameters: { name: string; type: TypeDto; identifier?: ts.Identifier }[], + parameters: BuiltParameters["prologueParams"], + afterParameter?: (parameter: BuiltParameters["prologueParams"][number], local: LocalDto) => void, lexicalThis?: LocalDto, ): void { const environment = this.getOrCreateLocal(environmentName, environmentType); @@ -321,16 +361,6 @@ export class MethodContext { left: environment, right: { _: "ParameterRef", index: 0, type: environmentType }, }); - parameters.forEach((param, index) => { - const local = param.identifier !== undefined - ? this.localForIdentifier(param.identifier, param.type) - : this.getOrCreateLocal(param.name, param.type); - this.cfg.emit({ - _: "AssignStmt", - left: local, - right: { _: "ParameterRef", index: index + 1, type: param.type }, - }); - }); const forwardedEnvironments = new Map(); for (const capture of captures) { const symbol = this.symbolForIdentifier(capture.identifier); @@ -373,7 +403,24 @@ export class MethodContext { fieldName: lexicalThis.name, type: lexicalThis.type, }; - this.emitThisAssignment(receiver); + + this.beginParameterInitialization(parameters); + const earlyThis = parameters.some((parameter) => parameter.initializer !== undefined || parameter.pattern !== undefined); + if (earlyThis) this.emitThisAssignment(receiver); + parameters.forEach((param, index) => { + const local = param.identifier !== undefined + ? this.localForIdentifier(param.identifier, param.type) + : this.getOrCreateLocal(param.name, param.type); + this.cfg.emit({ + _: "AssignStmt", + left: local, + right: { _: "ParameterRef", index: index + 1, type: param.type }, + }); + afterParameter?.(param, local); + if (param.identifier !== undefined) this.markParameterInitialized(param.identifier); + }); + this.uninitializedParameters.clear(); + if (!earlyThis) this.emitThisAssignment(receiver); } private emitThisAssignment(receiver?: ClosureFieldRefDto): void { diff --git a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts index af169dd25..de5fa67a6 100644 --- a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts @@ -26,6 +26,7 @@ import { MethodSignatureDto, UNKNOWN_CLASS_SIGNATURE, UNKNOWN_FILE_SIGNATURE } f import { BOOLEAN_TYPE, NUMBER_TYPE, STRING_TYPE, TypeDto, UNDEFINED_TYPE, UNKNOWN_TYPE } from "../dto/types"; import { ImmediateDto, LValueDto, LocalDto, ValueDto } from "../dto/values"; import { exportAssignmentSupport } from "./astUtils"; +import type { BuiltParameters } from "./astUtils"; import { Label } from "./cfg"; import { IteratorLowerer } from "./iteratorLowering"; import { unsupportedStmt } from "./diagnostics"; @@ -70,16 +71,9 @@ export class StmtLowerer { ) { // Nested function bodies (closures, object-literal methods) are lowered // with a fresh StmtLowerer over their own MethodContext. - this.expr = new ExprLowerer(m, (nestedContext, body, parameters) => { + this.expr = new ExprLowerer(m, (nestedContext, body, emitPrologue) => { const nested = new StmtLowerer(nestedContext); - parameters?.forEach((parameter) => { - if (parameter.pattern !== undefined) { - nested.lowerParameterBindingPattern( - parameter.pattern, - nestedContext.getOrCreateLocal(parameter.name, parameter.type), - ); - } - }); + emitPrologue((parameter, local) => nested.lowerParameterBinding(parameter, local)); if (ts.isBlock(body)) { nested.lowerStatements(body.statements); } else { @@ -827,9 +821,15 @@ export class StmtLowerer { // Destructuring // ------------------------------------------------------------------ - /** Unpack one pattern parameter after its `%patN := ParameterRef(i)` prologue binding. */ - lowerParameterBindingPattern(pattern: ts.BindingPattern, source: LocalDto): void { - this.lowerBindingPattern(pattern, source); + /** Apply a parameter default and unpack its pattern before the next argument is bound. */ + lowerParameterBinding(parameter: BuiltParameters["prologueParams"][number], source: LocalDto): void { + const initializer = parameter.initializer; + if (initializer !== undefined) { + this.m.withOrigin(initializer, () => this.emitDefaultValue(source, parameter.type, initializer)); + } + if (parameter.pattern !== undefined) { + this.lowerBindingPattern(parameter.pattern, source); + } } /** `{a, b: {c}, d = 1}` / `[x, , y]` unpacked from `source` via field/array refs. */ @@ -950,6 +950,7 @@ export class StmtLowerer { if (defaultInit !== undefined) { this.emitDefaultValue(destination, type, defaultInit); } + this.m.markParameterInitialized(target); return; } // Nested pattern: unpack through a temp. diff --git a/jacodb-ets/ts-frontend/test/default-parameters.spec.ts b/jacodb-ets/ts-frontend/test/default-parameters.spec.ts new file mode 100644 index 000000000..f41bea922 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/default-parameters.spec.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { consumers } from "./array-test-consumers"; +import { lower } from "./util"; +import { serializeEtsFile } from "../src/serialize"; + +describe("default parameter observable behavior", () => { + it("initializes destructured parameter bindings before later defaults", () => { + const { ir, native } = consumers("function f([a, ...tail] = [1, 2], b = tail.length) { return [a, b]; }", "f"); + + for (const args of [[], [undefined], [[0, 7, 8]]]) { + expect(ir(...args)).toEqual(native(...args)); + } + }); + + it("records ordinary closure captures referenced only by a parameter default", () => { + const source = "function make(seed: number) { return function(value = seed) { return value; }; }"; + const { file, diagnostics } = lower(source); + const roundTripped = JSON.parse(serializeEtsFile(file)); + const nested = roundTripped.classes.flatMap((clazz) => clazz.methods) + .find((method) => method.signature.name.startsWith("%AM")); + const environment = nested!.body!.locals.find((local) => local.type._ === "LexicalEnvType"); + const statements = nested!.body!.cfg.blocks.flatMap((block) => block.stmts); + + expect(diagnostics.messages).toEqual([]); + expect(environment.type.closures.some((capture) => capture.name === "seed")).toBe(true); + expect(statements.some((statement) => statement._ === "AssignStmt" + && statement.right._ === "ClosureFieldRef" && statement.right.fieldName === "seed")).toBe(true); + expect(statements.some((statement) => statement._ === "IfStmt")).toBe(true); + }); + + it("keeps closures over uninitialized parameters explicitly unsupported", () => { + const { diagnostics } = lower("function f(a = function() { return a; }) { return a; }"); + + expect(diagnostics.messages.some((message) => message.includes("uninitialized parameter"))).toBe(true); + }); + + it("binds earlier parameters before defaults and keeps falsy provided arguments", () => { + const { ir, native } = consumers("function f(a = 3, b = a + 1) { return [a, b]; }", "f"); + + for (const args of [[], [undefined], [0], [null], [false], [0, 7]]) { + expect(ir(...args)).toEqual(native(...args)); + } + }); + + for (const source of ["function f(a = a) { return a; }", "function f(a = b, b = 2) { return a; }"]) { + it(`preserves the parameter temporal dead zone: ${source}`, () => { + const { ir, native } = consumers(source, "f"); + + expect(() => native()).toThrow(ReferenceError); + expect(() => ir()).toThrow(ReferenceError); + expect(ir(7)).toEqual(native(7)); + }); + } + + it("resolves parameter defaults outside the function body's declarations", () => { + const { ir, native, initialize } = consumers("const seed = 7; function f(a = seed) { let seed = 9; return a; }", "f"); + initialize(); + + expect(ir()).toEqual(native()); + expect(ir()).toBe(7); + }); + + for (const source of [ + "function f(a = (b = mark()), b = 1) { return a; }", + "function f(a = (a = mark())) { return a; }", + "function f(a = ((b) = mark()), b = 1) { return a; }", + ]) { + it(`evaluates assignment RHS before the TDZ write fails: ${source}`, () => { + let calls = 0; + const { ir, native } = consumers(source, "f", { mark: () => ++calls }); + + expect(() => native()).toThrow(ReferenceError); + expect(calls).toBe(1); + + calls = 0; + expect(() => ir()).toThrow(ReferenceError); + expect(calls).toBe(1); + }); + } + + it("preserves an assignment RHS throw before the TDZ write", () => { + const failure = new Error("RHS failure"); + const { ir, native } = consumers("function f(a = (b = mark()), b = 1) { return a; }", "f", { + mark: () => { throw failure; }, + }); + + expect(() => native()).toThrow(failure); + expect(() => ir()).toThrow(failure); + }); + + for (const operator of ["+=", "&&=", "||=", "??="]) { + it(`checks the TDZ read before the RHS of ${operator}`, () => { + let calls = 0; + const source = `function f(a = (b ${operator} mark()), b = 1) { return a; }`; + const { ir, native } = consumers(source, "f", { mark: () => ++calls }); + + expect(() => native()).toThrow(ReferenceError); + expect(calls).toBe(0); + expect(() => ir()).toThrow(ReferenceError); + expect(calls).toBe(0); + }); + } + +});