mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
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:
1 parent
81ee28eebb
commit
94092349ab
2 files changed
+87
-49
No files matched your search
+30
-14
@@ -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
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user