diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsCallLoweringTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsCallLoweringTest.kt index b53f43daa..12ddba5fa 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsCallLoweringTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsCallLoweringTest.kt @@ -33,11 +33,15 @@ import org.jacodb.ets.dto.TemplateObjectExprDto import org.jacodb.ets.dto.dtoModule import org.jacodb.ets.dto.toEtsFile import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsCallExpr import org.jacodb.ets.model.EtsNewClassExpr import org.jacodb.ets.model.EtsNewExpr +import org.jacodb.ets.model.EtsNullType import org.jacodb.ets.model.EtsPtrCallExpr import org.jacodb.ets.model.EtsSpreadExpansionExpr import org.jacodb.ets.model.EtsTemplateObjectExpr +import org.jacodb.ets.model.EtsUndefinedType +import org.jacodb.ets.model.EtsUnionType import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME import org.jacodb.ets.utils.EtsIrProvider import org.jacodb.ets.utils.generateEtsIR @@ -69,6 +73,27 @@ class EtsTsCallLoweringTest { .methods.single { it.signature.name == method }.body!!.cfg.blocks .flatMap { it.stmts }.filterIsInstance() + @Test + fun `call return type retains explicit nullish alternatives through serialization`() { + val dto = frontendRoundTrip( + """ + type Maybe = T | null | undefined; + declare class External { static choice(): Maybe; } + export function caller() { return External.choice<"known">(); } + """.trimIndent(), + ) + val method = dto.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.name == "caller" } + val call = method.cfg.stmts.filterIsInstance() + .mapNotNull { it.rhv as? EtsCallExpr }.single { it.callee.name == "choice" } + val resultType = call.type as EtsUnionType + + assertTrue(EtsNullType in resultType.types) + assertTrue(EtsUndefinedType in resultType.types) + assertEquals(3, resultType.types.size) + assertEquals(resultType, call.callee.returnType) + } + @Test fun `comma constructor values decode as locals after computed access`() { val dto = frontendRoundTrip( diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 925a7f412..a59f23cae 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -2012,6 +2012,12 @@ export class ExprLowerer { parameters = this.parametersOfDeclaration(decl); } returnType = this.m.converter.convertType(this.m.checker.getReturnTypeOfSignature(signature)); + if (decl?.type !== undefined) { + // With strictNullChecks disabled the checker erases explicitly declared nullish alternatives. + // Keep its instantiated generic result, then restore only those explicit alternatives. + const declared = this.m.converter.convertTypeNode(decl.type); + returnType = preserveDeclaredNullishReturn(returnType, declared); + } } } catch { // keep unknowns @@ -2066,6 +2072,15 @@ export class ExprLowerer { } } +function preserveDeclaredNullishReturn(resolved: TypeDto, declared: TypeDto): TypeDto { + const members = (type: TypeDto): TypeDto[] => type._ === "UnionType" ? type.types.flatMap(members) : [type]; + const resolvedMembers = members(resolved); + const declaredNullish = members(declared).filter((type) => type._ === "NullType" || type._ === "UndefinedType"); + const missing = declaredNullish.filter((type) => !resolvedMembers.some((member) => member._ === type._)); + const uniqueMissing = missing.filter((type, index) => missing.findIndex((member) => member._ === type._) === index); + return uniqueMissing.length === 0 ? resolved : { _: "UnionType", types: [...resolvedMembers, ...uniqueMissing] }; +} + // ---------------------------------------------------------------------- // Helpers // ---------------------------------------------------------------------- diff --git a/jacodb-ets/ts-frontend/test/nullish-call-results.spec.ts b/jacodb-ets/ts-frontend/test/nullish-call-results.spec.ts new file mode 100644 index 000000000..026672b2f --- /dev/null +++ b/jacodb-ets/ts-frontend/test/nullish-call-results.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { TypeDto } from "../src/dto/types"; +import { CallExprDto } from "../src/dto/values"; +import { serializeEtsFile } from "../src/serialize"; +import { compile, lower, methodByName } from "./util"; + +function resultTypes(source: string): TypeDto[] { + // A script-level External declaration collides with lib.dom's global External interface. + const moduleSource = `export {};\n${source}`; + const { program } = compile(moduleSource); + expect(program.getSemanticDiagnostics()).toEqual([]); + + const { file, diagnostics } = lower(moduleSource); + expect(diagnostics.messages).toEqual([]); + const roundTrip = JSON.parse(serializeEtsFile(file)); + const method = methodByName(roundTrip, "caller"); + const calls = method.body!.cfg.blocks.flatMap((block) => block.stmts).flatMap((statement) => { + const value = statement._ === "AssignStmt" ? statement.right : undefined; + const isCall = value?._ === "StaticCallExpr" || value?._ === "InstanceCallExpr" || value?._ === "PtrCallExpr"; + return isCall ? [value as CallExprDto] : []; + }); + const result = calls.find((call) => call.method.name === "choice")!.method.returnType; + return result._ === "UnionType" ? result.types : [result]; +} + +describe("declared nullish call results", () => { + it("keeps null and undefined beside finite literals without strictNullChecks", () => { + const types = resultTypes(` + declare class External { static choice(): "known" | 7 | true | null | undefined; } + function caller() { return External.choice(); } + `); + + expect(types).toEqual(expect.arrayContaining([ + { _: "LiteralType", literal: "known" }, { _: "LiteralType", literal: 7 }, + { _: "LiteralType", literal: true }, { _: "NullType" }, { _: "UndefinedType" }, + ])); + expect(types).toHaveLength(5); + }); + + it("preserves generic instantiation while restoring declared alias nullish members", () => { + const types = resultTypes(` + type Maybe = T | null | undefined; + declare class External { static choice(): Maybe; } + function caller() { return External.choice<"kept">(); } + `); + + expect(types).toEqual(expect.arrayContaining([ + { _: "LiteralType", literal: "kept" }, { _: "NullType" }, { _: "UndefinedType" }, + ])); + expect(types).toHaveLength(3); + }); + + it("does not turn a nullable generic constraint into a return alternative", () => { + const types = resultTypes(` + declare class External { static choice(): T; } + function caller() { return External.choice<7>(); } + `); + + expect(types).toEqual([{ _: "LiteralType", literal: 7 }]); + }); +});