mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
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:
1 parent
8a6c68fc34
commit
5222ef2751
8 files changed
+158
-33
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 目录所用的目录投影 |
|
||||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user