diff --git a/jacodb-ets/ts-frontend/package-lock.json b/jacodb-ets/ts-frontend/package-lock.json index f17c12d58..d234255cc 100644 --- a/jacodb-ets/ts-frontend/package-lock.json +++ b/jacodb-ets/ts-frontend/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { - "typescript": "^5.4.5" + "typescript": "5.9.3" }, "devDependencies": { "@types/node": "^18.19.0", @@ -1413,9 +1413,9 @@ } }, "node_modules/typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/jacodb-ets/ts-frontend/package.json b/jacodb-ets/ts-frontend/package.json index 061aa1d1e..7814e319f 100644 --- a/jacodb-ets/ts-frontend/package.json +++ b/jacodb-ets/ts-frontend/package.json @@ -14,7 +14,7 @@ "test": "vitest run --testTimeout=15000" }, "dependencies": { - "typescript": "^5.4.5" + "typescript": "5.9.3" }, "devDependencies": { "@types/node": "^18.19.0", diff --git a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts index 815d59c64..e295a0126 100644 --- a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts @@ -91,29 +91,31 @@ export class StmtLowerer { // snapshot captures BEFORE the captured variables are assigned. Hoist only the // declarations whose values are evaluated earlier in the same statement list; // a reference in a deferred closure body cannot force early capture. - const hoisted = new Set(); + const declarations = new Map(); statements.forEach((statement, index) => { - if (!ts.isFunctionDeclaration(statement) || statement.name === undefined) { - return; - } - const functionSymbol = this.m.converter.symbolOf(statement.name); - if (functionSymbol === undefined) { - return; - } + if (!ts.isFunctionDeclaration(statement) || statement.name === undefined) return; + const symbol = this.m.converter.symbolOf(statement.name); + if (symbol === undefined) return; - for (let i = 0; i < index; i++) { - const earlierStatement = statements[i]; - const uses = this.functionUses(earlierStatement, functionSymbol); - if (uses.eager) { - hoisted.add(statement); - } - if (uses.deferred) { - // Capturing a function before its binding is assigned needs a - // live binding cell, which the lifted environment cannot express. - this.unsupportedForwardFunctionCaptures.add(earlierStatement); - } - } + const existing = declarations.get(symbol) ?? []; + existing.push({ statement, index }); + declarations.set(symbol, existing); }); + + const hoisted = new Set(); + if (declarations.size > 0) { + statements.forEach((statement, index) => { + // Collect all forward references in one traversal instead of rescanning + // every earlier subtree separately for every later declaration. + for (const [symbol, uses] of this.functionUses(statement, declarations)) { + for (const declaration of declarations.get(symbol)!) { + if (declaration.index <= index) continue; + if (uses.eager) hoisted.add(declaration.statement); + if (uses.deferred) this.unsupportedForwardFunctionCaptures.add(statement); + } + } + }); + } for (const statement of statements) { if (hoisted.has(statement)) { this.lowerStatement(statement); @@ -126,11 +128,13 @@ export class StmtLowerer { } } - private functionUses(node: ts.Node, functionSymbol: ts.Symbol): { eager: boolean; deferred: boolean } { - let eager = false; - let deferred = false; + private functionUses( + node: ts.Node, + functionSymbols: ReadonlyMap, + ): Map { + const uses = new Map(); const visit = (current: ts.Node, inDeferredBody: boolean): void => { - if ((eager && deferred) || ts.isTypeNode(current)) { + if (ts.isTypeNode(current)) { return; } @@ -178,9 +182,12 @@ export class StmtLowerer { const isValueReference = !isPropertyName && !isBindingName; if (isValueReference) { const referenceSymbol = this.m.converter.symbolOf(current); - if (referenceSymbol === functionSymbol) { - if (inDeferredBody) deferred = true; - else eager = true; + const isScopeFunction = referenceSymbol !== undefined && functionSymbols.has(referenceSymbol); + if (isScopeFunction) { + const referenceUses = uses.get(referenceSymbol) ?? { eager: false, deferred: false }; + if (inDeferredBody) referenceUses.deferred = true; + else referenceUses.eager = true; + uses.set(referenceSymbol, referenceUses); return; } } @@ -190,7 +197,7 @@ export class StmtLowerer { }; visit(node, false); - return { eager, deferred }; + return uses; } private immediatelyCalledFunction(expression: ts.Expression): ts.ArrowFunction | ts.FunctionExpression | undefined { diff --git a/jacodb-ets/ts-frontend/test/cli.spec.ts b/jacodb-ets/ts-frontend/test/cli.spec.ts index 93370c61e..0cbea06ae 100644 --- a/jacodb-ets/ts-frontend/test/cli.spec.ts +++ b/jacodb-ets/ts-frontend/test/cli.spec.ts @@ -56,6 +56,36 @@ describe("parseArgs", () => { }); describe("project mode", () => { + it("supports Node20 projects with relative TypeScript extension imports", () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "ets-frontend-node20-")); + tempDirs.push(projectDir); + fs.writeFileSync(path.join(projectDir, "package.json"), JSON.stringify({ type: "module" })); + fs.writeFileSync(path.join(projectDir, "tsconfig.json"), JSON.stringify({ + compilerOptions: { + module: "node20", moduleResolution: "nodenext", rewriteRelativeImportExtensions: true, + noEmit: true, allowImportingTsExtensions: true, + }, + files: ["index.ts", "dependency.ts"], + })); + fs.writeFileSync(path.join(projectDir, "dependency.ts"), "export class Dependency { value = 7; }"); + fs.writeFileSync(path.join(projectDir, "index.ts"), + 'import { Dependency } from "./dependency.ts"; export class Client extends Dependency {}'); + + const inputs = resolveProjectInputs(projectDir); + const outputDir = path.join(projectDir, "ir"); + const result = main(["--project", projectDir, outputDir]); + + expect(inputs.options.module).toBe(ts.ModuleKind.Node20); + expect(inputs.options.rewriteRelativeImportExtensions).toBe(true); + expect(result).toBe(0); + const file = JSON.parse(fs.readFileSync(path.join(outputDir, "index.ts.json"), "utf8")); + expect(file.importInfos).toContainEqual(expect.objectContaining({ importFrom: "./dependency.ts" })); + expect(file.classes).toContainEqual(expect.objectContaining({ + signature: expect.objectContaining({ name: "Client" }), + superClassName: "Dependency", + })); + }); + it("keeps an imported class alias while rejecting a mutable namespace property", () => { const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "ets-frontend-class-alias-")); tempDirs.push(projectDir); diff --git a/jacodb-ets/ts-frontend/test/closures.spec.ts b/jacodb-ets/ts-frontend/test/closures.spec.ts index 264f56efd..e0f38c3f7 100644 --- a/jacodb-ets/ts-frontend/test/closures.spec.ts +++ b/jacodb-ets/ts-frontend/test/closures.spec.ts @@ -1,10 +1,11 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { ClassCategory } from "../src/dto/constants"; import { AssignStmtDto, StmtDto } from "../src/dto/stmts"; +import { buildEtsFile } from "../src/lowering/fileBuilder"; import { serializeEtsFile } from "../src/serialize"; import { validateEtsFile } from "../src/validate"; import { consumers } from "./array-test-consumers"; -import { defaultMethod, lower, methodByName, singleBlockStmts } from "./util"; +import { compile, defaultMethod, lower, methodByName, singleBlockStmts } from "./util"; function allStmts(source: string): StmtDto[] { const { file } = lower(source); @@ -15,6 +16,23 @@ function allStmts(source: string): StmtDto[] { } describe("closures", () => { + it("does not repeatedly resolve sibling function subtrees for forward references", () => { + const declarations = Array.from({ length: 200 }, (_, index) => + `function sibling${index}() { return input; }`).join("\n"); + const { program, sourceFile, checker } = compile(`function outer(input: number) { ${declarations} }`); + const resolutions = vi.spyOn(checker, "getSymbolAtLocation"); + + try { + const file = buildEtsFile(program, sourceFile, { projectName: "proj", fileName: "test.ts" }); + + expect(validateEtsFile(file)).toEqual([]); + // One identifier per sibling must not cause a quadratic number of checker lookups. + expect(resolutions.mock.calls.length).toBeLessThan(5_000); + } finally { + resolutions.mockRestore(); + } + }); + it("lifts arrow functions into %AM methods with FunctionType use-site locals", () => { const { file } = lower(` let arr = [1, 2, 3];