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

Merge pull request #3673 from deepseek-harness/worktree/typert-forward-reexport

修复 Typert 对包内类型转发的误判
CreatixChu 1 месяц назад
Родитель
Сommit
03f439df9e

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.md
+2026-09-07-typert-package-local-forwarding-imports.md: f50dc7bfc8c9d83c2b6f2b584e1d1119b8df817b
+2026-09-07-typert-package-local-forwarding-imports.zh.md: 7e012d220df3e7356b35a105784f89e4df148802

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.md

@@ -0,0 +1,29 @@
+# Agent Note: Follow package-local forwarding modules in Typert references
+
+Status: implemented
+
+English | [中文](2026-09-07-typert-package-local-forwarding-imports.zh.md)
+
+## Problem
+
+`WorkspaceAnalyzer` resolves every type reference to its original declaration before classifying it, then reads only the referencing file's own `import` statement to decide whether the reference crossed a package through a public export. A package that re-exports another package's type from one of its own modules, and imports that module by relative path elsewhere, therefore fails with `crosses a package without an explicit package import` although the package import exists one hop away. The failure is deterministic for every batch size and package order; it surfaces in whichever analysis selects the referencing package as a root, which is why [issue 3525](https://github.com/deepseek-harness/deepseek-harness/issues/3525) observed it as batch-dependent.
+
+## Decision
+
+[`targetForReference`](../../../../packages/typert/generator/src/analyzer.ts) resolves a relative specifier through the face's shared compiler host and module-resolution cache and follows it only while the resolved file stays inside the referencing package. In each forwarding module it collects the `export` edges that carry the requested name: a named re-export with a specifier, an `export { local }` backed by that module's `import`, and star re-exports whose module exports the same symbol. Explicit edges are tried before star edges, matching TypeScript's shadowing of star exports, and each resolved module and requested export-name pair is entered once, so circular star re-exports terminate while distinct renamed routes through one module remain available. The walk stops at the first package specifier and feeds that identity and export name to the existing `packageExportName` check, so a forwarded type must still be public at the package subpath the forwarding module names, and a package name without a registration is refused there. The reference model is unchanged: the target remains `declaration` for a same-face owner and `cross-face` for another face.
+
+The walk yields no package import, and the reference fails as before, when a relative specifier resolves outside the referencing package, when the only edge carrying the name is a namespace re-export or a re-exported namespace import, or when every edge loops back to a module and requested-name pair already entered.
+
+## Alternatives considered
+
+**Treat a relative import whose alias chain ends in another package as implicitly public.** Rejected: it would accept `../../other/src/file.ts` and any forwarding module that itself reaches the other package by relative path, removing the public-export check the generated Remote declarations rely on to name an importable subpath.
+
+**Record the forwarding module as the reference target.** Rejected: emitters and cross-face links need the original declaration's package and public subpath; a package-local module has no public identity of its own.
+
+**Select edges in source order without symbol checks.** Rejected: a star re-export that loops back to an earlier module can precede the explicit re-export that actually carries the type, and TypeScript itself lets explicit exports shadow star exports; ordering explicit edges first and continuing past an entered module and requested-name pair keeps such modules accepted without an unbounded walk.
+
+**Make batched and whole-workspace analysis select the same roots.** Rejected as a fix: root selection does not change the verdict on a reference, only whether the reference is visited, so aligning the callers would hide the incorrect classification rather than remove it.
+
+## Consequences
+
+Packages may keep one forwarding module for foreign types and import it relatively, matching how their own modules are organized. Each cross-package relative reference costs one module resolution per hop through the face's shared resolution cache; `reachableFiles` now resolves through the same cache. [`type-model.spec.ts`](../../../../packages/typert/generator/tests/type-model.spec.ts) pins named, renamed multi-hop, import-then-export, star, and namespace-import forwarding, an explicit re-export beside a looping star edge, distinct renamed routes through one shared module, a forwarded private export, a forwarding module that crosses by relative path, a cycle whose only exit crosses by relative path, a namespace re-export, a re-exported namespace import, cross-face forwarding, and equality of whole and batched analysis for the forwarding fixture across batch sizes and package orders.

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: Typert 引用追踪包内转发模块
+
+Status: implemented
+
+[English](2026-09-07-typert-package-local-forwarding-imports.md) | 中文
+
+## Problem
+
+`WorkspaceAnalyzer` 先把每个类型引用解析到原始声明再分类,然后只读引用所在文件自己的 `import` 语句来判断该引用是否经由公开导出跨包。一个包若在自己的某个模块里重新导出另一个包的类型,并在别处用相对路径导入该模块,就会报 `crosses a package without an explicit package import`,尽管包导入只隔一跳。这个失败在任何批次大小和包顺序下都会稳定出现;它出现在哪次分析里,取决于哪次分析把引用方的包选为根,因此 [issue 3525](https://github.com/deepseek-harness/deepseek-harness/issues/3525) 观察到的现象像是与批次相关。
+
+## Decision
+
+[`targetForReference`](../../../../packages/typert/generator/src/analyzer.ts) 通过该 face 共享的编译器宿主及其模块解析缓存来解析相对说明符,且只在解析到的文件仍位于引用方包内时继续追踪。在每个转发模块里,它收集承载所请求名字的 `export` 边:带说明符的具名重新导出、由该模块自身 `import` 支撑的 `export { local }`,以及导出同一符号的星号重新导出。显式边先于星号边尝试,与 TypeScript 中显式导出遮蔽星号导出的规则一致;解析后的模块与请求导出名组成的每个组合只进入一次,因此循环的星号重新导出能够终止,经同一模块转发的不同改名路径仍可继续尝试。追踪在遇到第一个包说明符时停止,并把该包身份和导出名交给现有的 `packageExportName` 检查,因此被转发的类型仍必须在转发模块所写的包子路径上公开,没有登记的包名也在此被拒绝。引用模型不变:同 face 的所有者仍是 `declaration`,另一 face 仍是 `cross-face`。
+
+当相对说明符解析到引用方包之外、承载该名字的唯一边是命名空间重新导出或被重新导出的命名空间导入,或所有边都回到已进入的模块与请求名组合时,追踪得不到包导入,引用照旧失败。
+
+## Alternatives considered
+
+**把别名链终点在另一个包的相对导入视为隐式公开。** 已拒绝:这会接受 `../../other/src/file.ts`,也会接受自身用相对路径抵达另一个包的转发模块,从而取消公开导出检查,而生成的 Remote 声明依赖该检查来命名可导入的子路径。
+
+**把转发模块记为引用目标。** 已拒绝:发射器和跨 face 链接需要原始声明的包和公开子路径,包内模块没有自己的公开身份。
+
+**按源码顺序选边且不校验符号。** 已拒绝:回到更早模块的星号重新导出可能排在真正承载该类型的显式重新导出之前,而 TypeScript 本身允许显式导出遮蔽星号导出;显式边优先并跳过已进入的模块与请求名组合,既能接受这类模块,又不会无限追踪。
+
+**让分批分析与全工作区分析选择相同的根。** 作为修复方案已拒绝:根的选择不改变对一个引用的判定,只决定该引用是否被访问,对齐调用方只会掩盖错误分类,不能消除它。
+
+## Consequences
+
+包可以为外部类型保留一个转发模块并用相对路径导入它,与自身模块的组织方式一致。每个跨包相对引用每跳付出一次经该 face 共享解析缓存的模块解析;`reachableFiles` 现在也通过同一缓存解析。[`type-model.spec.ts`](../../../../packages/typert/generator/tests/type-model.spec.ts) 固定了具名、改名多跳、先导入再导出、星号和命名空间导入这几种转发,与回环星号边并存的显式重新导出,经同一模块转发的不同改名路径,被转发的私有导出,用相对路径跨包的转发模块,唯一出口用相对路径跨包的循环,命名空间重新导出,被重新导出的命名空间导入,跨 face 转发,以及转发 fixture 在不同批次大小和包顺序下全量分析与分批分析相等。

+ 2 - 2
packages/typert/generator/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/typert/generator/README.md
-README.md: 1d77a67b483ced2190d1144e09474474e7748399
-README.zh.md: 82756f40d716ac8ccff83f4d83c0390111fbd674
+README.md: 48d79dfc44f6c2f658e8aa13836e5044d6cbf25d
+README.zh.md: e546661fe297d09367185585e43d486cd417090d

+ 1 - 1
packages/typert/generator/README.md

@@ -79,7 +79,7 @@ The generator is built on one separation: extraction and emission are decoupled
 
 ### Analysis and faces
 
-Host and client are independent TypeScript programs. Direct project references establish compiler-face membership, while `dsh.client` package subpaths establish runtime-face contribution; `package.json#exports` marks every cross-package public boundary, and imports or re-exports are the only cross-face edges. `check` mode fails on syntax or semantic diagnostics, missing public annotations, private cross-package references, and reachable declaration merges the model cannot retain losslessly; `write` mode inserts checker-derived annotations and returns a clean check-mode model. Types owned by NPM dependencies remain `external` references instead of being expanded.
+Host and client are independent TypeScript programs. Direct project references establish compiler-face membership, while `dsh.client` package subpaths establish runtime-face contribution; `package.json#exports` marks every cross-package public boundary, and imports or re-exports are the only cross-face edges. A relative import that resolves inside the referencing package is followed through that module's re-exports until a package specifier appears, so package-local forwarding modules keep their original declaration references; a relative import that resolves into another package fails. `check` mode fails on syntax or semantic diagnostics, missing public annotations, private cross-package references, and reachable declaration merges the model cannot retain losslessly; `write` mode inserts checker-derived annotations and returns a clean check-mode model. Types owned by NPM dependencies remain `external` references instead of being expanded.
 
 ### Emission and publication contract
 

+ 1 - 1
packages/typert/generator/README.zh.md

@@ -79,7 +79,7 @@ files:
 
 ### 分析与 face
 
-Host 与 Client 是两个独立的 TypeScript 程序。直接项目引用确定编译器 face 的成员归属,`dsh.client` 包子路径则确定运行时 face 的贡献;`package.json#exports` 划定所有跨包公开边界,跨 face 的边只能来自导入或重新导出。`check` 模式遇到语法或语义诊断、缺失的公开类型标注、跨包私有引用,以及模型无法无损保留的可达声明合并时都会失败;`write` 模式插入类型检查器推导出的标注,并返回无诊断的 check 模式模型。NPM 依赖拥有的类型继续以 `external` 引用表示,不会被展开。
+Host 与 Client 是两个独立的 TypeScript 程序。直接项目引用确定编译器 face 的成员归属,`dsh.client` 包子路径则确定运行时 face 的贡献;`package.json#exports` 划定所有跨包公开边界,跨 face 的边只能来自导入或重新导出。解析到本包内部模块的相对导入会沿该模块的重新导出继续追踪,直到出现包说明符,因此包内转发模块保留原始声明引用;解析到其他包的相对导入会失败。`check` 模式遇到语法或语义诊断、缺失的公开类型标注、跨包私有引用,以及模型无法无损保留的可达声明合并时都会失败;`write` 模式插入类型检查器推导出的标注,并返回无诊断的 check 模式模型。NPM 依赖拥有的类型继续以 `external` 引用表示,不会被展开。
 
 ### 生成与发布约定
 

+ 143 - 55
packages/typert/generator/src/analyzer.ts

@@ -130,6 +130,25 @@ interface ModuleIdentity {
   readonly subpath: string
 }
 
+/** The package import a type reference reaches after following package-local forwarding modules. */
+interface PackageImport {
+  readonly module: ModuleIdentity
+  /** Name the type is exported under at that package subpath. */
+  readonly name: string
+}
+
+/** One `import` binding of a local name; `name` is absent for a namespace import. */
+interface ImportBinding {
+  readonly specifier: string
+  readonly name?: string
+}
+
+/** One outgoing `export` edge of a forwarding module for one exported name. */
+interface ForwardedExport {
+  readonly specifier: string
+  readonly name: string
+}
+
 interface StaticLookupDeclaration {
   readonly key: string
   readonly hostSymbol: SymbolId
@@ -317,15 +336,13 @@ export class WorkspaceAnalyzer {
           incremental: false,
           noEmit: true,
         }
-        const program = ts.createProgram({
-          rootNames,
-          options,
-          host: this.caches.programHost(face, options),
-        })
+        const host = this.caches.programHost(face, options)
+        const program = ts.createProgram({ rootNames, options, host })
         faces.push(new FaceAnalyzer({
           root: this.options.root,
           face,
           program,
+          host,
           registrations,
           allRegistrations: this.registrations,
           mode: this.options.mode,
@@ -588,6 +605,8 @@ interface FaceAnalyzerOptions {
   readonly root: string
   readonly face: TypertFace
   readonly program: ts.Program
+  /** Program host whose module-resolution cache serves import resolution outside the checker. */
+  readonly host: ts.CompilerHost
   readonly registrations: readonly PackageRegistration[]
   readonly allRegistrations: readonly PackageRegistration[]
   readonly mode: AnalysisMode
@@ -599,6 +618,7 @@ class FaceAnalyzer {
   private readonly root: string
   private readonly face: TypertFace
   private readonly program: ts.Program
+  private readonly host: ts.CompilerHost
   private readonly checker: ts.TypeChecker
   private readonly registrations: readonly PackageRegistration[]
   private readonly allRegistrations: readonly PackageRegistration[]
@@ -618,6 +638,7 @@ class FaceAnalyzer {
     this.root = options.root
     this.face = options.face
     this.program = options.program
+    this.host = options.host
     this.checker = options.program.getTypeChecker()
     this.registrations = options.registrations
     this.allRegistrations = options.allRegistrations
@@ -834,21 +855,86 @@ class FaceAnalyzer {
         if ((!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement))
           || statement.moduleSpecifier === undefined
           || !ts.isStringLiteral(statement.moduleSpecifier)) continue
-        const resolved = ts.resolveModuleName(
-          statement.moduleSpecifier.text,
-          sourceFile.fileName,
-          this.program.getCompilerOptions(),
-          ts.sys,
-        ).resolvedModule
-        if (resolved === undefined) continue
-        const resolvedPath = realPath(resolved.resolvedFileName)
-        if (!isWithin(resolvedPath, registration.root)) continue
+        const resolvedPath = this.resolveImport(statement.moduleSpecifier.text, sourceFile.fileName)
+        if (resolvedPath === undefined || !isWithin(resolvedPath, registration.root)) continue
         queue.push(this.sourceFiles.get(resolvedPath) as ts.SourceFile)
       }
     }
     return [...reachable.values()].sort((left, right) => left.fileName.localeCompare(right.fileName))
   }
 
+  private resolveImport(specifier: string, fromFile: string): string | undefined {
+    const resolved = ts.resolveModuleName(
+      specifier,
+      fromFile,
+      this.program.getCompilerOptions(),
+      this.host,
+      this.host.getModuleResolutionCache?.(),
+    ).resolvedModule
+    return resolved === undefined ? undefined : realPath(resolved.resolvedFileName)
+  }
+
+  /**
+   * Follow the import that names `symbol` at `site` through modules of the
+   * referencing package until a package specifier appears. Each forwarding
+   * module and requested export name pair is entered once; its explicit export
+   * edges are tried before its star edges. A relative specifier that resolves
+   * outside `from`, a namespace hop, or a module with no edge leading to a
+   * package specifier yields undefined.
+   */
+  private packageImportOf(
+    site: ReferenceSite,
+    moduleSpecifier: string,
+    symbol: ts.Symbol,
+    from: PackageRegistration,
+  ): PackageImport | undefined {
+    const visited = new Map<string, Set<string>>()
+    const walk = (sourceFile: ts.SourceFile, specifier: string, name: string): PackageImport | undefined => {
+      const module = moduleIdentity(specifier)
+      if (module !== undefined) return { module, name }
+      const resolvedPath = this.resolveImport(specifier, sourceFile.fileName)
+      if (resolvedPath === undefined || !isWithin(resolvedPath, from.root)) return undefined
+      const names = visited.get(resolvedPath)
+      if (names?.has(name)) return undefined
+      if (names === undefined) visited.set(resolvedPath, new Set([name]))
+      else names.add(name)
+      const forward = this.sourceFiles.get(resolvedPath) as ts.SourceFile
+      for (const edge of this.forwardedExports(forward, name, symbol)) {
+        const found = walk(forward, edge.specifier, edge.name)
+        if (found !== undefined) return found
+      }
+      return undefined
+    }
+    return walk(site.getSourceFile(), moduleSpecifier, authoredExportName(site, moduleSpecifier))
+  }
+
+  /** Export edges of `sourceFile` that carry `name`: explicit edges in source order, then star edges exporting `symbol`. */
+  private forwardedExports(sourceFile: ts.SourceFile, name: string, symbol: ts.Symbol): ForwardedExport[] {
+    const explicit: ForwardedExport[] = []
+    const stars: ForwardedExport[] = []
+    for (const statement of sourceFile.statements) {
+      if (!ts.isExportDeclaration(statement)
+        || (statement.exportClause === undefined && statement.moduleSpecifier === undefined)
+        || (statement.exportClause !== undefined && ts.isNamespaceExport(statement.exportClause))) continue
+      if (statement.exportClause === undefined) {
+        const specifier = (statement.moduleSpecifier as ts.StringLiteral).text
+        const exported = this.moduleExports(statement.moduleSpecifier as ts.StringLiteral)
+          .find(candidate => candidate.name === name && this.resolveSymbol(candidate) === symbol)
+        if (exported !== undefined) stars.push({ specifier, name })
+        continue
+      }
+      const element = statement.exportClause.elements.find(candidate => candidate.name.text === name)
+      if (element === undefined) continue
+      if (statement.moduleSpecifier !== undefined) {
+        explicit.push({ specifier: (statement.moduleSpecifier as ts.StringLiteral).text, name: element.propertyName?.text ?? name })
+        continue
+      }
+      const binding = importBindingOf(sourceFile, element.propertyName?.text ?? name)
+      if (binding?.name !== undefined) explicit.push({ specifier: binding.specifier, name: binding.name })
+    }
+    return [...explicit, ...stars]
+  }
+
   private collectServices(
     context: ts.InterfaceDeclaration,
     records: readonly ExportRecord[],
@@ -2390,17 +2476,21 @@ class FaceAnalyzer {
     }
 
     const moduleSpecifier = moduleSpecifierOf(site)
-    const module = moduleSpecifier === undefined ? undefined : moduleIdentity(moduleSpecifier)
     const from = this.registrationForFile(site.getSourceFile().fileName) as PackageRegistration
     const owner = this.registrationForFile(declaration.getSourceFile().fileName)
     if (owner !== undefined) {
       if (owner.name !== from.name) {
-        if (module === undefined) {
+        const imported = moduleSpecifier === undefined
+          ? undefined
+          : this.packageImportOf(site, moduleSpecifier, symbol, from)
+        if (imported === undefined) {
           this.fail(site, `reference to ${symbol.name} crosses a package without an explicit package import`)
         }
-        const exportName = authoredExportName(site, moduleSpecifier as string)
-        if (this.packageExportName(module, symbol, owner.face, exportName) === undefined) {
-          this.fail(site, `package reference ${exportName} is not exported by ${module.package} at ${module.subpath}`)
+        if (this.packageExportName(imported.module, symbol, owner.face, imported.name) === undefined) {
+          this.fail(
+            site,
+            `package reference ${imported.name} is not exported by ${imported.module.package} at ${imported.module.subpath}`,
+          )
         }
       }
       const typeDeclaration = declaration as ts.ClassDeclaration | ts.InterfaceDeclaration
@@ -2409,15 +2499,18 @@ class FaceAnalyzer {
       return { kind: 'declaration', symbol: this.symbolId(symbol) }
     }
 
-    const packageFaces = module === undefined
+    const imported = moduleSpecifier === undefined
+      ? undefined
+      : this.packageImportOf(site, moduleSpecifier, symbol, from)
+    const packageFaces = imported === undefined
       ? []
-      : [...new Set(this.allRegistrations.filter(candidate => candidate.name === module.package).map(candidate => candidate.face))]
+      : [...new Set(this.allRegistrations.filter(candidate => candidate.name === imported.module.package).map(candidate => candidate.face))]
     const otherFace = packageFaces.find(face => face !== this.face)
-    if (otherFace !== undefined && module !== undefined) {
-      const requestedName = authoredExportName(site, moduleSpecifier as string)
-      const exportName = this.packageExportName(module, symbol, otherFace, requestedName)
+    if (otherFace !== undefined && imported !== undefined) {
+      const { module } = imported
+      const exportName = this.packageExportName(module, symbol, otherFace, imported.name)
       if (exportName === undefined) {
-        this.fail(site, `cross-face reference ${requestedName} is not exported by ${module.package} at ${module.subpath}`)
+        this.fail(site, `cross-face reference ${imported.name} is not exported by ${module.package} at ${module.subpath}`)
       }
       this.recordCrossFaceLink(from.name, otherFace, module, exportName)
       return {
@@ -2429,11 +2522,11 @@ class FaceAnalyzer {
       }
     }
 
-    if (module !== undefined) {
+    if (imported !== undefined) {
       return {
         kind: 'external',
-        module: module.package,
-        subpath: module.subpath,
+        module: imported.module.package,
+        subpath: imported.module.subpath,
         name: symbol.name,
       }
     }
@@ -2483,7 +2576,8 @@ class FaceAnalyzer {
     requestedName: string,
   ): string | undefined {
     const registration = this.allRegistrations.find(candidate =>
-      candidate.face === face && candidate.name === module.package) as PackageRegistration
+      candidate.face === face && candidate.name === module.package)
+    if (registration === undefined) return undefined
     const target = packageExportTargets(registration.manifest)
       .find(([subpath]) => subpath === module.subpath)?.[1]
     if (target === undefined) return undefined
@@ -3019,18 +3113,7 @@ function moduleSpecifierOf(node: ReferenceSite): string | undefined {
     : node.expression
   const sourceFile = node.getSourceFile()
   const first = ts.isIdentifier(symbol) ? symbol.text : symbol.getFirstToken(sourceFile)?.getText(sourceFile)
-  for (const statement of sourceFile.statements) {
-    if (!ts.isImportDeclaration(statement) || statement.importClause === undefined
-      || !ts.isStringLiteral(statement.moduleSpecifier)) continue
-    if (statement.importClause.name?.text === first) return statement.moduleSpecifier.text
-    const bindings = statement.importClause.namedBindings
-    if (bindings !== undefined && ts.isNamespaceImport(bindings) && bindings.name.text === first) {
-      return statement.moduleSpecifier.text
-    }
-    if (bindings !== undefined && ts.isNamedImports(bindings)
-      && bindings.elements.some(element => element.name.text === first)) return statement.moduleSpecifier.text
-  }
-  return undefined
+  return first === undefined ? undefined : importBindingOf(sourceFile, first)?.specifier
 }
 
 function authoredExportName(node: ReferenceSite, moduleSpecifier: string): string {
@@ -3040,23 +3123,28 @@ function authoredExportName(node: ReferenceSite, moduleSpecifier: string): strin
     ? node.typeName.getText().split('.')
     : node.expression.getText().split('.')
   const localName = referenced[0] as string
-  for (const statement of node.getSourceFile().statements) {
-    if (!ts.isImportDeclaration(statement)
-      || statement.importClause === undefined
-      || !ts.isStringLiteral(statement.moduleSpecifier)
-      || statement.moduleSpecifier.text !== moduleSpecifier) continue
-    if (statement.importClause.name?.text === localName) return 'default'
+  const binding = importBindingOf(node.getSourceFile(), localName)
+  /* v8 ignore next -- moduleSpecifierOf returns only the import inspected by importBindingOf. */
+  if (binding === undefined) throw new TypertAnalysisError(`typert: cannot recover export name for ${localName} from ${moduleSpecifier}`)
+  return binding.name ?? (referenced[1] as string)
+}
+
+function importBindingOf(sourceFile: ts.SourceFile, localName: string): ImportBinding | undefined {
+  for (const statement of sourceFile.statements) {
+    if (!ts.isImportDeclaration(statement) || statement.importClause === undefined
+      || !ts.isStringLiteral(statement.moduleSpecifier)) continue
+    const specifier = statement.moduleSpecifier.text
+    if (statement.importClause.name?.text === localName) return { specifier, name: 'default' }
     const bindings = statement.importClause.namedBindings
-    if (bindings !== undefined && ts.isNamedImports(bindings)) {
-      const imported = bindings.elements.find(element => element.name.text === localName)
-      if (imported !== undefined) return imported.propertyName?.text ?? imported.name.text
-    }
-    if (bindings !== undefined && ts.isNamespaceImport(bindings) && bindings.name.text === localName) {
-      return referenced[1] as string
+    if (bindings === undefined) continue
+    if (ts.isNamespaceImport(bindings)) {
+      if (bindings.name.text === localName) return { specifier }
+      continue
     }
+    const element = bindings.elements.find(candidate => candidate.name.text === localName)
+    if (element !== undefined) return { specifier, name: element.propertyName?.text ?? element.name.text }
   }
-  /* v8 ignore next -- moduleSpecifierOf returns only the matching import inspected by this loop. */
-  throw new TypertAnalysisError(`typert: cannot recover export name for ${localName} from ${moduleSpecifier}`)
+  return undefined
 }
 
 function importTypeAttributesText(node: ts.ImportTypeNode): string {

+ 228 - 1
packages/typert/generator/tests/type-model.spec.ts

@@ -622,6 +622,227 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => {
     )
   })
 
+  describe('package-local forwarding modules', () => {
+    const hostPayload = '@fixture/host:packages/host/src/models.ts#Payload'
+
+    function consumerPayloadTarget(root: string): TypeTargetModel | undefined {
+      const host = new WorkspaceAnalyzer({ root }).analyze().faces.find(face => face.face === 'host')
+      const schema = host?.packages.find(candidate => candidate.name === '@fixture/consumer')?.schemas[0]
+      const consumer = host?.graph.declarations.find(candidate => candidate.id === schema?.symbol)
+      const value = consumer?.kind === 'interface'
+        ? consumer.members.find(member => member.name === 'value')
+        : undefined
+      const node = value?.kind === 'property'
+        ? host?.graph.nodes.find(candidate => candidate.id === value.type)
+        : undefined
+      return node?.kind === 'reference' ? node.target : undefined
+    }
+
+    it('follows a named re-export to the original declaration', () => {
+      const root = copyFixture('typert-forward-named-')
+      addSameFacePackage(root, './forward.ts', 'Payload', {
+        'forward.ts': "export type { Payload } from '@fixture/host/models'\n",
+      })
+
+      expect(consumerPayloadTarget(root)).toEqual({ kind: 'declaration', symbol: hostPayload })
+    })
+
+    it('follows renamed hops through several forwarding modules', () => {
+      const root = copyFixture('typert-forward-chain-')
+      addSameFacePackage(root, './outer.ts', 'Forwarded', {
+        'outer.ts': "export type { Inner as Forwarded } from './inner.ts'\n",
+        'inner.ts': "export type { Payload as Inner } from '@fixture/host/models'\n",
+      })
+
+      expect(consumerPayloadTarget(root)).toEqual({ kind: 'declaration', symbol: hostPayload })
+    })
+
+    it('follows an import re-exported without a module specifier', () => {
+      const root = copyFixture('typert-forward-import-export-')
+      addSameFacePackage(root, './forward.ts', 'Payload', {
+        'forward.ts': "import type { Payload as Imported } from '@fixture/host/models'\nexport type { Imported as Payload }\n",
+      })
+
+      expect(consumerPayloadTarget(root)).toEqual({ kind: 'declaration', symbol: hostPayload })
+    })
+
+    it('follows a star re-export', () => {
+      const root = copyFixture('typert-forward-star-')
+      addSameFacePackage(root, './forward.ts', 'Payload', {
+        'forward.ts': "export * from '@fixture/host/models'\n",
+      })
+
+      expect(consumerPayloadTarget(root)).toEqual({ kind: 'declaration', symbol: hostPayload })
+    })
+
+    it('follows a namespace import of the forwarding module', () => {
+      const root = copyFixture('typert-forward-namespace-')
+      addSameFacePackage(root, '@fixture/host/models', 'Payload', {
+        'forward.ts': "export type { Payload } from '@fixture/host/models'\n",
+      })
+      const sourcePath = join(root, 'packages/consumer/src/index.ts')
+      writeFileSync(
+        sourcePath,
+        readFileSync(sourcePath, 'utf8')
+          .replace("import type { Payload } from '@fixture/host/models'", "import type * as Forward from './forward.ts'")
+          .replace('readonly value: Payload', 'readonly value: Forward.Payload'),
+      )
+
+      expect(consumerPayloadTarget(root)).toEqual({ kind: 'declaration', symbol: hostPayload })
+    })
+
+    it('rejects a forwarded type absent from the package export', () => {
+      const root = copyFixture('typert-forward-private-')
+      writeFileSync(
+        join(root, 'packages/host/src/private.ts'),
+        'export interface PrivateHost { readonly value: string }\n',
+      )
+      addSameFacePackage(root, './forward.ts', 'PrivateHost', {
+        'forward.ts': "export type { PrivateHost } from '@fixture/host/private'\n",
+      })
+
+      expect(() => new WorkspaceAnalyzer({ root }).analyze()).toThrow(
+        'package reference PrivateHost is not exported by @fixture/host at ./private',
+      )
+    })
+
+    it('rejects a forwarding module that reaches another package by relative path', () => {
+      const root = copyFixture('typert-forward-relative-')
+      addSameFacePackage(root, './forward.ts', 'Payload', {
+        'forward.ts': "export type { Payload } from '../../host/src/models.ts'\n",
+      })
+
+      expect(() => new WorkspaceAnalyzer({ root }).analyze()).toThrow(
+        'reference to Payload crosses a package without an explicit package import',
+      )
+    })
+
+    it('follows a forwarding module across faces', () => {
+      const root = copyFixture('typert-forward-cross-face-')
+      writeFileSync(
+        join(root, 'packages/client/src/forward.ts'),
+        "export type { Payload as ForwardedPayload } from '@fixture/host'\n",
+      )
+      const sourcePath = join(root, 'packages/client/src/index.ts')
+      writeFileSync(
+        sourcePath,
+        readFileSync(sourcePath, 'utf8')
+          .replace(
+            "import type { HostAgent, Payload } from '@fixture/host'",
+            "import type { HostAgent } from '@fixture/host'\nimport type { ForwardedPayload as Payload } from './forward.ts'",
+          ),
+      )
+      const model = new WorkspaceAnalyzer({ root }).analyze()
+      const client = model.faces.find(face => face.face === 'client')
+      const payload = client?.graph.nodes.find(candidate =>
+        candidate.kind === 'reference' && candidate.name === 'Payload' && candidate.id.includes('packages/client/'))
+
+      expect(payload?.kind === 'reference' ? payload.target : undefined).toEqual({
+        kind: 'cross-face',
+        face: 'host',
+        package: '@fixture/host',
+        subpath: '.',
+        name: 'Payload',
+      })
+      expect(model.crossFaceLinks).toContainEqual({
+        fromFace: 'client',
+        fromPackage: '@fixture/client',
+        toFace: 'host',
+        toPackage: '@fixture/host',
+        subpath: '.',
+        name: 'Payload',
+      })
+    })
+
+    it('prefers an explicit re-export over a star edge that loops back', () => {
+      const root = copyFixture('typert-forward-cycle-explicit-')
+      addSameFacePackage(root, './outer.ts', 'Payload', {
+        'outer.ts': "export * from './inner.ts'\n",
+        'inner.ts': "export * from './outer.ts'\nexport type { Payload } from '@fixture/host/models'\n",
+      })
+
+      expect(consumerPayloadTarget(root)).toEqual({ kind: 'declaration', symbol: hostPayload })
+    })
+
+    it('follows a valid renamed route after another route reaches the same module', () => {
+      const root = copyFixture('typert-forward-shared-module-')
+      addSameFacePackage(root, './outer.ts', 'Payload', {
+        'outer.ts': "export * from './left.ts'\nexport * from './right.ts'\n",
+        'left.ts': "export { Left as Payload } from './shared.ts'\n",
+        'right.ts': "export { Right as Payload } from './shared.ts'\n",
+        'shared.ts': [
+          "export { Payload as Left } from './relative.ts'",
+          "export type { Payload as Right } from '@fixture/host/models'",
+          '',
+        ].join('\n'),
+        'relative.ts': "export type { Payload } from '../../host/src/models.ts'\n",
+      })
+
+      expect(consumerPayloadTarget(root)).toEqual({ kind: 'declaration', symbol: hostPayload })
+    })
+
+    it('rejects a forwarding cycle whose only exit crosses a package by relative path', () => {
+      const root = copyFixture('typert-forward-cycle-relative-')
+      addSameFacePackage(root, './outer.ts', 'Payload', {
+        'outer.ts': "export * from './inner.ts'\n",
+        'inner.ts': "export * from './outer.ts'\nexport type { Payload } from '../../host/src/models.ts'\n",
+      })
+
+      expect(() => new WorkspaceAnalyzer({ root }).analyze()).toThrow(
+        'reference to Payload crosses a package without an explicit package import',
+      )
+    })
+
+    it('rejects a namespace re-export in a forwarding module', () => {
+      const root = copyFixture('typert-forward-namespace-export-')
+      addSameFacePackage(root, '@fixture/host/models', 'Payload', {
+        'forward.ts': "export * as models from '@fixture/host/models'\n",
+      })
+      const sourcePath = join(root, 'packages/consumer/src/index.ts')
+      writeFileSync(
+        sourcePath,
+        readFileSync(sourcePath, 'utf8')
+          .replace("import type { Payload } from '@fixture/host/models'", "import type * as Forward from './forward.ts'")
+          .replace('readonly value: Payload', 'readonly value: Forward.models.Payload'),
+      )
+
+      expect(() => new WorkspaceAnalyzer({ root }).analyze()).toThrow(
+        'reference to Payload crosses a package without an explicit package import',
+      )
+    })
+
+    it('rejects a forwarding module that exports a namespace import binding', () => {
+      const root = copyFixture('typert-forward-namespace-binding-')
+      addSameFacePackage(root, '@fixture/host/models', 'Payload', {
+        'forward.ts': "import type * as Models from '@fixture/host/models'\nexport type { Models }\n",
+      })
+      const sourcePath = join(root, 'packages/consumer/src/index.ts')
+      writeFileSync(
+        sourcePath,
+        readFileSync(sourcePath, 'utf8')
+          .replace("import type { Payload } from '@fixture/host/models'", "import type { Models } from './forward.ts'")
+          .replace('readonly value: Payload', 'readonly value: Models.Payload'),
+      )
+
+      expect(() => new WorkspaceAnalyzer({ root }).analyze()).toThrow(
+        'reference to Payload crosses a package without an explicit package import',
+      )
+    })
+
+    it('produces one model regardless of batch size or package order', () => {
+      const root = copyFixture('typert-forward-batches-')
+      addSameFacePackage(root, './forward.ts', 'Payload', {
+        'forward.ts': "export type { Payload } from '@fixture/host/models'\n",
+      })
+      const packages = ['@fixture/host', '@fixture/client', '@fixture/consumer']
+      const direct = new WorkspaceAnalyzer({ root, packages }).analyze()
+
+      expect(new WorkspaceAnalyzer({ root, packages }).analyzeInBatches(1)).toEqual(direct)
+      expect(new WorkspaceAnalyzer({ root, packages }).analyzeInBatches(2)).toEqual(direct)
+      expect(new WorkspaceAnalyzer({ root, packages: [...packages].reverse() }).analyzeInBatches(2)).toEqual(direct)
+    })
+  })
+
   it('rejects TypeScript projects with source diagnostics before modeling them', () => {
     const root = copyFixture('typert-invalid-project-')
     const sourcePath = join(root, 'packages/host/src/index.ts')
@@ -1280,9 +1501,15 @@ function configureDualRuntimeClient(root: string, splitProjects: boolean): void
   writeFileSync(clientAggregatePath, `${JSON.stringify(clientAggregate, null, 2)}\n`)
 }
 
-function addSameFacePackage(root: string, specifier: string, importedName: string): void {
+function addSameFacePackage(
+  root: string,
+  specifier: string,
+  importedName: string,
+  files: Readonly<Record<string, string>> = {},
+): void {
   const packageRoot = join(root, 'packages/consumer')
   mkdirSync(join(packageRoot, 'src'), { recursive: true })
+  for (const [file, source] of Object.entries(files)) writeFileSync(join(packageRoot, 'src', file), source)
   writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({
     name: '@fixture/consumer',
     private: true,