diff --git a/src/generate.ts b/src/generate.ts index 53c8f5d..e1cf9ad 100644 --- a/src/generate.ts +++ b/src/generate.ts @@ -81,8 +81,11 @@ export function generateWithMaps( const errors: string[] = []; const edits = new Map(); const needsRuntime = new Set(); - // Delegated contracts the generated code imports itself: file -> exported name -> import. - const needsContract = new Map>(); + // Delegated contracts the generated code imports itself: file -> qualifier -> import. + const needsContract = new Map< + string, + Map + >(); // Aliased so an injected import cannot shadow or collide with handwritten names. const runtime = '__mappergenGetMapper'; @@ -138,6 +141,7 @@ export function generateWithMaps( function referenceContract( from: TypeScript.SourceFile, contract: TypeScript.ClassDeclaration, + qualifier: string, exported: string, ): string { // Module resolution substitutes the source extension, so the output one is emitted. @@ -148,11 +152,13 @@ export function generateWithMaps( .replace(/\.mts$/, '.mjs') .replace(/\.cts$/, '.cjs'); - const alias = `__mappergen${exported}`; - // Keyed by name, so several fields delegating to one contract share the import. + // Keyed by the qualifier: unique within the file, where a class name is not — + // two files may export contracts of the same name, imported under local aliases. + const alias = `__mappergenContract${qualifier}`; const imports = needsContract.get(from.fileName) ?? new Map(); - imports.set(exported, { + imports.set(qualifier, { alias, + exported, specifier: JSON.stringify(path.startsWith('.') ? path : `./${path}`), }); needsContract.set(from.fileName, imports); @@ -515,7 +521,7 @@ export function generateWithMaps( // An erased import leaves nothing to call, so generate one instead. // The contract is imported under the name it exports, not a local alias. if (isTypeOnly(alias)) - reference = referenceContract(file, declaration, symbol.name); + reference = referenceContract(file, declaration, qualifier, symbol.name); } else { const candidates = contractsByName.get(qualifier) ?? []; if (candidates.length > 1) @@ -532,7 +538,8 @@ export function generateWithMaps( `Delegate '${rule.delegate}': '${qualifier}' must be an @mapper contract in scope or in the mapping project.`, ); owner = found; - reference = referenceContract(file, found, qualifier); + // Looked up by name, so the qualifier is the exported name. + reference = referenceContract(file, found, qualifier, qualifier); } } @@ -752,7 +759,7 @@ export function generateWithMaps( text: `import { getMapper as ${runtime} } from 'mappergen';\n`, }); - for (const [exported, { alias, specifier }] of needsContract.get(file) ?? []) + for (const { alias, exported, specifier } of needsContract.get(file)?.values() ?? []) changes.push({ start: 0, end: 0, diff --git a/test/generate.test.ts b/test/generate.test.ts index 71f4e91..8ae608d 100644 --- a/test/generate.test.ts +++ b/test/generate.test.ts @@ -1805,6 +1805,49 @@ export type Contract = UserMapping;`, ); }); +test('two contracts sharing a class name are delegated to separately', async (t) => { + // Arrange: identical shapes, so only the instance proves which contract ran. + const f = contracts( + t, + { + 'a/User.ts': `export class AlphaUser { id!: string } +/** @mapper */ +export abstract class UserMapping { + abstract toUser(source: { id: string }): AlphaUser; +}`, + 'b/User.ts': `export class BetaUser { id!: string } +/** @mapper */ +export abstract class UserMapping { + abstract toUser(source: { id: string }): BetaUser; +}`, + 'Sensor.ts': `import { getMapper } from 'mappergen'; +import type { UserMapping as Alpha, AlphaUser } from './a/User.js'; +import type { UserMapping as Beta, BetaUser } from './b/User.js'; +/** @mapper */ +export abstract class SensorMapping { + /** + * @delegate owner Alpha.toUser + * @delegate reviewer Beta.toUser + */ + abstract toDto(source: { owner: { id: string }; reviewer: { id: string } }): { owner: AlphaUser; reviewer: BetaUser }; +} +export const sensors = getMapper(SensorMapping);`, + }, + {}, + ['**/*.ts'], + ); + + // Act + const module = await executeFile(f, 'Sensor.js'); + const alpha = await import(pathToFileURL(join(f.dir, 'dist/a/User.js')).href); + const beta = await import(pathToFileURL(join(f.dir, 'dist/b/User.js')).href); + const result = module.sensors.toDto({ owner: { id: 'u1' }, reviewer: { id: 'u2' } }); + + // Assert + assert.ok(result.owner instanceof alpha.AlphaUser); + assert.ok(result.reviewer instanceof beta.BetaUser); +}); + test('a renamed type-only import is delegated under the name the contract exports', async (t) => { // Arrange const f = contracts(