Skip to content

Commit 2c06118

Browse files
gabsongclaude
andcommitted
fix: distinguish duplicate model declarations from real model conflicts
CheckModelUnicity treated any two same-named declarations with different file positions as conflicting models. Valid TypeScript programs routinely contain duplicate declarations that describe the same model: - one file reached through two paths (symlinks, package manager links) - byte-identical copies of a file (pnpm injected workspace packages) - a built declaration file (.d.ts + .d.ts.map) next to its source file - verbatim copies of a declaration (common with generated API clients) These are now recognized as the same logical declaration instead of throwing 'Found 2 different model definitions'. Additionally, a declaration marked with @tsoaModel is honored again as the canonical model for its name across files: same-named declarations in other files resolve to the designated declaration, restoring the documented deduplication behavior that regressed in #1498. Genuinely different same-named declarations without a designation still throw, now with a hint about @tsoaModel. Closes #1650 Closes #1853 Closes #1860 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f0f9aa7 commit 2c06118

37 files changed

Lines changed: 1125 additions & 6 deletions

packages/cli/src/metadataGeneration/metadataGenerator.ts

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,42 @@
11
import { Config, Tsoa } from '@tsoa/runtime';
22
import { minimatch } from 'minimatch';
3-
import { createProgram, forEachChild, isClassDeclaration, type ClassDeclaration, type CompilerOptions, type Program, type TypeChecker } from 'typescript';
3+
import {
4+
createProgram,
5+
forEachChild,
6+
isClassDeclaration,
7+
isEnumDeclaration,
8+
isInterfaceDeclaration,
9+
isModuleBlock,
10+
isModuleDeclaration,
11+
isTypeAliasDeclaration,
12+
type ClassDeclaration,
13+
type CompilerOptions,
14+
type EnumDeclaration,
15+
type InterfaceDeclaration,
16+
type Node,
17+
type Program,
18+
type TypeAliasDeclaration,
19+
type TypeChecker,
20+
} from 'typescript';
421
import { getDecorators } from '../utils/decoratorUtils';
22+
import { isExistJSDocTag } from '../utils/jsDocUtils';
523
import { importClassesFromDirectories } from '../utils/importClassesFromDirectories';
624
import { ControllerGenerator } from './controllerGenerator';
725
import { GenerateMetadataError } from './exceptions';
26+
import { ModelDefinitionIdentity, type ModelDefinitionPosition } from './modelDefinitionIdentity';
827
import { TypeResolver } from './typeResolver';
928

29+
export type DesignatedModelDeclaration = InterfaceDeclaration | ClassDeclaration | TypeAliasDeclaration | EnumDeclaration;
30+
1031
export class MetadataGenerator {
1132
public readonly controllerNodes = new Array<ClassDeclaration>();
1233
public readonly typeChecker: TypeChecker;
1334
private readonly program: Program;
35+
private readonly modelDefinitionIdentity = new ModelDefinitionIdentity();
1436
private referenceTypeMap: Tsoa.ReferenceTypeMap = {};
15-
private modelDefinitionPosMap: { [name: string]: Array<{ fileName: string; pos: number }> } = {};
37+
private modelDefinitionPosMap: { [name: string]: ModelDefinitionPosition[] } = {};
1638
private expressionOrigNameMap: Record<string, string> = {};
39+
private designatedModelIndex?: Map<string, DesignatedModelDeclaration[]>;
1740

1841
constructor(
1942
entryFile: string,
@@ -223,15 +246,66 @@ export class MetadataGenerator {
223246
return this.referenceTypeMap[refName];
224247
}
225248

226-
public CheckModelUnicity(refName: string, positions: Array<{ fileName: string; pos: number }>) {
249+
public CheckModelUnicity(refName: string, positions: ModelDefinitionPosition[]) {
227250
if (!this.modelDefinitionPosMap[refName]) {
228251
this.modelDefinitionPosMap[refName] = positions;
229252
} else {
230253
const origPositions = this.modelDefinitionPosMap[refName];
231-
if (!(origPositions.length === positions.length && positions.every(pos => origPositions.find(origPos => pos.pos === origPos.pos && pos.fileName === origPos.fileName)))) {
232-
throw new Error(`Found 2 different model definitions for model ${refName}: orig: ${JSON.stringify(origPositions)}, act: ${JSON.stringify(positions)}`);
254+
if (!(origPositions.length === positions.length && positions.every(pos => origPositions.find(origPos => this.modelDefinitionIdentity.areDefinitionsEquivalent(pos, origPos))))) {
255+
const printable = (definitionPositions: ModelDefinitionPosition[]) => JSON.stringify(definitionPositions.map(({ fileName, pos }) => ({ fileName, pos })));
256+
if (this.GetDesignatedModels(refName).length > 1) {
257+
throw new GenerateMetadataError(`Multiple models for ${refName} marked with '@tsoaModel'; '@tsoaModel' should only be applied to one model.`);
258+
}
259+
throw new Error(
260+
`Found 2 different model definitions for model ${refName}: orig: ${printable(origPositions)}, act: ${printable(positions)}. ` +
261+
`If both definitions describe the same model, mark the canonical declaration with a '@tsoaModel' JSDoc tag; otherwise rename one of the types to resolve the collision.`,
262+
);
263+
}
264+
}
265+
}
266+
267+
/**
268+
* Returns the declarations marked with '@tsoaModel' for the given type name anywhere in the
269+
* program. Such a declaration is the canonical model for that name: same-named declarations in
270+
* other files resolve to it instead of raising a model definition conflict. Designations that
271+
* are the same logical declaration reached through different files (e.g. a marked source file
272+
* whose built declaration file, which keeps the JSDoc, is also in the program) are deduplicated.
273+
*/
274+
public GetDesignatedModels(typeName: string): DesignatedModelDeclaration[] {
275+
if (!this.designatedModelIndex) {
276+
this.designatedModelIndex = this.buildDesignatedModelIndex();
277+
}
278+
const designated = this.designatedModelIndex.get(typeName);
279+
if (!designated) {
280+
return [];
281+
}
282+
return designated.filter(
283+
(declaration, index) => !designated.slice(0, index).some(other => this.modelDefinitionIdentity.areDefinitionsEquivalent(toDefinitionPosition(declaration), toDefinitionPosition(other))),
284+
);
285+
}
286+
287+
private buildDesignatedModelIndex(): Map<string, DesignatedModelDeclaration[]> {
288+
const index = new Map<string, DesignatedModelDeclaration[]>();
289+
const visit = (node: Node) => {
290+
if (
291+
(isInterfaceDeclaration(node) || isClassDeclaration(node) || isTypeAliasDeclaration(node) || isEnumDeclaration(node)) &&
292+
node.name &&
293+
isExistJSDocTag(node, tag => tag.tagName.text === 'tsoaModel')
294+
) {
295+
const declarations = index.get(node.name.text) || [];
296+
declarations.push(node);
297+
index.set(node.name.text, declarations);
298+
} else if (isModuleDeclaration(node) || isModuleBlock(node)) {
299+
forEachChild(node, visit);
233300
}
301+
};
302+
for (const sourceFile of this.program.getSourceFiles()) {
303+
if (!sourceFile.text.includes('@tsoaModel')) {
304+
continue;
305+
}
306+
forEachChild(sourceFile, visit);
234307
}
308+
return index;
235309
}
236310

237311
public CheckExpressionUnicity(formattedRefName: string, refName: string) {
@@ -254,3 +328,7 @@ export class MetadataGenerator {
254328
.map(generator => generator.Generate());
255329
}
256330
}
331+
332+
function toDefinitionPosition(declaration: DesignatedModelDeclaration): ModelDefinitionPosition {
333+
return { fileName: declaration.getSourceFile().fileName, pos: declaration.pos, declaration };
334+
}

0 commit comments

Comments
 (0)