fix(typert): reject unresolved workspace imports in source-only generation

rejectBuildOutputs caught a missing paths alias only when the package's
lib/types already existed. On a tree whose packages are not yet compiled,
as when host-typert runs beside host-src on a clean checkout, the import
resolved to nothing and checkDiagnostics: false hid it. The guard now also
fails when a face program cannot resolve an import of a workspace package,
found through its node_modules link, and names the paths entry derived
from the package's exports types condition.

Refs #5852
This commit is contained in:
imccyu committed 2026-10-12 11:06:56 +08:00
1 parent 8a6c68fc34
commit 5222ef2751
8 files changed
+158 -33

No files matched your search

+4 -4
View File
@@ -27,8 +27,8 @@
en: ebcb4850a5620d93
zh: 55f6f54265830f28
/deepseek-ai-dsh-typert-generator/use-this-package/generating-without-a-bundler:
en: f8b70c1210fd8e40
zh: 8fb506eb32e46913
en: e1afb26aea87fe46
zh: ed3cdfde78d87825
/deepseek-ai-dsh-typert-generator/understand-the-implementation:
en: b90762df7a978580
zh: 123ab225a2b5df2a
@@ -36,8 +36,8 @@
en: 0a515742229bf442
zh: dfd2c5eddd943af9
/deepseek-ai-dsh-typert-generator/understand-the-implementation/source-map:
en: c205dc3f10099130
zh: 8525c389ebed4b2e
en: 89d9205446b496a6
zh: d7bcae876c1905ec
/deepseek-ai-dsh-typert-generator/understand-the-implementation/analysis-and-faces:
en: a844aafcafbc27b6
zh: 8efe7d472361bbc2
+2 -2
View File
@@ -59,7 +59,7 @@ In `workspace` mode it emits every explicit contributor once without blocking bu
`writeWorkspaceArtifacts()` and `writePackageArtifacts()` are the plugin's file writers: each artifact becomes `lib/typert.<face>.{js,d.ts}`, plus `lib/typert.remote-client.{js,d.ts,d.ts.map}` when it carries Remote methods, and a Host artifact without Remote methods removes stale Remote client files. Writing the result of `WorkspaceTypertGenerator.generateContributors()` through them leaves the same bytes as the `workspace`-mode plugin, and `findWorkspaceRoot()` applies the plugin's workspace-root rule. The repository's bundle-free pass, [`scripts/generate-typert.ts`](../../../scripts/generate-typert.ts), generates the Host face this way for type checks that need no bundle.
With `rejectBuildOutputs: true`, analysis fails with a `TypertAnalysisError` when a face program loads a file from a workspace package's `lib/` directory. The error names each import specifier that resolved there and the source file a `paths` alias should map it to, so a passing generation depends only on workspace source and can run before or beside package compilation. Leave it off for a face whose sources import generated outputs, such as a Client face importing `/remote`.
With `rejectBuildOutputs: true`, analysis fails with a `TypertAnalysisError` when a face program loads a file from a workspace package's `lib/` directory, or cannot resolve an import of a workspace package, as on a tree whose packages are not yet compiled. The error names each such import specifier and the source file a `paths` alias should map it to, so a passing generation depends only on workspace source and can run before or beside package compilation. Leave it off for a face whose sources import generated outputs, such as a Client face importing `/remote`.
-----
@@ -86,7 +86,7 @@ The generator is built on one separation: extraction and emission are decoupled
| [`src/workspace.ts`](src/workspace.ts) | `WorkspaceTypertGenerator`: discovery, generation, export and files validation; workspace-root lookup |
| [`src/tsdown-plugin.ts`](src/tsdown-plugin.ts) | tsdown plugin face: decorator lowering and artifact emission |
| [`src/artifact-files.ts`](src/artifact-files.ts) | Package-local artifact writers shared by the plugin and bundle-free generation |
| [`src/build-output-guard.ts`](src/build-output-guard.ts) | Detection of workspace `lib/` files in a face program for `rejectBuildOutputs` |
| [`src/build-output-guard.ts`](src/build-output-guard.ts) | Detection of workspace `lib/` files and unresolved workspace imports in a face program for `rejectBuildOutputs` |
| [`src/worker.ts`](src/worker.ts) | Worker Thread entry for workspace-mode generation |
| [`src/cordis-catalog.ts`](src/cordis-catalog.ts) | Catalog projection used by the generated Cordis catalogs |
+2 -2
View File
@@ -59,7 +59,7 @@ JSON、YAML、SVG、PNG、JPEG 和 WebP 资源导出不提供 TypeScript 声明
`writeWorkspaceArtifacts()` 与 `writePackageArtifacts()` 是插件的写盘函数:每个产物写成 `lib/typert.<face>.{js,d.ts}`,带 Remote 方法时另写 `lib/typert.remote-client.{js,d.ts,d.ts.map}`,没有 Remote 方法的 Host 产物会删除残留的 Remote client 文件。把 `WorkspaceTypertGenerator.generateContributors()` 的结果交给它们写盘,得到的字节与 `workspace` 模式插件相同;`findWorkspaceRoot()` 采用插件的工作区根目录规则。仓库中不打包的生成 [`scripts/generate-typert.ts`](../../../scripts/generate-typert.ts) 就以这种方式为不需要 bundle 的类型检查生成 Host face。
设置 `rejectBuildOutputs: true` 后,若 face program 载入了工作区包 `lib/` 目录下的文件,分析会以 `TypertAnalysisError` 失败。错误信息列出解析到那里的每个 import 说明符,以及应由 `paths` 别名映射到的源文件;因此通过的生成只依赖工作区源码,可以在包编译之前或与之并行运行。若某个 face 的源码 import 生成产物(例如 Client face import `/remote`),则不要开启它。
设置 `rejectBuildOutputs: true` 后,若 face program 载入了工作区包 `lib/` 目录下的文件,或无法解析对工作区包的 import(例如包尚未编译的工作区),分析会以 `TypertAnalysisError` 失败。错误信息列出每个这样的 import 说明符,以及应由 `paths` 别名映射到的源文件;因此通过的生成只依赖工作区源码,可以在包编译之前或与之并行运行。若某个 face 的源码 import 生成产物(例如 Client face import `/remote`),则不要开启它。
-----
@@ -86,7 +86,7 @@ JSON、YAML、SVG、PNG、JPEG 和 WebP 资源导出不提供 TypeScript 声明
| [`src/workspace.ts`](src/workspace.ts) | `WorkspaceTypertGenerator`:发现、生成、导出与文件清单校验;工作区根目录查找 |
| [`src/tsdown-plugin.ts`](src/tsdown-plugin.ts) | tsdown 插件面:装饰器转换与产物生成 |
| [`src/artifact-files.ts`](src/artifact-files.ts) | 插件与不打包生成共用的包内产物写盘函数 |
| [`src/build-output-guard.ts`](src/build-output-guard.ts) | 为 `rejectBuildOutputs` 检测 face program 中的工作区 `lib/` 文件 |
| [`src/build-output-guard.ts`](src/build-output-guard.ts) | 为 `rejectBuildOutputs` 检测 face program 中的工作区 `lib/` 文件与无法解析的工作区 import |
| [`src/worker.ts`](src/worker.ts) | workspace 模式生成所用的 Worker Thread 入口 |
| [`src/cordis-catalog.ts`](src/cordis-catalog.ts) | 生成 Cordis 目录所用的目录投影 |
+38 -15
View File
@@ -8,8 +8,8 @@
import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
import { dirname, extname, join, relative, resolve, sep } from 'node:path'
import ts from 'typescript'
import { findBuildOutputLoads } from './build-output-guard.ts'
import type { BuildOutputLoad } from './build-output-guard.ts'
import { findBuildOutputLoads, findUnresolvedWorkspaceImports } from './build-output-guard.ts'
import type { BuildOutputLoad, UnresolvedWorkspaceImport } from './build-output-guard.ts'
import type {
CrossFaceLink,
DocumentationModel,
@@ -83,10 +83,11 @@ export interface WorkspaceAnalyzerOptions {
readonly caches?: WorkspaceCaches
/**
* Fail analysis when a face program loads a file from a workspace
* package's `lib/` directory (default false). Enable it for faces that
* import no generated output, so that the result depends only on workspace
* source and never on whether package compilation ran; the failure names
* each importing specifier and the source a `paths` alias should map it to.
* package's `lib/` directory, or cannot resolve an import of a workspace
* package (default false). Enable it for faces that import no generated
* output, so that the result depends only on workspace source and never on
* whether package compilation ran; the failure names each importing
* specifier and the source a `paths` alias should map it to.
*/
readonly rejectBuildOutputs?: boolean
}
@@ -382,7 +383,13 @@ export class WorkspaceAnalyzer {
const host = this.caches.programHost(face, options)
const program = ts.createProgram({ rootNames, options, host })
if (this.options.rejectBuildOutputs) {
rejectBuildOutputLoads(face, this.options.root, aggregatePath, findBuildOutputLoads(program, this.options.root, host))
rejectBuildOutputLoads(
face,
this.options.root,
aggregatePath,
findBuildOutputLoads(program, this.options.root, host),
findUnresolvedWorkspaceImports(program, this.options.root, host),
)
}
faces.push(new FaceAnalyzer({
root: this.options.root,
@@ -3354,13 +3361,31 @@ function slash(value: string): string {
return value.replaceAll('\\', '/')
}
function rejectBuildOutputLoads(face: TypertFace, root: string, aggregatePath: string, loads: readonly BuildOutputLoad[]): void {
if (loads.length === 0) return
function rejectBuildOutputLoads(
face: TypertFace,
root: string,
aggregatePath: string,
loads: readonly BuildOutputLoad[],
unresolved: readonly UnresolvedWorkspaceImport[],
): void {
if (loads.length === 0 && unresolved.length === 0) return
const path = (file: string): string => slash(relative(root, file))
const config = path(aggregatePath)
const lines = [
`typert(${face}): the ${face} program loads workspace build outputs instead of source; resolve each import below to source so that generation does not depend on package compilation:`,
]
const lines: string[] = []
const addAlias = (specifier: string, source: string): void => {
lines.push(` add ${JSON.stringify(specifier)}: [${JSON.stringify(`./${path(source)}`)}] to the compilerOptions.paths of ${config} or the config it extends`)
}
if (unresolved.length > 0) {
lines.push(`typert(${face}): the ${face} program cannot resolve workspace imports, which would analyze as unknown types; map each import below to source:`)
for (const entry of unresolved) {
lines.push(` '${entry.specifier}' imported by ${path(entry.file)}`)
if (entry.source === undefined) lines.push(` add a compilerOptions.paths alias for it to ${config} or the config it extends`)
else addAlias(entry.specifier, entry.source)
}
}
if (loads.length > 0) {
lines.push(`typert(${face}): the ${face} program loads workspace build outputs instead of source; resolve each import below to source so that generation does not depend on package compilation:`)
}
for (const load of loads) {
lines.push(` ${path(load.file)}`)
if (load.importers.length === 0) {
@@ -3373,9 +3398,7 @@ function rejectBuildOutputLoads(face: TypertFace, root: string, aggregatePath: s
if (load.source === undefined) {
lines.push(' no workspace source compiles to it; import a source module instead')
} else {
for (const specifier of specifiers) {
lines.push(` add ${JSON.stringify(specifier)}: [${JSON.stringify(`./${path(load.source)}`)}] to the compilerOptions.paths of ${config} or the config it extends`)
}
for (const specifier of specifiers) addAlias(specifier, load.source)
}
}
throw new TypertAnalysisError(lines.join('\n'))
@@ -1,12 +1,14 @@
/**
* Detection of workspace build outputs loaded into an analyzed face program.
* Typert reads workspace packages only from source when a caller rejects
* build outputs, so its result cannot depend on whether, or when, package
* compilation ran.
* Detection of workspace build outputs loaded into an analyzed face program,
* and of workspace imports that resolve to nothing. Typert reads workspace
* packages only from source when a caller rejects build outputs, so its
* result cannot depend on whether, or when, package compilation ran: an
* import that reaches `lib/` on a compiled tree resolves to nothing on a
* clean one, and both are rejected.
* @module @deepseek-ai/dsh-typert-generator/build-output-guard
*/
import { existsSync } from 'node:fs'
import { existsSync, readFileSync, realpathSync } from 'node:fs'
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
import ts from 'typescript'
@@ -64,6 +66,76 @@ export function findBuildOutputLoads(
.map(([file, importers]) => ({ file, importers, source: classifier.sourceFor(file) }))
}
/** One import of a workspace package from a program source file that module resolution cannot resolve. */
export interface UnresolvedWorkspaceImport {
/** Absolute path of the importing file. */
readonly file: string
/** Import specifier as written. */
readonly specifier: string
/** Absolute path of the workspace source the specifier's package export compiles from, when one exists. */
readonly source: string | undefined
}
/**
* List the imports in workspace program files that name a workspace package
* and resolve to nothing. A workspace package is one whose `node_modules` link,
* found from the importing file's directories, leads inside `root` outside any
* `node_modules`; without that link or a `paths` alias the package cannot be
* identified, and type checking reports the import. The source of a subpath is
* derived from the package's `exports` `types` condition under `lib/types/`.
* @param program - the analyzed face program.
* @param root - workspace root.
* @param host - module resolution host of the program.
* @returns unresolved imports in file and specifier order.
*/
export function findUnresolvedWorkspaceImports(
program: ts.Program,
root: string,
host: ts.ModuleResolutionHost,
): UnresolvedWorkspaceImport[] {
const classifier = new BuildOutputClassifier(root)
const options = program.getCompilerOptions()
const cache = ts.createModuleResolutionCache(root, file => file, options)
const found: UnresolvedWorkspaceImport[] = []
for (const sourceFile of program.getSourceFiles()) {
if (!classifier.isWorkspaceFile(sourceFile.fileName) || classifier.isBuildOutput(sourceFile.fileName)) continue
for (const { fileName: specifier } of ts.preProcessFile(sourceFile.text, true, true).importedFiles) {
if (specifier.startsWith('.') || isAbsolute(specifier) || specifier.includes(':')) continue
if (ts.resolveModuleName(specifier, sourceFile.fileName, options, host, cache).resolvedModule !== undefined) continue
const packageRoot = classifier.linkedWorkspacePackage(sourceFile.fileName, packageNameOf(specifier))
if (packageRoot === undefined) continue
found.push({ file: resolve(sourceFile.fileName), specifier, source: exportSource(packageRoot, specifier) })
}
}
return found.sort((left, right) => left.file.localeCompare(right.file) || left.specifier.localeCompare(right.specifier))
}
function packageNameOf(specifier: string): string {
const parts = specifier.split('/')
return parts.slice(0, specifier.startsWith('@') ? 2 : 1).join('/')
}
function exportSource(packageRoot: string, specifier: string): string | undefined {
const packageName = packageNameOf(specifier)
const subpath = specifier === packageName ? '.' : `.${specifier.slice(packageName.length)}`
let manifest: unknown
try {
manifest = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'))
} catch (_unreadableManifest) {
// The error names the import without a suggested source path.
return undefined
}
const entry = property(property(manifest, 'exports'), subpath)
const types = typeof entry === 'string' ? entry : property(entry, 'types')
if (typeof types !== 'string' || !types.startsWith('./lib/types/')) return undefined
const stem = join(packageRoot, 'src', types.slice('./lib/types/'.length).replace(/\.d\.([cm]?)ts$/u, '.$1ts'))
return [stem, `${stem}x`].find(candidate => existsSync(candidate))
}
function property(value: unknown, key: string): unknown {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>)[key] : undefined
}
class BuildOutputClassifier {
private readonly packageRoots = new Map<string, string | undefined>()
@@ -90,6 +162,19 @@ class BuildOutputClassifier {
return [stem, `${stem}x`].find(candidate => existsSync(candidate))
}
linkedWorkspacePackage(importer: string, packageName: string): string | undefined {
let directory = dirname(resolve(importer))
for (;;) {
const link = join(directory, 'node_modules', packageName)
if (existsSync(link)) {
const target = realpathSync(link)
return this.isWorkspaceFile(join(target, 'package.json')) ? target : undefined
}
if (directory === this.root || dirname(directory) === directory) return undefined
directory = dirname(directory)
}
}
private packageRoot(directory: string): string | undefined {
if (this.packageRoots.has(directory)) return this.packageRoots.get(directory)
const found = existsSync(join(directory, 'package.json'))
+2 -1
View File
@@ -28,7 +28,8 @@ export interface WorkspaceTypertGeneratorOptions {
readonly checkDiagnostics?: boolean
/**
* Fail when an analyzed face program loads a file from a workspace
* package's `lib/` directory (default false); see
* package's `lib/` directory or cannot resolve an import of a workspace
* package (default false); see
* `WorkspaceAnalyzerOptions.rejectBuildOutputs`.
*/
readonly rejectBuildOutputs?: boolean
+15
View File
@@ -107,6 +107,21 @@ describe('bundle-free Typert pass', { timeout: 60_000 }, () => {
expect(Object.keys(typertOutputs(pkg))).toEqual(['typert.host.d.ts', 'typert.host.js'])
})
it('rejects an unresolved workspace import before the package compiles and names the missing alias', () => {
const { root, pkg } = workspace(DEPENDENT_TYPERT_SOURCE)
writeCompiledDependency(root, false)
const output = captureConsole()
expect(main([], root)).toBe(1)
expect(output.error().split('\n').slice(1, 4)).toEqual([
' TypertAnalysisError: typert(host): the host program cannot resolve workspace imports, which would analyze as unknown types; map each import below to source:',
" '@fixture/dep/types' imported by packages/fixture/probe/src/index.ts",
' add "@fixture/dep/types": ["./packages/fixture/dep/src/types.ts"] to the compilerOptions.paths of tsconfig.host.json or the config it extends',
])
expect(existsSync(join(pkg, 'lib'))).toBe(false)
})
it('reports a failed write as a possibly mixed generation', () => {
const { root, pkg } = workspace()
writeFileSync(join(pkg, 'lib'), 'not a directory\n')
+5 -4
View File
@@ -63,13 +63,14 @@ function writeFixtureFile(path: string, content: string): void {
export const DEPENDENT_TYPERT_SOURCE = "import type { Shape } from '@fixture/dep/types'\n\n/** @typert schema */\nexport interface Payload { shape: Shape }\n"
/**
* Add a compiled Host package, `@fixture/dep`, linked into `node_modules` and referenced from
* Add a Host package, `@fixture/dep`, linked into `node_modules` and referenced from
* `tsconfig.host.json`. Without a `paths` alias, `@fixture/dep/types` resolves through the link to
* its compiled `lib/types/types.d.ts`.
* its compiled `lib/types/types.d.ts`, or to nothing before the package compiles.
* @param root - workspace written by {@link writeTypertWorkspace}.
* @param compiled - whether `lib/types/types.d.ts` exists, as after package compilation.
* @returns absolute path of the dependency package.
*/
export function writeCompiledDependency(root: string): string {
export function writeCompiledDependency(root: string, compiled = true): string {
const dependency = join(root, 'packages/fixture/dep')
const declaration = 'export interface Shape { side: number }\n'
writeFixtureFile(join(dependency, 'package.json'), `${JSON.stringify({
@@ -87,7 +88,7 @@ export function writeCompiledDependency(root: string): string {
aggregate.references.push({ path: './packages/fixture/dep' })
writeFixtureFile(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`)
writeFixtureFile(join(dependency, 'src/types.ts'), declaration)
writeFixtureFile(join(dependency, 'lib/types/types.d.ts'), declaration)
if (compiled) writeFixtureFile(join(dependency, 'lib/types/types.d.ts'), declaration)
mkdirSync(join(root, 'node_modules/@fixture'), { recursive: true })
symlinkSync(dependency, join(root, 'node_modules/@fixture/dep'), 'junction')
return dependency