Просмотр исходного кода

Merge branch 'config-catalog' into catalog-flatten

Tianyi Cui 2 месяцев назад
Родитель
Сommit
4e2da27201

+ 20 - 0
packages/core/agent-core/tests/gen-config-catalog.spec.ts

@@ -196,6 +196,26 @@ export function apply(ctx: Context, config: Config): void {}
 `,
     }))).toThrow(/config type 'Config' is imported from '@fix\/dep'/)
   })
+
+  it('hard-errors when one name resolves to two different declarations across the closure', () => {
+    expect(() => collectConfigCatalog(make({
+      'src/index.ts': `import type { Context } from 'cordis'
+import type { A } from './a.ts'
+import type { B } from './b.ts'
+/** Fixture config. */
+export interface Config {
+  /** A. */
+  a?: A
+  /** B. */
+  b?: B
+}
+/** Load. */
+export function apply(ctx: Context, config: Config): void {}
+`,
+      'src/a.ts': '/** First Option. */\nexport interface Option {\n  /** X. */\n  x?: string\n}\n/** A. */\nexport interface A {\n  /** O. */\n  o?: Option\n}\n',
+      'src/b.ts': '/** Second Option. */\nexport interface Option {\n  /** Y. */\n  y?: string\n}\n/** B. */\nexport interface B {\n  /** O. */\n  o?: Option\n}\n',
+    }))).toThrow(/type name 'Option' resolves to two different declarations/)
+  })
 })
 
 describe('gen-config-catalog schema cross-check', () => {

+ 37 - 8
scripts/gen-config-catalog.ts

@@ -41,7 +41,10 @@
  * - Every type NAME a pasted declaration references resolves: pasted
  *   transitively when package-local, linked when it is another plugin's
  *   config type / a core-data-structures entry / a workspace or external
- *   import. An unresolvable name is an error, never silently unexplained.
+ *   import. An unresolvable name is an error, and so is a NAME COLLISION —
+ *   two distinct declarations, or a declaration and an import, sharing one
+ *   name across the closure (a verbatim fence has a single flat namespace) —
+ *   never a silent skip.
  * - The runtime schemastery schema (`Config` export or `static Config`),
  *   when present, is walked statically — `z.object` keys, nested object/array
  *   compositions as key PATHS (`agents[].id`), and `z.intersect` composition
@@ -700,28 +703,54 @@ export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] {
     entry.configTypeName = typeName
     const pastes: Paste[] = []
     const refs = new Map<string, TypeRef>()
-    const pastedNames = new Set<string>()
+    // A bare name is the fence's whole namespace: two DIFFERENT declarations
+    // (or a declaration in one file and an import in another) sharing a name
+    // cannot both render unambiguously, so every resolution is identity-checked
+    // by source pointer and a collision is a violation, never a silent skip.
+    const pastedDeclByName = new Map<string, string>()
     const queue: { name: string; from: FileCtx }[] = [{ name: typeName, from: ctx }]
     for (let item = queue.shift(); item !== undefined; item = queue.shift()) {
       const { name, from } = item
-      if (pastedNames.has(name)) continue
       const resolved = resolveTypeName(from, name, cache, violations)
       if (resolved === null) {
         violations.push(`${pkg}: config declaration references '${name}' (via ${from.rel}), which is neither declared in the package, imported, nor a known global type.`)
         continue
       }
       if ('ref' in resolved) {
-        if (name === typeName) violations.push(`${pkg}: config type '${name}' is imported from '${resolved.ref.specifier}'; a plugin's config type must live in its own package.`)
-        else refs.set(name, resolved.ref)
+        if (name === typeName) {
+          violations.push(`${pkg}: config type '${name}' is imported from '${resolved.ref.specifier}'; a plugin's config type must live in its own package.`)
+          continue
+        }
+        if (pastedDeclByName.has(name)) {
+          violations.push(`${pkg}: '${name}' resolves to a package-local declaration (${pastedDeclByName.get(name) ?? ''}) in one file and an import from '${resolved.ref.specifier}' in another; rename one so the fence is unambiguous.`)
+          continue
+        }
+        const existing = refs.get(name)
+        if (existing && (existing.specifier !== resolved.ref.specifier || existing.imported !== resolved.ref.imported)) {
+          violations.push(`${pkg}: '${name}' is imported from both '${existing.specifier}' (${existing.imported}) and '${resolved.ref.specifier}' (${resolved.ref.imported}) across the pasted closure; disambiguate the aliases.`)
+          continue
+        }
+        refs.set(name, resolved.ref)
+        continue
+      }
+      const declKey = pointer(resolved.ctx.rel, resolved.ctx.sf, resolved.decl)
+      const prior = pastedDeclByName.get(name)
+      if (prior === declKey) continue // same declaration reached again — benign
+      if (prior !== undefined) {
+        violations.push(`${pkg}: type name '${name}' resolves to two different declarations (${prior} and ${declKey}) across the pasted closure; rename one — a verbatim fence cannot carry two same-named declarations.`)
+        continue
+      }
+      if (refs.has(name)) {
+        violations.push(`${pkg}: '${name}' resolves to an import from '${refs.get(name)?.specifier ?? ''}' in one file and a package-local declaration (${declKey}) in another; rename one so the fence is unambiguous.`)
         continue
       }
-      pastedNames.add(name)
-      pastes.push({ text: pasteText(resolved.ctx, resolved.decl), source: pointer(resolved.ctx.rel, resolved.ctx.sf, resolved.decl) })
+      pastedDeclByName.set(name, declKey)
+      pastes.push({ text: pasteText(resolved.ctx, resolved.decl), source: declKey })
       checkMemberDocs(resolved.ctx, resolved.decl, violations)
       const names = new Set<string>()
       collectTypeNames(resolved.decl, names)
       for (const n of names) {
-        if (GLOBAL_TYPES.has(n) || pastedNames.has(n)) continue
+        if (GLOBAL_TYPES.has(n)) continue
         queue.push({ name: n, from: resolved.ctx })
       }
     }

+ 1 - 0
scripts/translation-pairing.manifest.json

@@ -9,6 +9,7 @@
   "excluded": [
     "docs/AGENTS.md",
     "docs/module-graph.md",
+    "docs/config-catalog.md",
     "docs/cordis-catalog/",
     "docs/tool-catalog/",
     "docs/persistence-catalog/",