diff --git a/scripts/clean.spec.ts b/scripts/clean.spec.ts index 8e58c0ca44..cf13aef6c5 100644 --- a/scripts/clean.spec.ts +++ b/scripts/clean.spec.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -27,6 +27,11 @@ function addProject(root: string, path: string, outDir = 'lib/types'): void { write(join(root, path, 'src/index.ts'), 'export {}\n') } +function link(target: string, path: string): void { + mkdirSync(dirname(path), { recursive: true }) + symlinkSync(target, path, process.platform === 'win32' ? 'junction' : 'dir') +} + afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) @@ -82,28 +87,39 @@ describe('RepositoryCleaner', () => { expect(existsSync(join(root, 'native/system/tsconfig.tsbuildinfo'))).toBe(false) }) - it('removes pnpm links to deleted packages and keeps live links', async () => { + it('removes pnpm links to deleted or cleaned 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'), '{}') + write(join(root, 'packages/hooks/stale/lib/index.js')) 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) + link(join(root, 'packages/core/live'), join(hoisted, '@deepseek-ai/dsh-live')) + link(join(root, 'packages/hooks/gone'), join(hoisted, '@deepseek-ai/dsh-gone')) + link(join(root, 'packages/hooks/stale'), join(hoisted, '@deepseek-ai/dsh-stale')) + link(join(root, 'packages/hooks/plain'), join(hoisted, 'plain')) + link(join(hoisted, 'loop'), join(hoisted, 'loop')) + link(join(root, 'packages/hooks/top'), join(root, 'node_modules/@deepseek-ai/dsh-top')) - const removed = await new RepositoryCleaner(root).clean() + 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(readdirSync(join(hoisted, '@deepseek-ai'))).toEqual(['dsh-live']) + expect(readdirSync(hoisted)).toEqual(['@deepseek-ai']) + expect(readdirSync(join(root, 'node_modules/@deepseek-ai'))).toEqual([]) expect(existsSync(join(hoisted, '@deepseek-ai/dsh-live/package.json'))).toBe(true) }) + it('does not scan node_modules outside the repository', async () => { + const root = fixture() + const externalModules = fixture() + addProject(root, 'products/shell') + link(join(root, 'packages/hooks/gone'), join(externalModules, 'gone')) + link(externalModules, join(root, 'node_modules')) + + await new RepositoryCleaner(root).clean() + + expect(readdirSync(externalModules)).toEqual(['gone']) + }) + 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 88e589e011..8d8d8e4fc1 100644 --- a/scripts/clean.ts +++ b/scripts/clean.ts @@ -1,4 +1,5 @@ -import { lstat, readdir, realpath, rm, stat } from 'node:fs/promises' +import { type Dirent } from 'node:fs' +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' @@ -6,13 +7,17 @@ import { repositoryConfigHost } from './ts-project.ts' const knownOrphanEntries = new Set(['node_modules', 'lib', '.typecheck']) -function isMissing(error: unknown): boolean { - return error instanceof Error && 'code' in error && error.code === 'ENOENT' +function errorCode(error: unknown): unknown { + return error instanceof Error && 'code' in error ? error.code : undefined } -async function exists(path: string, probe: typeof lstat = lstat): Promise { +function isMissing(error: unknown): boolean { + return errorCode(error) === 'ENOENT' +} + +async function exists(path: string): Promise { try { - await probe(path) + await lstat(path) return true } catch (error) { if (isMissing(error)) return false @@ -20,35 +25,46 @@ async function exists(path: string, probe: typeof lstat = lstat): Promise { +async function directoryEntries(path: string): Promise { try { - const entries = await readdir(path, { withFileTypes: true }) - return entries.filter(entry => entry.isDirectory()).map(entry => join(path, entry.name)) + return await readdir(path, { withFileTypes: true }) } catch (error) { if (isMissing(error)) return [] throw error } } -/** 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) - } +async function childDirectories(path: string): Promise { + return (await directoryEntries(path)).filter(entry => entry.isDirectory()).map(entry => join(path, entry.name)) +} + +/** Lists symbolic links directly in a node_modules directory and in its `@scope` directories. */ +async function moduleLinks(modulesDirectory: string): Promise { + const links = (directory: string, entries: Dirent[]): string[] => + entries.filter(entry => entry.isSymbolicLink()).map(entry => join(directory, entry.name)) + const entries = await directoryEntries(modulesDirectory) + const result = links(modulesDirectory, entries) + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith('@')) continue + const scope = join(modulesDirectory, entry.name) + result.push(...links(scope, await directoryEntries(scope))) } - await scan(modulesDirectory, true) - return links + return result +} + +/** Resolves a link's canonical target, or undefined when the target cannot be reached. */ +async function linkTarget(link: string): Promise { + try { + return await realpath(link) + } catch (error) { + if (['ENOENT', 'ENOTDIR', 'ELOOP'].includes(String(errorCode(error)))) return undefined + throw error + } +} + +function isDescendant(root: string, path: string): boolean { + const relativePath = relative(root, path) + return relativePath !== '' && relativePath !== '..' && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath) } function repositoryPath(root: string, path: string): string { @@ -109,13 +125,6 @@ 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 @@ -136,6 +145,20 @@ export class RepositoryCleaner { } } + // pnpm install keeps links to deleted workspace packages. A link is residue when its + // target is unreachable or is removed by this plan; node_modules outside the repository is not scanned. + const removedPaths = (await Promise.all([...targets].map(linkTarget))).filter(path => path !== undefined) + for (const modulesDirectory of ['node_modules', 'node_modules/.pnpm/node_modules']) { + const canonicalModules = await linkTarget(join(this.root, modulesDirectory)) + if (canonicalModules === undefined || !isDescendant(canonicalRoot, canonicalModules)) continue + for (const link of await moduleLinks(join(this.root, modulesDirectory))) { + const target = await linkTarget(link) + if (target === undefined || removedPaths.some(path => path === target || isDescendant(path, target))) { + await this.addIfPresent(targets, link, canonicalRoot) + } + } + } + if (unsafeOrphans.length > 0) { throw new Error([ 'clean: refusing to remove package directories without package.json; unknown entries remain:', @@ -187,8 +210,7 @@ export class RepositoryCleaner { } private assertDescendant(root: string, path: string, displayPath: string): void { - const repositoryRelative = relative(root, path) - if (repositoryRelative === '' || repositoryRelative === '..' || repositoryRelative.startsWith(`..${sep}`) || isAbsolute(repositoryRelative)) { + if (!isDescendant(root, path)) { throw new Error(`clean: refusing deletion target outside repository: ${displayPath}`) } }