From 0caf899fda10748d3470622d44e174a15d6a1f24 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:36:26 -0700 Subject: [PATCH] feat(cli): auto-detect import file extension from tsconfig The TypeScript generator's `importWithFileExtension` option had to be set manually for projects using native ESM module resolution (`node16`/`nodenext`), which requires explicit `.js` extensions on relative imports. Without it, generated code failed to type-check in those projects. When the option is not explicitly declared, the TypeScript plugin now inspects the nearest `tsconfig.json` to the output directory and emits a `.js` extension for `node16`/`nodenext` resolution, while keeping the idiomatic extensionless form for `bundler`/`node` resolution. An explicit option still takes precedence. Also include `test/**/*.ts` in the CLI package's tsconfig (matching the zod/testtools convention) so test files type-check, fixing a handful of pre-existing strict-mode errors surfaced by that change. Fixes #2686 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/plugins/typescript.ts | 11 +- packages/cli/test/db/pull.test.ts | 2 +- packages/cli/test/import-extension.test.ts | 134 +++++++++++++++++++++ packages/cli/test/proxy.test.ts | 3 +- packages/cli/test/ts-schema-gen.test.ts | 6 +- packages/cli/tsconfig.json | 2 +- packages/sdk/src/index.ts | 1 + packages/sdk/src/tsconfig-utils.ts | 54 +++++++++ 8 files changed, 203 insertions(+), 10 deletions(-) create mode 100644 packages/cli/test/import-extension.test.ts create mode 100644 packages/sdk/src/tsconfig-utils.ts diff --git a/packages/cli/src/plugins/typescript.ts b/packages/cli/src/plugins/typescript.ts index 80576ac35..82fb48937 100644 --- a/packages/cli/src/plugins/typescript.ts +++ b/packages/cli/src/plugins/typescript.ts @@ -1,5 +1,5 @@ import type { CliPlugin } from '@zenstackhq/sdk'; -import { TsSchemaGenerator } from '@zenstackhq/sdk'; +import { detectImportFileExtension, TsSchemaGenerator } from '@zenstackhq/sdk'; import fs from 'node:fs'; import path from 'node:path'; @@ -22,9 +22,12 @@ const plugin: CliPlugin = { // liteOnly mode const liteOnly = pluginOptions['liteOnly'] === true; - // add .js extension when importing - const importWithFileExtension = pluginOptions['importWithFileExtension']; - if (importWithFileExtension && typeof importWithFileExtension !== 'string') { + // file extension to append to relative imports; when not explicitly set, + // auto-detect from the project's tsconfig (".js" for node16/nodenext ESM) + let importWithFileExtension = pluginOptions['importWithFileExtension']; + if (importWithFileExtension === undefined) { + importWithFileExtension = detectImportFileExtension(outDir); + } else if (typeof importWithFileExtension !== 'string') { throw new Error('The "importWithFileExtension" option must be a string if specified.'); } diff --git a/packages/cli/test/db/pull.test.ts b/packages/cli/test/db/pull.test.ts index 58649e3da..4166e7a01 100644 --- a/packages/cli/test/db/pull.test.ts +++ b/packages/cli/test/db/pull.test.ts @@ -1406,7 +1406,7 @@ describe('DB pull - SQL specific features', () => { return; } - const { workDir, schema } = await createProject( + const { workDir } = await createProject( `model User { id Int @id @default(autoincrement()) email String @unique diff --git a/packages/cli/test/import-extension.test.ts b/packages/cli/test/import-extension.test.ts new file mode 100644 index 000000000..033de742c --- /dev/null +++ b/packages/cli/test/import-extension.test.ts @@ -0,0 +1,134 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createProject, runCli } from './utils'; + +const model = ` +model User { + id String @id @default(cuid()) + name String + posts Post[] +} + +model Post { + id String @id @default(cuid()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId String +} +`; + +/** + * Overwrites the project's tsconfig.json with the given compiler options + * (merged over a sensible default that includes the generated files). + */ +function writeTsConfig(workDir: string, compilerOptions: Record) { + fs.writeFileSync( + path.join(workDir, 'tsconfig.json'), + JSON.stringify( + { + compilerOptions: { + target: 'ESNext', + esModuleInterop: true, + skipLibCheck: true, + strict: true, + noEmit: true, + types: ['node'], + ...compilerOptions, + }, + include: ['**/*.ts'], + }, + null, + 4, + ), + ); +} + +function readGenerated(workDir: string, file: string) { + return fs.readFileSync(path.join(workDir, 'zenstack', file), 'utf8'); +} + +function typeCheck(workDir: string) { + // throws (non-zero exit) if type checking fails + execSync('npx tsc --noEmit', { cwd: workDir, stdio: 'pipe' }); +} + +describe('Import file extension generation', () => { + it('omits the extension and compiles under bundler resolution', async () => { + const { workDir } = await createProject(model); + // createTestProject already writes a bundler tsconfig, but set it explicitly here + writeTsConfig(workDir, { module: 'ESNext', moduleResolution: 'Bundler' }); + + runCli('generate', workDir); + + // relative imports between generated files carry no extension + for (const file of ['models.ts', 'input.ts']) { + const content = readGenerated(workDir, file); + expect(content).toContain(`from "./schema"`); + expect(content).not.toContain(`./schema.js`); + } + + // and the project type-checks + expect(() => typeCheck(workDir)).not.toThrow(); + }); + + it('auto-adds ".js" and compiles under nodenext resolution', async () => { + const { workDir } = await createProject(model); + writeTsConfig(workDir, { module: 'NodeNext', moduleResolution: 'NodeNext' }); + + runCli('generate', workDir); + + // node16/nodenext requires explicit extensions; they must be present + for (const file of ['models.ts', 'input.ts']) { + const content = readGenerated(workDir, file); + expect(content).toContain(`from "./schema.js"`); + } + + // the real proof: nodenext would fail to resolve "./schema" without the extension + expect(() => typeCheck(workDir)).not.toThrow(); + }); + + it('fails to compile under nodenext if the extension is suppressed', async () => { + // negative control proving the auto-detected extension is load-bearing + const modelWithoutExtension = ` +plugin typescript { + provider = "@core/typescript" + importWithFileExtension = "" +} + +${model}`; + const { workDir } = await createProject(modelWithoutExtension); + writeTsConfig(workDir, { module: 'NodeNext', moduleResolution: 'NodeNext' }); + + runCli('generate', workDir); + + const content = readGenerated(workDir, 'input.ts'); + expect(content).toContain(`from "./schema"`); + expect(content).not.toContain(`./schema.js`); + + // missing extension is a hard error under nodenext + expect(() => typeCheck(workDir)).toThrow(); + }); + + it('honors an explicit importWithFileExtension over auto-detection', async () => { + const modelWithExtension = ` +plugin typescript { + provider = "@core/typescript" + importWithFileExtension = "js" +} + +${model}`; + const { workDir } = await createProject(modelWithExtension); + // bundler resolution would auto-detect "no extension"; the explicit option wins + writeTsConfig(workDir, { module: 'ESNext', moduleResolution: 'Bundler' }); + + runCli('generate', workDir); + + const content = readGenerated(workDir, 'input.ts'); + expect(content).toContain(`from "./schema.js"`); + + // bundler resolution also accepts the explicit extension + expect(() => typeCheck(workDir)).not.toThrow(); + }); +}); diff --git a/packages/cli/test/proxy.test.ts b/packages/cli/test/proxy.test.ts index 840412f23..f6d2bb583 100644 --- a/packages/cli/test/proxy.test.ts +++ b/packages/cli/test/proxy.test.ts @@ -69,7 +69,8 @@ describe('CLI proxy tests', () => { // create the client with skipValidationForComputedFields. const client = await createTestClient(zmodel, { skipValidationForComputedFields: true, - omit: { User: { postCount: true } }, + // a string schema can't be statically typed, so the omit config is untyped here + omit: { User: { postCount: true } } as any, }); const app = createProxyApp(client, client.$schema); diff --git a/packages/cli/test/ts-schema-gen.test.ts b/packages/cli/test/ts-schema-gen.test.ts index 29aa7b522..0bf7af48c 100644 --- a/packages/cli/test/ts-schema-gen.test.ts +++ b/packages/cli/test/ts-schema-gen.test.ts @@ -462,9 +462,9 @@ model User { true, ); - expect(schemaLite!.models.User.attributes).toBeUndefined(); - expect(schemaLite!.models.User.fields.id.attributes).toBeUndefined(); - expect(schemaLite!.models.User.fields.email.attributes).toBeUndefined(); + expect(schemaLite!.models['User']!.attributes).toBeUndefined(); + expect(schemaLite!.models['User']!.fields['id']!.attributes).toBeUndefined(); + expect(schemaLite!.models['User']!.fields['email']!.attributes).toBeUndefined(); }); it('supports ignorable fields for @updatedAt', async () => { diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 8ef64682a..e7ce31be8 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "@zenstackhq/typescript-config/base.json", - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index b213ff89f..c962b626b 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -2,4 +2,5 @@ import * as ModelUtils from './model-utils'; export * from './cli-plugin'; export { PrismaSchemaGenerator } from './prisma/prisma-schema-generator'; export * from './ts-schema-generator'; +export * from './tsconfig-utils'; export { ModelUtils }; diff --git a/packages/sdk/src/tsconfig-utils.ts b/packages/sdk/src/tsconfig-utils.ts new file mode 100644 index 000000000..c294114c3 --- /dev/null +++ b/packages/sdk/src/tsconfig-utils.ts @@ -0,0 +1,54 @@ +import path from 'node:path'; +import * as ts from 'typescript'; + +/** + * Detects the file extension that should be appended to relative imports in + * generated TypeScript, by inspecting the nearest `tsconfig.json` to the given + * directory. + * + * Returns `'.js'` when the project uses native ESM module resolution + * (`node16`/`nodenext`), which requires explicit extensions on relative imports. + * Returns `undefined` for `bundler`/`node` resolution (where extensionless + * imports are the idiomatic, maximally-compatible form) or when no tsconfig is + * found. + */ +export function detectImportFileExtension(fromDir: string): string | undefined { + const configPath = ts.findConfigFile(fromDir, ts.sys.fileExists, 'tsconfig.json'); + if (!configPath) { + return undefined; + } + + const configFile = ts.readConfigFile(configPath, ts.sys.readFile); + if (configFile.error) { + return undefined; + } + + // resolve `extends` chains and module/moduleResolution defaults + const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(configPath)); + + // Prefer an explicit `moduleResolution`; when it's omitted, TypeScript derives + // it from `module`, so fall back to inspecting the module kind ourselves. + let moduleResolution = parsed.options.moduleResolution; + if (moduleResolution === undefined) { + switch (parsed.options.module) { + case ts.ModuleKind.Node16: + case ts.ModuleKind.Node18: + case ts.ModuleKind.Node20: + case ts.ModuleKind.NodeNext: + moduleResolution = ts.ModuleResolutionKind.NodeNext; + break; + default: + break; + } + } + + // node16/nodenext resolution requires explicit extensions on relative imports + if ( + moduleResolution === ts.ModuleResolutionKind.Node16 || + moduleResolution === ts.ModuleResolutionKind.NodeNext + ) { + return '.js'; + } + + return undefined; +}