From 787b4e6b9e1dbe1e88649bbc4061dee4b8912819 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:30:35 +0800 Subject: [PATCH] fix(build): derive clean outputs from project graph --- .../2026-06-17-ts-build-config.i18n.yaml | 4 +- .../process/2026-06-17-ts-build-config.md | 2 +- .../process/2026-06-17-ts-build-config.zh.md | 2 +- scripts/clean.spec.ts | 62 ++++++++++++++ scripts/clean.ts | 80 +++++++++++++++++-- 5 files changed, 140 insertions(+), 10 deletions(-) create mode 100644 scripts/clean.spec.ts diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml index 32202d837f..d8530ac919 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.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 -2026-06-17-ts-build-config.md: 527570393c42d581d28da0380efdf9ba8bade8ae -2026-06-17-ts-build-config.zh.md: 6535add99115bff0e396e87729bef225dae39e6c +2026-06-17-ts-build-config.md: 17036438b83a77f72b49f55abf29632af3f4ffef +2026-06-17-ts-build-config.zh.md: 70e49c61deba418894a48be3016898d1d85c78a0 diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md index 527570393c..17036438b8 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md @@ -43,7 +43,7 @@ In-package relative imports use explicit `.ts` specifiers. - Referenced package/vendor projects keep the same emit behavior as build, so typecheck refreshes their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/*/tsconfig.json` or `vendor/*/tsconfig.json`. - The no-emit aggregates disable `rewriteRelativeImportExtensions`; they emit nothing and include tests that import helpers across project-reference boundaries. Package/vendor emit projects keep the rewrite enabled. -Composite projects keep their incremental build information inside their package-local `lib/` output. `pnpm run clean` explicitly removes package/vendor/CLI `lib/` outputs, legacy root build information, and deleted `packages/*/*` directories that contain only known generated residue. It preserves `node_modules` for every package that still has a `package.json`, and refuses to remove a manifest-less directory containing unknown files. Build does not invoke clean automatically, so ordinary builds retain incremental state. +Composite projects keep their incremental build information inside their project-local `lib/` output. `pnpm run clean` derives live output directories from the root TypeScript project-reference graph, removes legacy root build information, and removes deleted `packages/*/*` directories that contain only known generated residue. It preserves `node_modules` for every package that still has a `package.json`, and refuses to remove a manifest-less directory containing unknown files. Build does not invoke clean automatically, so ordinary builds retain incremental state. The command orchestration shape is: diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md index 6535add991..70e49c61de 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md @@ -43,7 +43,7 @@ Status: implemented - 被引用的包/vendor 项目保持与构建相同的输出行为,因此类型检查会刷新它们的 `lib/types` 输出,而无需使用独立的 no-emit 图。项目特定的严格度变更放在各自的 `packages/*/*/tsconfig.json` 或 `vendor/*/tsconfig.json` 中。 - 两个 no-emit 聚合禁用 `rewriteRelativeImportExtensions`;它们不输出任何文件,且包含跨 project-reference 边界导入 helper 的测试。包/vendor 的 emit 项目保持重写开启。 -复合项目将增量构建信息保存在各包本地的 `lib/` 输出中。`pnpm run clean` 会显式删除包、vendor 和 CLI(命令行界面)的 `lib/` 输出、遗留的根目录构建信息,以及已删除包留下且仅包含已知生成残留的 `packages/*/*` 目录。对于仍有 `package.json` 的每个包,该命令都会保留 `node_modules`;如果不含 `package.json` 的目录中存在未知文件,则拒绝删除。构建不会自动调用 clean,因此常规构建会保留增量状态。 +复合项目将增量构建信息保存在各项目本地的 `lib/` 输出中。`pnpm run clean` 会根据根 TypeScript project-reference 图确定当前有效的输出目录,删除遗留的根目录构建信息,并删除已删除包留下且仅包含已知生成残留的 `packages/*/*` 目录。对于仍有 `package.json` 的每个包,该命令都会保留 `node_modules`;如果不含 `package.json` 的目录中存在未知文件,则拒绝删除。构建不会自动调用 clean,因此常规构建会保留增量状态。 命令编排结构如下: diff --git a/scripts/clean.spec.ts b/scripts/clean.spec.ts new file mode 100644 index 0000000000..0d3aced888 --- /dev/null +++ b/scripts/clean.spec.ts @@ -0,0 +1,62 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +import { RepositoryCleaner } from './clean.ts' + +const roots: string[] = [] + +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-clean-')) + roots.push(root) + return root +} + +function write(path: string, content = ''): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) +} + +function addProject(root: string, path: string): void { + write(join(root, 'tsconfig.json'), JSON.stringify({ files: [], references: [{ path }] })) + write(join(root, path, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { composite: true, outDir: 'lib/types' }, + include: ['src'], + })) + write(join(root, path, 'src/index.ts'), 'export {}\n') +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('RepositoryCleaner', () => { + it('derives live build outputs from project references and removes safe stale package residue', async () => { + const root = fixture() + addProject(root, 'products/shell') + write(join(root, 'products/shell/lib/types/index.js')) + write(join(root, 'products/shell/lib/index.js')) + write(join(root, '.typecheck/legacy.tsbuildinfo')) + write(join(root, 'root.tsbuildinfo')) + write(join(root, 'packages/removed/ghost/node_modules/.bin/tool')) + + await new RepositoryCleaner(root).clean() + + expect(existsSync(join(root, 'products/shell/lib'))).toBe(false) + expect(existsSync(join(root, 'products/shell/src/index.ts'))).toBe(true) + expect(existsSync(join(root, '.typecheck'))).toBe(false) + expect(existsSync(join(root, 'root.tsbuildinfo'))).toBe(false) + expect(existsSync(join(root, 'packages/removed/ghost'))).toBe(false) + }) + + it('does not delete any target when a manifest-less package contains an unknown file', async () => { + const root = fixture() + addProject(root, 'products/shell') + write(join(root, 'products/shell/lib/types/index.js')) + write(join(root, 'packages/removed/ghost/notes.txt')) + + await expect(new RepositoryCleaner(root).clean()).rejects.toThrow('packages/removed/ghost/notes.txt') + expect(existsSync(join(root, 'products/shell/lib'))).toBe(true) + }) +}) diff --git a/scripts/clean.ts b/scripts/clean.ts index 3f0f85836f..93b9b2519c 100644 --- a/scripts/clean.ts +++ b/scripts/clean.ts @@ -1,9 +1,21 @@ import { lstat, readdir, rm } from 'node:fs/promises' -import { dirname, join, relative, resolve, sep } from 'node:path' +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' +import ts from 'typescript' const knownOrphanEntries = new Set(['node_modules', 'lib', '.typecheck']) +const configHost: ts.ParseConfigFileHost = { + useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, + readDirectory: (...args) => ts.sys.readDirectory(...args), + fileExists: fileName => ts.sys.fileExists(fileName), + readFile: fileName => ts.sys.readFile(fileName), + getCurrentDirectory: () => ts.sys.getCurrentDirectory(), + onUnRecoverableConfigFileDiagnostic(diagnostic) { + throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')) + }, +} + function isMissing(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOENT' } @@ -32,7 +44,17 @@ function repositoryPath(root: string, path: string): string { return relative(root, path).split(sep).join('/') } -class RepositoryCleaner { +function parseConfig(configPath: string): ts.ParsedCommandLine { + const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, configHost) + if (!parsed) throw new Error(`clean: cannot parse TypeScript config ${configPath}`) + if (parsed.errors.length > 0) { + throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n')) + } + return parsed +} + +/** Plans and removes repository-owned build output without crossing the repository boundary. */ +export class RepositoryCleaner { constructor(private readonly root: string) {} /** @@ -41,6 +63,7 @@ class RepositoryCleaner { */ async clean(): Promise { const targets = await this.plan() + // Planning validates every target first, so an unsafe orphan prevents all deletion. for (const target of targets) await rm(target, { recursive: true, force: true }) return targets.map(target => repositoryPath(this.root, target)) } @@ -49,23 +72,29 @@ class RepositoryCleaner { const targets = new Set() const unsafeOrphans: string[] = [] + // These checks cover legacy root-level incremental state emitted by older configs. await this.addIfPresent(targets, join(this.root, '.typecheck')) for (const entry of await readdir(this.root, { withFileTypes: true })) { if (entry.isFile() && entry.name.endsWith('.tsbuildinfo')) targets.add(join(this.root, entry.name)) } - for (const vendorDirectory of await childDirectories(join(this.root, 'vendor'))) { - await this.addIfPresent(targets, join(vendorDirectory, 'lib')) + // The root project-reference graph is the source of truth for live build targets. + // Each emitting project declares lib/types as outDir; its parent lib also owns + // the sibling runtime bundles, so the complete build output root is removed. + for (const outputDirectory of this.buildOutputDirectories()) { + await this.addIfPresent(targets, outputDirectory) } - await this.addIfPresent(targets, join(this.root, 'apps', 'cli', 'lib')) for (const groupDirectory of await childDirectories(join(this.root, 'packages'))) { for (const packageDirectory of await childDirectories(groupDirectory)) { + // A package.json marks a live package; its output was discovered from the + // project graph above, and its package-local node_modules must be preserved. if (await exists(join(packageDirectory, 'package.json'))) { - await this.addIfPresent(targets, join(packageDirectory, 'lib')) continue } + // A manifest-less package directory is stale only when every remaining + // entry is known generated residue; unknown files make the whole clean fail. const entries = await readdir(packageDirectory) const unknown = entries.filter(entry => !knownOrphanEntries.has(entry) && !entry.endsWith('.tsbuildinfo')) if (unknown.length > 0) { @@ -86,7 +115,46 @@ class RepositoryCleaner { return [...targets].sort() } + private buildOutputDirectories(): string[] { + const outputs = new Set() + const pending = [join(this.root, 'tsconfig.json')] + const visited = new Set() + + while (pending.length > 0) { + const nextConfigPath = pending.pop() + if (nextConfigPath === undefined) break + const configPath = resolve(nextConfigPath) + if (visited.has(configPath)) continue + visited.add(configPath) + + const parsed = parseConfig(configPath) + if (parsed.options.outDir !== undefined) { + const typesDirectory = resolve(parsed.options.outDir) + if (basename(typesDirectory) !== 'types') { + throw new Error(`clean: expected TypeScript outDir to end in /types: ${repositoryPath(this.root, typesDirectory)}`) + } + const outputDirectory = dirname(typesDirectory) + this.assertRepositoryTarget(outputDirectory) + outputs.add(outputDirectory) + } + + for (const reference of parsed.projectReferences ?? []) { + pending.push(ts.resolveProjectReferencePath(reference)) + } + } + + return [...outputs] + } + + private assertRepositoryTarget(path: string): void { + const repositoryRelative = relative(this.root, path) + if (repositoryRelative === '' || repositoryRelative === '..' || repositoryRelative.startsWith(`..${sep}`) || isAbsolute(repositoryRelative)) { + throw new Error(`clean: refusing build output outside repository: ${path}`) + } + } + private async addIfPresent(targets: Set, path: string): Promise { + // Missing outputs are normal on a clean checkout; only existing paths become deletion targets. if (await exists(path)) targets.add(path) } }