also looking at this
stacks/bun-plugin-auto-imports
fix: resolve generated export collisions consistently
#1186
3 files
+107
-5
| @@ -93,6 +93,15 @@ declarations, and as the fourth argument to `generateGlobalsScript()`. This keep | ||
| 93 | 93 | generated package imports stable when the consumer directory or install layout |
| 94 | 94 | changes. The package must export every returned specifier. |
| 95 | 95 | |
| 96 | ### Export name collisions | |
| 97 | ||
| 98 | Directory scans use the caller's directory order and sorted file paths. The first | |
| 99 | registration of an exported name wins, matching auto-import injection. Runtime | |
| 100 | indexes, their returned export metadata, globals scripts and global declarations | |
| 101 | use that same selection. Repeated overload signatures and re-exports do not emit | |
| 102 | duplicate names. Import a module explicitly when helpers in different modules | |
| 103 | share a name but have different meanings. | |
| 104 | ||
| 96 | 105 | ## Testing |
| 97 | 106 | |
| 98 | 107 | ```bash |
| @@ -26,6 +26,16 @@ export interface ExportInfo { | ||
| 26 | 26 | isDefault: boolean |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | /** Keep the same first-registration precedence as the auto-import context. */ | |
| 30 | function firstRegisteredExports<T extends ExportInfo>(entries: T[]): T[] { | |
| 31 | const seen = new Set<string>() | |
| 32 | return entries.filter((entry) => { | |
| 33 | if (seen.has(entry.name)) return false | |
| 34 | seen.add(entry.name) | |
| 35 | return true | |
| 36 | }) | |
| 37 | } | |
| 38 | ||
| 29 | 39 | // ─── Core Auto-Import Engine ──────────────────────────────────────────────── |
| 30 | 40 | |
| 31 | 41 | interface ImportEntry { |
| @@ -519,9 +529,11 @@ export async function generateRuntimeIndex( | ||
| 519 | 529 | allExports.push(...exports) |
| 520 | 530 | } |
| 521 | 531 | |
| 532 | const selectedExports = firstRegisteredExports(allExports) | |
| 533 | ||
| 522 | 534 | // Group exports by file |
| 523 | 535 | const exportsByFile = new Map<string, ExportInfo[]>() |
| 524 | for (const exp of allExports) { | |
| 536 | for (const exp of selectedExports) { | |
| 525 | 537 | const existing = exportsByFile.get(exp.file) || [] |
| 526 | 538 | existing.push(exp) |
| 527 | 539 | exportsByFile.set(exp.file, existing) |
| @@ -529,6 +541,17 @@ export async function generateRuntimeIndex( | ||
| 529 | 541 | |
| 530 | 542 | // Generate import/export statements |
| 531 | 543 | const lines: string[] = [GENERATED_COMMENT] |
| 544 | const owners = new Map(selectedExports.map(exp => [exp.name, exp])) | |
| 545 | const noted = new Set<string>() | |
| 546 | for (const exp of allExports) { | |
| 547 | const owner = owners.get(exp.name)! | |
| 548 | const key = `${exp.name}\0${exp.file}` | |
| 549 | if (exp.file === owner.file || noted.has(key)) continue | |
| 550 | noted.add(key) | |
| 551 | const skipped = path.relative(path.dirname(outputPath), exp.file).replace(/\\/g, '/') | |
| 552 | const selected = path.relative(path.dirname(outputPath), owner.file).replace(/\\/g, '/') | |
| 553 | lines.push(`// Skipped ${JSON.stringify(exp.name)} from ${JSON.stringify(skipped)}; first registered in ${JSON.stringify(selected)}.`) | |
| 554 | } | |
| 532 | 555 | |
| 533 | 556 | for (const [file, exports] of exportsByFile) { |
| 534 | 557 | const relativePath = path.relative(path.dirname(outputPath), file).replace(/\\/g, '/') |
| @@ -567,7 +590,7 @@ export async function generateRuntimeIndex( | ||
| 567 | 590 | const content = `${lines.join('\n')}\n` |
| 568 | 591 | await Bun.write(outputPath, content) |
| 569 | 592 | |
| 570 | return { exports: allExports, content } | |
| 593 | return { exports: selectedExports, content } | |
| 571 | 594 | } |
| 572 | 595 | |
| 573 | 596 | /** |
| @@ -589,7 +612,7 @@ export async function generateGlobalsScript( | ||
| 589 | 612 | } |
| 590 | 613 | |
| 591 | 614 | // Only include value exports (not types) for global injection |
| 592 | const valueExports = allExports.filter(e => !e.isType) | |
| 615 | const valueExports = firstRegisteredExports(allExports).filter(e => !e.isType) | |
| 593 | 616 | |
| 594 | 617 | const relativePath = path.relative(path.dirname(outputPath), indexPath).replace(/\\/g, '/') |
| 595 | 618 | const importPath = relativePath.startsWith('.') ? relativePath : `./${relativePath}` |
| @@ -642,7 +665,7 @@ export async function generateGlobalTypes( | ||
| 642 | 665 | |
| 643 | 666 | // Group by import source |
| 644 | 667 | const bySource = new Map<string, typeof allExports>() |
| 645 | for (const exp of allExports) { | |
| 668 | for (const exp of firstRegisteredExports(allExports)) { | |
| 646 | 669 | const existing = bySource.get(exp.importFrom) || [] |
| 647 | 670 | existing.push(exp) |
| 648 | 671 | bySource.set(exp.importFrom, existing) |
| @@ -671,7 +694,7 @@ export async function generateGlobalTypes( | ||
| 671 | 694 | // Add value exports as const declarations |
| 672 | 695 | for (const [importFrom, exports] of bySource) { |
| 673 | 696 | const alias = `_${importFrom.replace(/[^a-zA-Z0-9]/g, '_')}` |
| 674 | const valueExports = exports.filter(e => !e.isType && !e.isDefault) | |
| 697 | const valueExports = exports.filter(e => !e.isType) | |
| 675 | 698 | |
| 676 | 699 | for (const exp of valueExports) { |
| 677 | 700 | lines.push(` const ${exp.name}: typeof ${alias}.${exp.name}`) |
| @@ -0,0 +1,70 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' | |
| 3 | import { tmpdir } from 'node:os' | |
| 4 | import { join } from 'node:path' | |
| 5 | import { generateGlobalTypes, generateGlobalsScript, generateRuntimeIndex } from '../src' | |
| 6 | ||
| 7 | test('generated runtime imports and declarations honor first registration across overloads and source collisions', async () => { | |
| 8 | const root = await mkdtemp(join(tmpdir(), 'auto-import-collisions-')) | |
| 9 | const app = join(root, 'app'), defaults = join(root, 'defaults') | |
| 10 | let complete = false | |
| 11 | const globalNames = ['collisionProbe', 'collisionDefault', 'defaultOnlyProbe'] | |
| 12 | const previous = new Map(globalNames.map(name => [name, Object.getOwnPropertyDescriptor(globalThis, name)])) | |
| 13 | try { | |
| 14 | await mkdir(app) | |
| 15 | await mkdir(defaults) | |
| 16 | // Create in reverse order; filesystem order must not choose the winner. | |
| 17 | await writeFile(join(app, 'z-reexports.ts'), "export { collisionProbe } from './a-owned'\nexport const collisionDefault = 'later'\nexport interface CollisionShape { later: true }\n") | |
| 18 | await writeFile(join(app, 'a-owned.ts'), "export function collisionProbe(value: string): string\nexport function collisionProbe(value: number): number\nexport function collisionProbe(value: string | number) { return value }\nexport default function collisionDefault() { return 'owned-default' }\nexport interface CollisionShape { owned: true }\n") | |
| 19 | await writeFile(join(defaults, 'default.ts'), "export function collisionProbe() { return 'framework' }\nexport function defaultOnlyProbe() { return 'default-only' }\nexport interface CollisionShape { framework: true }\n") | |
| 20 | const index = join(root, 'index.ts') | |
| 21 | const generated = await generateRuntimeIndex([app, app, defaults], index) | |
| 22 | // Build the generated file, rather than testing a hand-written re-export. | |
| 23 | const built = await Bun.build({ entrypoints: [index], outdir: join(root, 'built'), target: 'bun' }) | |
| 24 | expect(built.logs.map(String), generated.content).toEqual([]) | |
| 25 | expect(built.success, generated.content).toBe(true) | |
| 26 | const loaded = await import(join(root, 'built/index.js')) | |
| 27 | expect(loaded.collisionProbe('actual-call')).toBe('actual-call') | |
| 28 | expect(loaded.collisionProbe(42)).toBe(42) | |
| 29 | expect(loaded.collisionDefault()).toBe('owned-default') | |
| 30 | expect(loaded.defaultOnlyProbe()).toBe('default-only') | |
| 31 | expect('CollisionShape' in loaded).toBe(false) | |
| 32 | expect(generated.exports.map(value => value.name).sort()).toEqual(['CollisionShape', 'collisionDefault', 'collisionProbe', 'defaultOnlyProbe'].sort()) | |
| 33 | const globals = await generateGlobalsScript([app, app, defaults], join(root, 'globals.ts'), index) | |
| 34 | expect(globals.match(/const collisionProbe:/g)).toHaveLength(1) | |
| 35 | expect(globals.match(/const collisionDefault:/g)).toHaveLength(1) | |
| 36 | expect(globals).not.toContain('const CollisionShape:') | |
| 37 | await import(join(root, 'globals.ts')) | |
| 38 | expect(Reflect.get(globalThis, 'collisionProbe')('global-call')).toBe('global-call') | |
| 39 | expect(Reflect.get(globalThis, 'collisionDefault')()).toBe('owned-default') | |
| 40 | expect(Reflect.get(globalThis, 'defaultOnlyProbe')()).toBe('default-only') | |
| 41 | expect(generated.content).toContain('app/z-reexports.ts') | |
| 42 | expect(generated.content).toContain('app/a-owned.ts') | |
| 43 | await writeFile(join(root, 'framework.ts'), "export * from './defaults/default'\n") | |
| 44 | const declarations = await generateGlobalTypes([ | |
| 45 | { path: app, importFrom: './index' }, | |
| 46 | { path: app, importFrom: './index' }, | |
| 47 | { path: defaults, importFrom: './framework' }, | |
| 48 | ], join(root, 'auto-imports.d.ts')) | |
| 49 | expect(declarations.match(/const collisionProbe:/g)).toHaveLength(1) | |
| 50 | expect(declarations.match(/type CollisionShape =/g)).toHaveLength(1) | |
| 51 | expect(declarations).toContain('const collisionProbe: typeof ___index.collisionProbe') | |
| 52 | expect(declarations).not.toContain('___framework.collisionProbe') | |
| 53 | expect(declarations).toContain('const collisionDefault: typeof ___index.collisionDefault') | |
| 54 | await writeFile(join(root, 'consumer.ts'), "const shape: CollisionShape = { owned: true }\nexport const flag: true = shape.owned\nexport const result: string = collisionDefault()\nexport const numeric: number = collisionProbe(42)\nexport const fallback: string = defaultOnlyProbe()\n") | |
| 55 | const checked = Bun.spawnSync([process.execPath, join(import.meta.dir, '../node_modules/typescript/bin/tsc'), '--noEmit', '--strict', '--module', 'esnext', '--moduleResolution', 'bundler', '--target', 'esnext', '--skipLibCheck', join(root, 'consumer.ts'), join(root, 'auto-imports.d.ts')], { cwd: root, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', timeout: 20000 }) | |
| 56 | await writeFile(join(root, 'typecheck.stdout'), checked.stdout) | |
| 57 | await writeFile(join(root, 'typecheck.stderr'), checked.stderr) | |
| 58 | expect(checked.exitCode, checked.stdout.toString() + checked.stderr.toString()).toBe(0) | |
| 59 | complete = true | |
| 60 | } | |
| 61 | finally { | |
| 62 | for (const name of globalNames) { | |
| 63 | const descriptor = previous.get(name) | |
| 64 | if (descriptor) Object.defineProperty(globalThis, name, descriptor) | |
| 65 | else Reflect.deleteProperty(globalThis, name) | |
| 66 | } | |
| 67 | if (complete) await rm(root, { recursive: true }) | |
| 68 | else console.error(`AUTO_IMPORT_COLLISION_FIXTURE_RETAINED ${root}`) | |
| 69 | } | |
| 70 | }, 30000) | |