fix(scripts): clean links into removed packages and skip external node_modules

Links whose target this clean removes, or whose target loops or passes through a file, are residue. A node_modules directory resolving outside the repository is not scanned.
This commit is contained in:
Turtle committed 2026-10-21 14:40:16 +08:00
1 parent 81ee28eebb
commit 94092349ab
2 files changed
+87 -49

No files matched your search

+30 -14
View File
@@ -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()
+57 -35
View File
@@ -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<boolean> {
function isMissing(error: unknown): boolean {
return errorCode(error) === 'ENOENT'
}
async function exists(path: string): Promise<boolean> {
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<boolea
}
}
async function childDirectories(path: string): Promise<string[]> {
async function directoryEntries(path: string): Promise<Dirent[]> {
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<string[]> {
const links: string[] = []
const scan = async (directory: string, scopes: boolean): Promise<void> => {
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<string[]> {
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<string[]> {
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<string | undefined> {
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}`)
}
}