Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ module.exports = {
'@typescript-eslint/require-await': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-floating-promises': 'off',
// Consistent with no-unsafe-assignment/argument/member-access/return being off globally:
// test helpers and assertion libraries often have loose or any-typed signatures.
'@typescript-eslint/no-unsafe-call': 'off',
// Crashes also fail the test
'no-unsafe-optional-chaining': 'off',
},
Expand Down
31 changes: 28 additions & 3 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { generateRoutes } from './module/generate-routes';
import { generateSpec } from './module/generate-spec';
import { fsExists, fsReadFile } from './utils/fs';
import { AbstractRouteGenerator } from './routeGeneration/routeGenerator';
import { extname, isAbsolute } from 'node:path';
import { dirname, extname, isAbsolute } from 'node:path';
import * as ts from 'typescript';
import type { CompilerOptions } from 'typescript';

const workingDir: string = process.cwd();
Expand Down Expand Up @@ -88,8 +89,32 @@ const resolveConfig = async (config?: string | Config): Promise<Config> => {
return typeof config === 'object' ? config : getConfig(config);
};

const validateCompilerOptions = (config?: Record<string, unknown>): CompilerOptions => {
return (config || {}) as CompilerOptions;
// Exported for testing. The `cwd` parameter overrides the working directory used when
// searching for tsconfig.json; defaults to the process working directory.
export const validateCompilerOptions = (config?: Record<string, unknown>, cwd = workingDir): CompilerOptions => {
// Discover and parse the project's tsconfig.json using TypeScript's own API so that
// numeric enum values (module, target, moduleResolution, etc.) and exports conditions
// are resolved correctly. Without this, createProgram defaults to Node10/CommonJS
// resolution and ignores exports maps, causing z.infer<> and similar constructs to
// resolve to {} when types come from compiled .d.ts files instead of .ts sources.
const tsconfigPath = ts.findConfigFile(cwd, p => ts.sys.fileExists(p));
let baseOptions: CompilerOptions = {};
let basePath = cwd;
if (tsconfigPath) {
const readResult = ts.readConfigFile(tsconfigPath, p => ts.sys.readFile(p));
if (!readResult.error) {
const parsed = ts.parseJsonConfigFileContent(readResult.config, ts.sys, dirname(tsconfigPath));
baseOptions = parsed.options;
basePath = dirname(tsconfigPath);
}
}
if (!config || Object.keys(config).length === 0) {
return baseOptions;
}
// Convert string enum values (e.g. "ESNext") to their numeric equivalents via
// TypeScript's own API, then merge on top of the tsconfig-derived base options.
const { options: overrideOptions } = ts.convertCompilerOptionsFromJson(config, basePath);
return { ...baseOptions, ...overrideOptions };
};

export interface ExtendedSpecConfig extends SpecConfig {
Expand Down
15 changes: 15 additions & 0 deletions tests/fixtures/tsconfig-bundler/controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Controller, Get, Route } from '@tsoa/runtime';
import type { Widget, ZodWidget } from 'external-pkg';

@Route('CrossPackage')
export class CrossPackageController extends Controller {
@Get('widget')
public getWidget(): Widget {
return { id: 1, name: 'test', active: true };
}

@Get('zod-widget')
public getZodWidget(): ZodWidget {
return { id: 1, label: 'test', enabled: true };
}
}
18 changes: 18 additions & 0 deletions tests/fixtures/tsconfig-bundler/pkg/external/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { z } from 'zod';

export interface Widget {
id: number;
name: string;
active: boolean;
}

const ZodWidgetSchema = z.object({
id: z.number(),
label: z.string(),
enabled: z.boolean(),
});

// z.infer<> expands correctly only when TypeScript reads the .ts source.
// Compiled .d.ts files lose the schema's generic structure, causing the
// inferred type to collapse to {} — the core bug this fix addresses.
export type ZodWidget = z.infer<typeof ZodWidgetSchema>;
16 changes: 16 additions & 0 deletions tests/fixtures/tsconfig-bundler/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"module": "preserve",
"moduleResolution": "bundler",
"customConditions": ["source"],
"target": "esnext",
"strict": true,
"skipLibCheck": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"baseUrl": ".",
"paths": {
"external-pkg": ["./pkg/external/types.ts"]
}
}
}
94 changes: 94 additions & 0 deletions tests/unit/swagger/validateCompilerOptions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { expect } from 'chai';
import 'mocha';
import { join, normalize } from 'path';
import * as ts from 'typescript';
import { validateCompilerOptions } from '@tsoa/cli/cli';
import { MetadataGenerator } from '@tsoa/cli/metadataGeneration/metadataGenerator';
import { Tsoa } from '@tsoa/runtime';

// Unwrap nested refAlias chains until we reach a type with properties.
function resolveProperties(type: Tsoa.Type): Tsoa.Property[] {
if (type.dataType === 'refObject') {
return type.properties;
}
if (type.dataType === 'nestedObjectLiteral') {
return type.properties;
}
if (type.dataType === 'refAlias') {
return resolveProperties(type.type);
}
return [];
}

// Fixture: tests/fixtures/tsconfig-bundler/tsconfig.json sets
// moduleResolution: "bundler" and customConditions: ["source"]
const fixtureDir = normalize(join(__dirname, '../../fixtures/tsconfig-bundler'));

describe('validateCompilerOptions', () => {
describe('tsconfig.json discovery', () => {
it('should read moduleResolution from the project tsconfig.json', () => {
const options = validateCompilerOptions(undefined, fixtureDir);
expect(options.moduleResolution).to.equal(ts.ModuleResolutionKind.Bundler);
});

it('should read customConditions from the project tsconfig.json', () => {
const options = validateCompilerOptions(undefined, fixtureDir);
expect(options.customConditions).to.deep.equal(['source']);
});

it('should return empty options when no tsconfig.json exists in the given directory', () => {
// Pass a directory that has no tsconfig.json anywhere in its ancestry
// (we use the filesystem root, which never has one)
const options = validateCompilerOptions(undefined, '/');
expect(options.moduleResolution).to.be.undefined;
expect(options.customConditions).to.be.undefined;
});
});

describe('compilerOptions overrides', () => {
it('should apply compilerOptions on top of tsconfig.json settings', () => {
const options = validateCompilerOptions({ customConditions: ['custom'] }, fixtureDir);
// Override takes precedence over tsconfig value
expect(options.customConditions).to.deep.equal(['custom']);
// moduleResolution from tsconfig is still present
expect(options.moduleResolution).to.equal(ts.ModuleResolutionKind.Bundler);
});

it('should convert string enum values in compilerOptions (e.g. moduleResolution)', () => {
const options = validateCompilerOptions({ moduleResolution: 'node16' }, fixtureDir);
expect(options.moduleResolution).to.equal(ts.ModuleResolutionKind.Node16);
});
});

describe('cross-package type resolution', () => {
// The fixture tsconfig maps 'external-pkg' via paths to a local .ts source file.
// Without reading the tsconfig, TypeScript cannot find external-pkg and treats
// Widget as `any`, so it never appears in the reference type map.
const controllerPath = normalize(join(__dirname, '../../fixtures/tsconfig-bundler/controller.ts'));

it('should expand Widget from a cross-package import when tsconfig is read', () => {
const options = validateCompilerOptions(undefined, fixtureDir);
const metadata = new MetadataGenerator(controllerPath, options).Generate();
const widgetType = metadata.referenceTypeMap['Widget'];
expect(widgetType).to.exist;
const propNames = resolveProperties(widgetType).map(p => p.name);
expect(propNames).to.include.members(['id', 'name', 'active']);
});

it('should expand z.infer<> from a cross-package import when tsconfig is read', () => {
// This is the core failure mode: without reading tsconfig.json, TypeScript
// defaults to Node10/CommonJS resolution and cannot follow the paths mapping
// to the .ts source. As a result, ZodWidget resolves to `any` and is absent
// from the reference type map — the same way z.infer<> collapses to {} when
// read from compiled .d.ts files that lack the full Zod generic structure.
const options = validateCompilerOptions(undefined, fixtureDir);
const metadata = new MetadataGenerator(controllerPath, options).Generate();
const zodWidgetType = metadata.referenceTypeMap['ZodWidget'];
expect(zodWidgetType).to.exist;
// ZodWidget is a refAlias wrapping z.infer<>, which itself is a refAlias
// wrapping the resolved nestedObjectLiteral — traverse the chain.
const propNames = resolveProperties(zodWidgetType).map(p => p.name);
expect(propNames).to.include.members(['id', 'label', 'enabled']);
});
});
});