diff --git a/scripts/clean.spec.ts b/scripts/clean.spec.ts index 11a8c880b0..8e58c0ca44 100644 --- a/scripts/clean.spec.ts +++ b/scripts/clean.spec.ts @@ -82,6 +82,28 @@ describe('RepositoryCleaner', () => { expect(existsSync(join(root, 'native/system/tsconfig.tsbuildinfo'))).toBe(false) }) + it('removes pnpm links to deleted packages and keeps live links', async () => { + const root = fixture() + const type = process.platform === 'win32' ? 'junction' : 'dir' + addProject(root, 'packages/core/live') + write(join(root, 'packages/core/live/package.json'), '{}') + const hoisted = join(root, 'node_modules/.pnpm/node_modules') + mkdirSync(join(hoisted, '@deepseek-ai'), { recursive: true }) + symlinkSync(join(root, 'packages/core/live'), join(hoisted, '@deepseek-ai/dsh-live'), type) + symlinkSync(join(root, 'packages/hooks/gone'), join(hoisted, '@deepseek-ai/dsh-gone'), type) + symlinkSync(join(root, 'packages/hooks/plain'), join(hoisted, 'plain'), type) + symlinkSync(join(root, 'packages/hooks/top'), join(root, 'node_modules/top'), type) + + const removed = await new RepositoryCleaner(root).clean() + + expect(removed).toEqual([ + 'node_modules/.pnpm/node_modules/@deepseek-ai/dsh-gone', + 'node_modules/.pnpm/node_modules/plain', + 'node_modules/top', + ]) + expect(existsSync(join(hoisted, '@deepseek-ai/dsh-live/package.json'))).toBe(true) + }) + it('refuses project outputs reached through a symlink outside the repository', async () => { const root = fixture() const externalProject = fixture() diff --git a/scripts/clean.ts b/scripts/clean.ts index bb789f298c..88e589e011 100644 --- a/scripts/clean.ts +++ b/scripts/clean.ts @@ -1,4 +1,4 @@ -import { lstat, readdir, realpath, rm } from 'node:fs/promises' +import { lstat, readdir, realpath, rm, stat } from 'node:fs/promises' import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' import ts from 'typescript' @@ -10,9 +10,9 @@ function isMissing(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOENT' } -async function exists(path: string): Promise { +async function exists(path: string, probe: typeof lstat = lstat): Promise { try { - await lstat(path) + await probe(path) return true } catch (error) { if (isMissing(error)) return false @@ -30,6 +30,27 @@ async function childDirectories(path: string): Promise { } } +/** Lists symbolic links whose targets are missing in a node_modules directory and its `@scope` directories. */ +async function danglingLinks(modulesDirectory: string): Promise { + const links: string[] = [] + const scan = async (directory: string, scopes: boolean): Promise => { + let entries + try { + entries = await readdir(directory, { withFileTypes: true }) + } catch (error) { + if (isMissing(error)) return + throw error + } + for (const entry of entries) { + const path = join(directory, entry.name) + if (scopes && entry.isDirectory() && entry.name.startsWith('@')) await scan(path, false) + else if (entry.isSymbolicLink() && !await exists(path, stat)) links.push(path) + } + } + await scan(modulesDirectory, true) + return links +} + function repositoryPath(root: string, path: string): string { return relative(root, path).split(sep).join('/') } @@ -88,6 +109,13 @@ export class RepositoryCleaner { await this.addIfPresent(targets, outputDirectory, canonicalRoot) } + // pnpm install keeps links whose targets are gone, such as hoisted links to deleted workspace packages. + for (const modulesDirectory of ['node_modules', 'node_modules/.pnpm/node_modules']) { + for (const link of await danglingLinks(join(this.root, modulesDirectory))) { + await this.addIfPresent(targets, link, canonicalRoot) + } + } + 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