fix(typert): try explicit forwarding edges before star edges and reuse the resolution cache

Review follow-up. A forwarding module's explicit re-exports are tried
before its star re-exports and an entered module is skipped on later
edges, so a star edge that loops back no longer hides the explicit
re-export beside it. Import resolution outside the checker goes through
the face's shared compiler host and module-resolution cache, and an
import naming an unregistered package fails the public-export check
instead of throwing a TypeError. Tests pin the looping star, the
relative-only cycle exit, namespace re-exports, and re-exported
namespace imports.
This commit is contained in:
creatixchu committed 2026-09-07 18:00:39 +08:00
1 parent 3599ff5119
commit 5acf3cd09b
5 files changed
+117 -44

No files matched your search

@@ -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 .agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.md
2026-09-07-typert-package-local-forwarding-imports.md: bcae7e6c6e3ccca9c1b67d7d925501199dce4854
2026-09-07-typert-package-local-forwarding-imports.zh.md: 828343af3f7c0b45257248c7e678ef912bbaa10f
2026-09-07-typert-package-local-forwarding-imports.md: 35780d5fe9e6c1f7a2ff82d7157b98f163a11a34
2026-09-07-typert-package-local-forwarding-imports.zh.md: 52bb0e025d3374289eb15ad97339d2b92a5ab6ef
@@ -10,9 +10,9 @@ English | [中文](2026-09-07-typert-package-local-forwarding-imports.zh.md)
## Decision
[`targetForReference`](../../../../packages/typert/generator/src/analyzer.ts) resolves a relative specifier with the program's module resolution and follows it only while the resolved file stays inside the referencing package. In each forwarding module it locates the `export` edge that carries the requested name: a named re-export with a specifier, a star re-export whose module exports the same symbol, or an `export { local }` backed by that module's `import`. 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. The reference model is unchanged: the target remains `declaration` for a same-face owner and `cross-face` for another face.
[`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 a module already entered on the current walk is not entered again, so circular star re-exports terminate. 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 a forwarding module re-exports a namespace, or when a forwarding chain revisits a file.
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 already entered.
## Alternatives considered
@@ -20,8 +20,10 @@ The walk yields no package import, and the reference fails as before, when a rel
**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 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, cached by the shared compiler host. [`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, a forwarded private export, a forwarding module that crosses by relative path, cross-face forwarding, and equality of whole and batched analysis for the forwarding fixture across batch sizes and package orders.
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, 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.
@@ -10,9 +10,9 @@ Status: implemented
## Decision
[`targetForReference`](../../../../packages/typert/generator/src/analyzer.ts) 用程序的模块解析来解析相对说明符,且只在解析到的文件仍位于引用方包内时继续追踪。在每个转发模块里,它找出承载所请求名字的那条 `export` 边:带说明符的具名重新导出、导出同一符号的星号重新导出,或由该模块自身 `import` 支撑的 `export { local }`。追踪在遇到第一个包说明符时停止,并把该包身份和导出名交给现有的 `packageExportName` 检查,因此被转发的类型仍必须在转发模块所写的包子路径上公开。引用模型不变:同 face 的所有者仍是 `declaration`,另一 face 仍是 `cross-face`。
[`targetForReference`](../../../../packages/typert/generator/src/analyzer.ts) 通过该 face 共享的编译器宿主及其模块解析缓存来解析相对说明符,且只在解析到的文件仍位于引用方包内时继续追踪。在每个转发模块里,它收集承载所请求名字的 `export` 边:带说明符的具名重新导出、由该模块自身 `import` 支撑的 `export { local }`,以及导出同一符号的星号重新导出。显式边先于星号边尝试,与 TypeScript 中显式导出遮蔽星号导出的规则一致;本次追踪已进入过的模块不会再次进入,因此循环的星号重新导出能够终止。追踪在遇到第一个包说明符时停止,并把该包身份和导出名交给现有的 `packageExportName` 检查,因此被转发的类型仍必须在转发模块所写的包子路径上公开,没有登记的包名也在此被拒绝。引用模型不变:同 face 的所有者仍是 `declaration`,另一 face 仍是 `cross-face`。
当相对说明符解析到引用方包之外、转发模块重新导出命名空间,或转发链重复访问同一文件时,追踪得不到包导入,引用照旧失败。
当相对说明符解析到引用方包之外、承载该名字的唯一边是命名空间重新导出或被重新导出的命名空间导入,或所有边都回到已进入的模块时,追踪得不到包导入,引用照旧失败。
## Alternatives considered
@@ -20,8 +20,10 @@ Status: implemented
**把转发模块记为引用目标。** 已拒绝:发射器和跨 face 链接需要原始声明的包和公开子路径,包内模块没有自己的公开身份。
**按源码顺序选边且不校验符号。** 已拒绝:回到更早模块的星号重新导出可能排在真正承载该类型的显式重新导出之前,而 TypeScript 本身允许显式导出遮蔽星号导出;显式边优先并跳过已进入的模块,既能接受这类模块,又不会无限追踪。
**让分批分析与全工作区分析选择相同的根。** 作为修复方案已拒绝:根的选择不改变对一个引用的判定,只决定该引用是否被访问,对齐调用方只会掩盖错误分类,不能消除它。
## Consequences
包可以为外部类型保留一个转发模块并用相对路径导入它,与自身模块的组织方式一致。每个跨包相对引用每跳付出一次模块解析,由共享编译器宿主缓存。[`type-model.spec.ts`](../../../../packages/typert/generator/tests/type-model.spec.ts) 固定了具名、改名多跳、先导入再导出、星号和命名空间导入这几种转发,被转发的私有导出,用相对路径跨包的转发模块,跨 face 转发,以及转发 fixture 在不同批次大小和包顺序下全量分析与分批分析相等。
包可以为外部类型保留一个转发模块并用相对路径导入它,与自身模块的组织方式一致。每个跨包相对引用每跳付出一次经该 face 共享解析缓存的模块解析;`reachableFiles` 现在也通过同一缓存解析。[`type-model.spec.ts`](../../../../packages/typert/generator/tests/type-model.spec.ts) 固定了具名、改名多跳、先导入再导出、星号和命名空间导入这几种转发,与回环星号边并存的显式重新导出,被转发的私有导出,用相对路径跨包的转发模块,唯一出口用相对路径跨包的循环,命名空间重新导出,被重新导出的命名空间导入,跨 face 转发,以及转发 fixture 在不同批次大小和包顺序下全量分析与分批分析相等。
+47 -36
View File
@@ -336,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,
@@ -607,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
@@ -618,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[]
@@ -637,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
@@ -862,15 +864,23 @@ class FaceAnalyzer {
}
private resolveImport(specifier: string, fromFile: string): string | undefined {
const resolved = ts.resolveModuleName(specifier, fromFile, this.program.getCompilerOptions(), ts.sys).resolvedModule
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. A relative
* specifier that resolves outside `from`, a forwarding module that does not
* re-export the name from a module, or a namespace hop yields undefined.
* referencing package until a package specifier appears. Each forwarding
* module 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,
@@ -878,48 +888,48 @@ class FaceAnalyzer {
symbol: ts.Symbol,
from: PackageRegistration,
): PackageImport | undefined {
let sourceFile = site.getSourceFile()
let specifier = moduleSpecifier
let name = authoredExportName(site, moduleSpecifier)
const visited = new Set<string>()
for (;;) {
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) || visited.has(resolvedPath)) return undefined
visited.add(resolvedPath)
sourceFile = this.sourceFiles.get(resolvedPath) as ts.SourceFile
const forwarded = this.forwardedExport(sourceFile, name, symbol)
if (forwarded === undefined) return undefined
specifier = forwarded.specifier
name = forwarded.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))
}
private forwardedExport(sourceFile: ts.SourceFile, name: string, symbol: ts.Symbol): ForwardedExport | undefined {
/** 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)) continue
if (statement.moduleSpecifier !== undefined && ts.isStringLiteral(statement.moduleSpecifier)) {
const specifier = statement.moduleSpecifier.text
if (statement.exportClause === undefined) {
const exported = this.moduleExports(statement.moduleSpecifier)
.find(candidate => candidate.name === name && this.resolveSymbol(candidate) === symbol)
if (exported !== undefined) return { specifier, name }
continue
}
if (ts.isNamespaceExport(statement.exportClause)) continue
const element = statement.exportClause.elements.find(candidate => candidate.name.text === name)
if (element !== undefined) return { specifier, name: element.propertyName?.text ?? name }
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
}
if (statement.exportClause === undefined || ts.isNamespaceExport(statement.exportClause)) 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) return undefined
return { specifier: binding.specifier, name: binding.name }
if (binding?.name !== undefined) explicit.push({ specifier: binding.specifier, name: binding.name })
}
return undefined
return [...explicit, ...stars]
}
private collectServices(
@@ -2563,7 +2573,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
@@ -754,6 +754,64 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => {
})
})
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('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', {