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 d8530ac919..45ddcdfdae 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: 17036438b83a77f72b49f55abf29632af3f4ffef -2026-06-17-ts-build-config.zh.md: 70e49c61deba418894a48be3016898d1d85c78a0 +2026-06-17-ts-build-config.md: 5bdfc5e170f12cd95a68f443ab8d02b16db554f3 +2026-06-17-ts-build-config.zh.md: 9f74fb6be9c8e00a070e27a609edf8421a2b5ca6 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 17036438b8..5bdfc5e170 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 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. +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. Before removing an existing target, it resolves the target's parent and refuses it if that resolved parent is outside the repository, so a symlinked project reference cannot redirect cleanup outside the checkout. 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 70e49c61de..9f74fb6be9 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` 会根据根 TypeScript project-reference 图确定当前有效的输出目录,删除遗留的根目录构建信息,并删除已删除包留下且仅包含已知生成残留的 `packages/*/*` 目录。对于仍有 `package.json` 的每个包,该命令都会保留 `node_modules`;如果不含 `package.json` 的目录中存在未知文件,则拒绝删除。构建不会自动调用 clean,因此常规构建会保留增量状态。 +复合项目将增量构建信息保存在各项目本地的 `lib/` 输出中。`pnpm run clean` 会根据根 TypeScript project-reference 图确定当前有效的输出目录,删除遗留的根目录构建信息,并删除已删除包留下且仅包含已知生成残留的 `packages/*/*` 目录。在删除现有目标前,该命令会解析目标父目录的真实路径;如果解析后的父目录位于仓库之外,则拒绝删除,防止使用符号链接的 project reference 将清理操作重定向到工作副本之外。对于仍有 `package.json` 的每个包,该命令都会保留 `node_modules`;如果不含 `package.json` 的目录中存在未知文件,则拒绝删除。构建不会自动调用 clean,因此常规构建会保留增量状态。 命令编排结构如下: diff --git a/packages/support/llm-mock-server/tests/server.spec.ts b/packages/support/llm-mock-server/tests/server.spec.ts index 34db57aa82..a32e5cc8b2 100644 --- a/packages/support/llm-mock-server/tests/server.spec.ts +++ b/packages/support/llm-mock-server/tests/server.spec.ts @@ -1,5 +1,5 @@ import { request } from 'node:http' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import type { MockLlmBehavior, MockLlmServer, MockLlmServerEvent } from '../src/index.ts' import { startMockLlmServer } from '../src/index.ts' @@ -169,21 +169,23 @@ describe('mock LLM server wire behaviors', () => { ['partial_disconnect', 100] as const, ])('records a client that closes during %s', async (behavior, delayMs) => { const events: MockLlmServerEvent[] = [] + const result = Promise.withResolvers>() const server = await start([behavior], { chunkDelayMs: delayMs, disconnectDelayMs: delayMs, chunkSize: 1, - onEvent: (event) => { events.push(event) }, + onEvent: (event) => { + events.push(event) + if (event.type === 'result') result.resolve(event) + }, }) const controller = new AbortController() const response = await chat(server, { signal: controller.signal }) controller.abort() await expect(response.text()).rejects.toThrow() - // The server observes the socket close asynchronously; a fixed sleep - // raced slow runners, so poll until the outcome lands. - await vi.waitFor(() => { - expect(server.requests[0]).toMatchObject({ behavior, outcome: 'client_closed' }) - }) + await result.promise + + expect(server.requests[0]).toMatchObject({ behavior, outcome: 'client_closed' }) expect(events.filter(event => event.type === 'result')).toEqual([ expect.objectContaining({ behavior, outcome: 'client_closed' }), ]) diff --git a/scripts/clean.spec.ts b/scripts/clean.spec.ts index 0d3aced888..0a46764d9a 100644 --- a/scripts/clean.spec.ts +++ b/scripts/clean.spec.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -59,4 +59,21 @@ describe('RepositoryCleaner', () => { await expect(new RepositoryCleaner(root).clean()).rejects.toThrow('packages/removed/ghost/notes.txt') expect(existsSync(join(root, 'products/shell/lib'))).toBe(true) }) + + it('refuses project outputs reached through a symlink outside the repository', async () => { + const root = fixture() + const externalProject = fixture() + write(join(root, 'tsconfig.json'), JSON.stringify({ files: [], references: [{ path: './linked' }] })) + write(join(externalProject, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { composite: true, outDir: 'lib/types' }, + include: ['src'], + })) + write(join(externalProject, 'src/index.ts'), 'export {}\n') + write(join(externalProject, 'lib/types/index.js')) + symlinkSync(externalProject, join(root, 'linked'), process.platform === 'win32' ? 'junction' : 'dir') + + await expect(new RepositoryCleaner(root).clean()).rejects.toThrow('outside repository') + + expect(existsSync(join(externalProject, 'lib/types/index.js'))).toBe(true) + }) }) diff --git a/scripts/clean.ts b/scripts/clean.ts index 3cb3820db7..fff158c458 100644 --- a/scripts/clean.ts +++ b/scripts/clean.ts @@ -1,4 +1,4 @@ -import { lstat, readdir, rm } from 'node:fs/promises' +import { lstat, readdir, realpath, rm } from 'node:fs/promises' import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' import ts from 'typescript' @@ -45,7 +45,11 @@ function parseConfig(configPath: string): ts.ParsedCommandLine { /** Plans and removes repository-owned build output without crossing the repository boundary. */ export class RepositoryCleaner { - constructor(private readonly root: string) {} + private readonly root: string + + constructor(root: string) { + this.root = resolve(root) + } /** * Remove generated build state and package directories containing only known residue. @@ -61,9 +65,10 @@ export class RepositoryCleaner { private async plan(): Promise { const targets = new Set() const unsafeOrphans: string[] = [] + const canonicalRoot = await realpath(this.root) // These checks cover legacy root-level incremental state emitted by older configs. - await this.addIfPresent(targets, join(this.root, '.typecheck')) + await this.addIfPresent(targets, join(this.root, '.typecheck'), canonicalRoot) for (const entry of await readdir(this.root, { withFileTypes: true })) { if (entry.isFile() && entry.name.endsWith('.tsbuildinfo')) targets.add(join(this.root, entry.name)) } @@ -72,7 +77,7 @@ export class RepositoryCleaner { // 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, outputDirectory, canonicalRoot) } for (const groupDirectory of await childDirectories(join(this.root, 'packages'))) { @@ -90,7 +95,7 @@ export class RepositoryCleaner { if (unknown.length > 0) { unsafeOrphans.push(...unknown.map(entry => repositoryPath(this.root, join(packageDirectory, entry)))) } else { - targets.add(packageDirectory) + await this.addIfPresent(targets, packageDirectory, canonicalRoot) } } } @@ -137,15 +142,24 @@ export class RepositoryCleaner { } private assertRepositoryTarget(path: string): void { - const repositoryRelative = relative(this.root, path) + this.assertDescendant(this.root, path, path) + } + + private assertDescendant(root: string, path: string, displayPath: string): void { + const repositoryRelative = relative(root, path) if (repositoryRelative === '' || repositoryRelative === '..' || repositoryRelative.startsWith(`..${sep}`) || isAbsolute(repositoryRelative)) { - throw new Error(`clean: refusing build output outside repository: ${path}`) + throw new Error(`clean: refusing deletion target outside repository: ${displayPath}`) } } - private async addIfPresent(targets: Set, path: string): Promise { + private async addIfPresent(targets: Set, path: string, canonicalRoot: string): Promise { // Missing outputs are normal on a clean checkout; only existing paths become deletion targets. - if (await exists(path)) targets.add(path) + if (!await exists(path)) return + // Resolve the parent rather than the final entry: rm unlinks a final symlink, + // but a symlink in an ancestor would make deletion cross the repository boundary. + const canonicalParent = await realpath(dirname(path)) + this.assertDescendant(canonicalRoot, join(canonicalParent, basename(path)), path) + targets.add(path) } }