ReviewOS

also looking at this

stacks/bun-plugin-auto-imports

fix: resolve generated export collisions consistently

#1186
Merged chris wants to merge codex/runtime-export-collisions into main
3 files +107 -5
README.mdmodified+9-0
Changes to README.md
@@ -93,6 +93,15 @@ declarations, and as the fourth argument to `generateGlobalsScript()`. This keep
9393generated package imports stable when the consumer directory or install layout
9494changes. The package must export every returned specifier.
9595
96### Export name collisions
97
98Directory scans use the caller's directory order and sorted file paths. The first
99registration of an exported name wins, matching auto-import injection. Runtime
100indexes, their returned export metadata, globals scripts and global declarations
101use that same selection. Repeated overload signatures and re-exports do not emit
102duplicate names. Import a module explicitly when helpers in different modules
103share a name but have different meanings.
104
96105## Testing
97106
98107```bash
src/plugin.tsmodified+28-5
Changes to src/plugin.ts
@@ -26,6 +26,16 @@ export interface ExportInfo {
2626 isDefault: boolean
2727}
2828
29/** Keep the same first-registration precedence as the auto-import context. */
30function 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
2939// ─── Core Auto-Import Engine ────────────────────────────────────────────────
3040
3141interface ImportEntry {
@@ -519,9 +529,11 @@ export async function generateRuntimeIndex(
519529 allExports.push(...exports)
520530 }
521531
532 const selectedExports = firstRegisteredExports(allExports)
533
522534 // Group exports by file
523535 const exportsByFile = new Map<string, ExportInfo[]>()
524 for (const exp of allExports) {
536 for (const exp of selectedExports) {
525537 const existing = exportsByFile.get(exp.file) || []
526538 existing.push(exp)
527539 exportsByFile.set(exp.file, existing)
@@ -529,6 +541,17 @@ export async function generateRuntimeIndex(
529541
530542 // Generate import/export statements
531543 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 }
532555
533556 for (const [file, exports] of exportsByFile) {
534557 const relativePath = path.relative(path.dirname(outputPath), file).replace(/\\/g, '/')
@@ -567,7 +590,7 @@ export async function generateRuntimeIndex(
567590 const content = `${lines.join('\n')}\n`
568591 await Bun.write(outputPath, content)
569592
570 return { exports: allExports, content }
593 return { exports: selectedExports, content }
571594}
572595
573596/**
@@ -589,7 +612,7 @@ export async function generateGlobalsScript(
589612 }
590613
591614 // 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)
593616
594617 const relativePath = path.relative(path.dirname(outputPath), indexPath).replace(/\\/g, '/')
595618 const importPath = relativePath.startsWith('.') ? relativePath : `./${relativePath}`
@@ -642,7 +665,7 @@ export async function generateGlobalTypes(
642665
643666 // Group by import source
644667 const bySource = new Map<string, typeof allExports>()
645 for (const exp of allExports) {
668 for (const exp of firstRegisteredExports(allExports)) {
646669 const existing = bySource.get(exp.importFrom) || []
647670 existing.push(exp)
648671 bySource.set(exp.importFrom, existing)
@@ -671,7 +694,7 @@ export async function generateGlobalTypes(
671694 // Add value exports as const declarations
672695 for (const [importFrom, exports] of bySource) {
673696 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)
675698
676699 for (const exp of valueExports) {
677700 lines.push(` const ${exp.name}: typeof ${alias}.${exp.name}`)
test/runtime-collisions.test.tsadded+70-0
Changes to test/runtime-collisions.test.ts
@@ -0,0 +1,70 @@
1import { expect, test } from 'bun:test'
2import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join } from 'node:path'
5import { generateGlobalTypes, generateGlobalsScript, generateRuntimeIndex } from '../src'
6
7test('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)