From 601fb9d1952ea07a6bba070ad1887c2b0890136e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 01:11:08 +0800 Subject: [PATCH] fix(fs-search): address the second-round #1119 review - inline the collect() identity wrapper now that both streams use the seam's diagnostic-tail shape - resolve the packaged rg path lazily at the first call (memoized): @vscode/ripgrep resolves its platform package at module evaluation, so a static import turned a missing/corrupt platform package into a Loader composition failure instead of the documented per-call SEARCH_FAILED - classify synchronous spawn-creation throws (a NUL in argv, an abort racing the pre-check, a rejected resolution) into SEARCH_FAILED / SEARCH_ABORTED instead of leaking raw errors - correct the stderrMaxBytes contract: the stderr excerpt is embedded in SEARCH_* error messages, not hidden from the model - export virtualManifest and pin its three acceptance paths (prefix hit, pnpm-11 truncated-name content-scan fallback, both miss) with fixture unit tests Tests: rg-path.spec.ts (resolution failure + memoized rejection), tools.spec.ts spawn-creation classification, notices spec virtualManifest. --- ...26-08-01-packaged-ripgrep-search.i18n.yaml | 4 +- .../2026-08-01-packaged-ripgrep-search.md | 2 +- .../2026-08-01-packaged-ripgrep-search.zh.md | 2 +- docs/config-catalog.md | 4 +- packages/fs/tool-fs-search/src/index.ts | 3 +- packages/fs/tool-fs-search/src/search-core.ts | 70 ++++++++++++++----- .../fs/tool-fs-search/tests/rg-path.spec.ts | 37 ++++++++++ .../fs/tool-fs-search/tests/tools.spec.ts | 43 ++++++++++++ scripts/gen-third-party-notices.spec.ts | 57 ++++++++++++++- scripts/gen-third-party-notices.ts | 7 +- 10 files changed, 200 insertions(+), 29 deletions(-) create mode 100644 packages/fs/tool-fs-search/tests/rg-path.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml index 3647587fa5..f63372c8b4 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.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 .agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md -2026-08-01-packaged-ripgrep-search.md: 849cc0804a2081492297649ac8f13236e2ac60fe -2026-08-01-packaged-ripgrep-search.zh.md: 4381c3a8bca8e6bb9ab375ad352fec5a5ddb9f99 +2026-08-01-packaged-ripgrep-search.md: 7c515618a18b61bd90177a6fdf19bbd52e564209 +2026-08-01-packaged-ripgrep-search.zh.md: f2b1a12c737f772bff6a6c91c17f7453dbc89748 diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md index 849cc0804a..7c515618a1 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.md @@ -12,7 +12,7 @@ The `glob`/`grep` tools ran through the bash executor seam, which made a system ## Decision -`@deepseek-ai/dsh-tool-fs-search` now runs the PACKAGED ripgrep binary (`@vscode/ripgrep`, an npm dependency whose optional platform packages ship the binary) through the `ctx.subprocess` seam: `runRipgrep()` spawns `rgPath` with a plain argv vector prefixed by `--no-config`, collect-mode stdout/stderr, `graceMs`, and `exec.signal` forwarded. There is no shell layer, so the shell-quoting boundary is gone from execution; the `singleQuote` helper and its shell-spawning tests are deleted with it. The raw streams request the seam's diagnostic-tail collect shape (no spill files — the tool never reads a raw spill path; a lossy stdout read fails as `SEARCH_RAW_OUTPUT_OVERFLOW`). The terminate grace and the stderr tail budget are validated `Config` fields (`graceMs` default 3000, `stderrMaxBytes` default 64 KiB), no longer inherited from bash-local's config. Registration is unconditional — the load-time `command -v rg` probe and the conditional registration decision are deleted, and with them the "rg not found" warning. The package injects `tools`, `systemPrompt`, and `subprocess`. +`@deepseek-ai/dsh-tool-fs-search` now runs the PACKAGED ripgrep binary (`@vscode/ripgrep`, an npm dependency whose optional platform packages ship the binary) through the `ctx.subprocess` seam: `runRipgrep()` spawns `rgPath` with a plain argv vector prefixed by `--no-config`, collect-mode stdout/stderr, `graceMs`, and `exec.signal` forwarded. `rgPath` resolves lazily at the first call (memoized per process): `@vscode/ripgrep` resolves its platform package at module evaluation, so a static import would turn a missing or corrupt platform package (`--omit=optional`, partial install) into a Loader-composition failure — the load-time failure mode this change exists to remove. There is no shell layer, so the shell-quoting boundary is gone from execution; the `singleQuote` helper and its shell-spawning tests are deleted with it. The raw streams request the seam's diagnostic-tail collect shape (no spill files — the tool never reads a raw spill path; a lossy stdout read fails as `SEARCH_RAW_OUTPUT_OVERFLOW`). The terminate grace and the stderr tail budget are validated `Config` fields (`graceMs` default 3000, `stderrMaxBytes` default 64 KiB), no longer inherited from bash-local's config. Registration is unconditional — the load-time `command -v rg` probe and the conditional registration decision are deleted, and with them the "rg not found" warning. The package injects `tools`, `systemPrompt`, and `subprocess`. Exit semantics stay tool-owned: exit 0 is success with results, exit 1 is a successful empty search, anything else classifies into the existing `SEARCH_*` vocabulary (invalid pattern, launch failure, signal kill, raw-output overflow). Timeout is the cooperative tool-call budget attached to the tool definitions: `@deepseek-ai/dsh-timeout-policy` aborts `exec.signal`, the subprocess seam's terminate escalation provides the hard kill, and the tool reports `SEARCH_ABORTED`. The working directory is the session header cwd when present, else `process.cwd()` — there is no executor config to default through anymore, so the tool owns the fallback. diff --git a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md index 4381c3a8bc..f2b1a12c73 100644 --- a/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-01-packaged-ripgrep-search.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -`@deepseek-ai/dsh-tool-fs-search` 现在运行 PACKAGED(打包的)ripgrep 二进制(`@vscode/ripgrep`,一个 npm 依赖,其可选平台包随附二进制),经由 `ctx.subprocess` seam:`runRipgrep()` 以纯 argv 向量 spawn `rgPath`,向量前缀 `--no-config`,配以 collect 模式 stdout/stderr、`graceMs` 与转发的 `exec.signal`。不再有 shell 层,执行路径上的 shell 引号边界随之消失;`singleQuote` 工具与其 shell spawn 测试一并删除。原始流使用 seam 的诊断尾部 collect 形态(无 spill 文件——工具从不读取原始 spill 路径;lossy stdout 读取以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败)。终止宽限与 stderr 尾部预算成为经校验的 `Config` 字段(`graceMs` 默认 3000,`stderrMaxBytes` 默认 64 KiB),不再继承自 bash-local 的配置。注册变为无条件——加载期 `command -v rg` 探针与条件注册决策被删除,连同那条 "rg not found" 警告。本包注入 `tools`、`systemPrompt` 与 `subprocess`。 +`@deepseek-ai/dsh-tool-fs-search` 现在运行 PACKAGED(打包的)ripgrep 二进制(`@vscode/ripgrep`,一个 npm 依赖,其可选平台包随附二进制),经由 `ctx.subprocess` seam:`runRipgrep()` 以纯 argv 向量 spawn `rgPath`,向量前缀 `--no-config`,配以 collect 模式 stdout/stderr、`graceMs` 与转发的 `exec.signal`。`rgPath` 在首次调用时懒解析(进程内 memoize):`@vscode/ripgrep` 在模块求值阶段解析其平台包,静态导入会把平台包缺失/损坏(`--omit=optional`、安装不全)变成 Loader 组合加载失败——这正是本次改动要消除的加载期失败模式。不再有 shell 层,执行路径上的 shell 引号边界随之消失;`singleQuote` 工具与其 shell spawn 测试一并删除。原始流使用 seam 的诊断尾部 collect 形态(无 spill 文件——工具从不读取原始 spill 路径;lossy stdout 读取以 `SEARCH_RAW_OUTPUT_OVERFLOW` 失败)。终止宽限与 stderr 尾部预算成为经校验的 `Config` 字段(`graceMs` 默认 3000,`stderrMaxBytes` 默认 64 KiB),不再继承自 bash-local 的配置。注册变为无条件——加载期 `command -v rg` 探针与条件注册决策被删除,连同那条 "rg not found" 警告。本包注入 `tools`、`systemPrompt` 与 `subprocess`。 退出语义仍由工具拥有:退出码 0 为有结果的成功,1 为成功的空搜索,其余归入既有 `SEARCH_*` 词汇(无效模式、启动失败、信号杀死、原始输出溢出)。超时是挂在工具定义上的协作式工具调用预算:`@deepseek-ai/dsh-timeout-policy` 中止 `exec.signal`,subprocess seam 的终止升级提供硬终止,工具报告 `SEARCH_ABORTED`。工作目录为会话 header cwd(存在时),否则为 `process.cwd()`——不再有执行器配置可供默认化,因此回退由工具自己拥有。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a37e8da1c9..c568634985 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1735,14 +1735,14 @@ export interface Config { rawOutputMaxBytes?: number /** Terminate-escalation grace period (ms) for one search process, handed to the subprocess seam. */ graceMs?: number - /** Max bytes retained for one search's stderr diagnostic tail (never surfaced to the model). */ + /** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */ stderrMaxBytes?: number /** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */ timeoutMs?: number } ``` -Source: [`packages/fs/tool-fs-search/src/index.ts:71`](../packages/fs/tool-fs-search/src/index.ts) +Source: [`packages/fs/tool-fs-search/src/index.ts:72`](../packages/fs/tool-fs-search/src/index.ts) ## `@deepseek-ai/dsh-tool-goal` diff --git a/packages/fs/tool-fs-search/src/index.ts b/packages/fs/tool-fs-search/src/index.ts index 9a4042dde1..7f8e43cb73 100644 --- a/packages/fs/tool-fs-search/src/index.ts +++ b/packages/fs/tool-fs-search/src/index.ts @@ -55,6 +55,7 @@ export { SEARCH_TIMEOUT_MS, SearchError, previewLine, + resolveRgPath, runRipgrep, toWorkdirRelative, trySaveFormattedResult, @@ -83,7 +84,7 @@ export interface Config { rawOutputMaxBytes?: number /** Terminate-escalation grace period (ms) for one search process, handed to the subprocess seam. */ graceMs?: number - /** Max bytes retained for one search's stderr diagnostic tail (never surfaced to the model). */ + /** Max bytes retained for one search's stderr tail; the excerpt is embedded in `SEARCH_*` error messages, never shown on success. */ stderrMaxBytes?: number /** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */ timeoutMs?: number diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 9444b029cc..854c593190 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -21,11 +21,10 @@ import { isAbsolute, relative, sep } from 'node:path' import type { Context } from 'cordis' -import { rgPath } from '@vscode/ripgrep' import { HarnessError } from '@deepseek-ai/dsh-llm' import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention' import type { RetainedItems } from '@deepseek-ai/dsh-retention' -import type { SubprocessCollect, SubprocessOutcome, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessOutcome, SubprocessOutputRead, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' import type { ToolExecution } from '@deepseek-ai/dsh-tools' @@ -154,6 +153,26 @@ function completeStdout(toolName: string, stdout: SubprocessOutputRead, rawOutpu ) } +let rgPathPromise: Promise | undefined + +/** + * The packaged ripgrep binary path, resolved lazily once per process. + * + * `@vscode/ripgrep` resolves its platform package (`@vscode/ripgrep- + * -`) at module evaluation, so a static import would turn a missing or + * corrupt platform package (`pnpm install --omit=optional`, partial install) + * into a failure of the whole Loader composition. Resolving at the call + * boundary keeps that failure at the first search call as `SEARCH_FAILED` — + * the package's documented no-load-time-probe contract. + * + * @returns the packaged binary's absolute path; the memoized promise rejects + * when the platform package cannot be resolved. + */ +export function resolveRgPath(): Promise { + rgPathPromise ??= import('@vscode/ripgrep').then(module => module.rgPath) + return rgPathPromise +} + /** * Run the packaged ripgrep binary with a plain argv vector and return its * complete raw stdout. The working directory is the calling agent's session @@ -173,9 +192,12 @@ function completeStdout(toolName: string, stdout: SubprocessOutputRead, rawOutpu * success with zero results (`noMatches`), anything else throws a * {@link SearchError} (abort/timeout → `SEARCH_ABORTED`, invalid pattern → * `SEARCH_INVALID_PATTERN`, the rest → `SEARCH_FAILED` / - * `SEARCH_RAW_OUTPUT_OVERFLOW`). A spawn REJECTION — the seam's - * infrastructure failures — is translated into `SEARCH_FAILED` with the - * original as `cause`; a pre-aborted signal becomes `SEARCH_ABORTED`. + * `SEARCH_RAW_OUTPUT_OVERFLOW`). Both launch-time failure domains are + * classified: a synchronous throw at spawn CREATION (a NUL in argv, an abort + * racing the pre-check, a rejected `@vscode/ripgrep` resolution) and a + * rejection of `handle.done` (the seam's infrastructure failures) both become + * `SEARCH_FAILED` with the original as `cause` — an abort already observed by + * creation time becomes `SEARCH_ABORTED` instead. * * @param ctx - the plugin context; execution uses its `subprocess` service. * @param exec - the tool-execution context; supplies the session cwd and the abort signal. @@ -200,19 +222,31 @@ export async function runRipgrep( } const cwd = exec.agent?.session.header.cwd const workdir = cwd ?? process.cwd() - const collect = (maxBytes: number): SubprocessCollect => - ({ maxBytes }) - const handle = ctx.subprocess.spawn({ - argv: [rgPath, '--no-config', ...argv], - cwd: workdir, - stdio: { - stdin: 'ignore', - stdout: collect(rawOutputMaxBytes), - stderr: collect(stderrMaxBytes), - }, - graceMs, - signal: exec.signal, - } satisfies SubprocessSpawnSpec) + let handle: SubprocessHandle + try { + handle = ctx.subprocess.spawn({ + argv: [await resolveRgPath(), '--no-config', ...argv], + cwd: workdir, + stdio: { + stdin: 'ignore', + stdout: { maxBytes: rawOutputMaxBytes }, + stderr: { maxBytes: stderrMaxBytes }, + }, + graceMs, + signal: exec.signal, + } satisfies SubprocessSpawnSpec) + } catch (error: unknown) { + // Node's spawn() throws synchronously for a NUL in argv, and the local + // impl can throw synchronously when the signal aborts between the check + // above and this call (or when the platform-package resolution rejects). + // The static narrowing that proves this re-check "always false" cannot + // see AbortSignal state changes. + // oxlint-disable-next-line typescript/no-unnecessary-condition + if (exec.signal.aborted) { + throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED') + } + throw new SearchError(`${toolName} could not start its search command (ripgrep launch failed)`, 'SEARCH_FAILED', { cause: error }) + } let outcome: SubprocessOutcome try { outcome = await handle.done diff --git a/packages/fs/tool-fs-search/tests/rg-path.spec.ts b/packages/fs/tool-fs-search/tests/rg-path.spec.ts new file mode 100644 index 0000000000..52888a3453 --- /dev/null +++ b/packages/fs/tool-fs-search/tests/rg-path.spec.ts @@ -0,0 +1,37 @@ +/** + * Failure-path tests for the lazy packaged-ripgrep resolution. The success + * path (the real `@vscode/ripgrep` module) is exercised throughout + * tools.spec.ts; here the module is mocked to throw at evaluation, proving a + * missing or corrupt platform package (`--omit=optional`, partial install) + * surfaces as a per-call `SEARCH_FAILED` — not a composition-load failure. + */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import { resolveRgPath, runRipgrep } from '@deepseek-ai/dsh-tool-fs-search' + +// Any access to the mocked module's surface throws — the shape a missing +// platform package produces at module evaluation. +vi.mock('@vscode/ripgrep', () => new Proxy({}, { + get() { + throw new Error('platform package @vscode/ripgrep-win32-x64 is not installed') + }, +})) + +describe('lazy packaged-ripgrep resolution', () => { + it('fails the first search call with SEARCH_FAILED instead of failing module load', async () => { + // The resolution rejects before any spawn, so no subprocess service is needed. + const controller = new AbortController() + const exec = { signal: controller.signal, name: 'glob', callId: CallId('missing-platform-package') } as unknown as ToolExecution + + await expect(runRipgrep(new Context(), exec, 'glob', ['--files'], 1_000_000, 3_000, 64 * 1024)) + .rejects.toMatchObject({ name: 'SearchError', code: 'SEARCH_FAILED' }) + }) + + it('keeps failing every subsequent call (the resolution is memoized)', async () => { + await expect(resolveRgPath()).rejects.toThrow(/platform package/) + await expect(resolveRgPath()).rejects.toThrow(/platform package/) + }) +}) diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index 5c6713a7f0..a8a2498c60 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -32,6 +32,7 @@ import { presentGrepCall, presentGrepResult, previewLine, + resolveRgPath, runRipgrep, sampleAcrossTopLevel, toWorkdirRelative, @@ -468,6 +469,48 @@ describe('workdir derivation and signal forwarding', () => { expect(text(result)).toContain('could not start') }) + it('classifies a synchronous spawn-creation throw as SEARCH_FAILED', async () => { + // Node's spawn() throws synchronously for a NUL in argv, and the local + // impl can throw synchronously for other invalid specs. Creation-time + // failures must join the error vocabulary instead of escaping raw. + const { ctx, subprocess } = await setup() + subprocess.handler = () => { throw new Error('spawn ERR_INVALID_ARG_VALUE') } + + const result = await call(ctx, 'grep', { pattern: 'x' }) + + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } }) + expect(text(result)).toContain('could not start') + }) + + it('classifies a synchronous spawn-creation throw after an abort as SEARCH_ABORTED', async () => { + // The local impl can throw synchronously when the signal aborts between + // the pre-spawn check and the spawn call; no process was launched, so the + // abort is the reportable cause. + const { ctx, subprocess } = await setup() + const controller = new AbortController() + subprocess.handler = () => { + controller.abort('timeout') + throw new Error('aborted during spawn') + } + + const result = await call(ctx, 'glob', { pattern: '*' }, { signal: controller.signal }) + + expect(result.isError).toBe(true) + expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_ABORTED' } }) + expect(text(result)).toContain('aborted before completion') + }) + + it('resolves the packaged ripgrep path lazily, once per process', async () => { + // The module must not touch @vscode/ripgrep at load (a missing platform + // package would otherwise fail the whole composition), and repeated + // resolution reuses the first result. The resolution-failure path is + // pinned separately in rg-path.spec.ts. + await setup() + expect(await resolveRgPath()).toBe(rgPath) + expect(resolveRgPath()).toBe(resolveRgPath()) + }) + it('rejects when the subprocess implementation drops a requested collect stream', async () => { const { ctx, subprocess } = await setup() subprocess.dropReaders = true diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index 707c30ff70..f31cca6879 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -1,7 +1,8 @@ -import { readdirSync, readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { tmpdir } from 'node:os' import { describe, expect, it } from 'vitest' -import { collectPythonDependencies, isPermissive, type Manifest, manifestPatterns, parsePyprojectRequirements, parseVendoredRows, render, tierExternalDeps } from './gen-third-party-notices.ts' +import { collectPythonDependencies, isPermissive, type Manifest, manifestPatterns, parsePyprojectRequirements, parseVendoredRows, render, tierExternalDeps, virtualManifest } from './gen-third-party-notices.ts' const root = resolve(import.meta.dirname, '..') @@ -63,6 +64,56 @@ describe('tierExternalDeps', () => { }) }) +describe('virtualManifest', () => { + it('resolves a manifest from an ordinary prefix-matching store directory', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-notices-prefix-')) + try { + const name = '@scope/pkg' + const version = '1.0.0' + const store = join(root, 'store') + const manifestDir = join(store, `${name.replace('/', '+')}@${version}`, 'node_modules', name) + mkdirSync(manifestDir, { recursive: true }) + writeFileSync(join(manifestDir, 'package.json'), JSON.stringify({ name, version, license: 'MIT' })) + + expect(virtualManifest(store, name)).toMatchObject({ name, version, license: 'MIT' }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('falls back to a content scan when pnpm 11 truncates the store directory name', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-notices-truncated-')) + try { + const name = '@scope/pkg' + const version = '2.0.0' + const store = join(root, 'store') + // The truncated name no longer starts with `@scope+pkg@`, so only the + // whole-store content scan can find the package. + const manifestDir = join(store, '@scope+pkg_9f1c2d3e4a5b6c7d8e9f0a1b2c3d4e5f', 'node_modules', name) + mkdirSync(manifestDir, { recursive: true }) + writeFileSync(join(manifestDir, 'package.json'), JSON.stringify({ name, version, license: 'Apache-2.0' })) + + expect(virtualManifest(store, name)).toMatchObject({ name, version, license: 'Apache-2.0' }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('returns undefined when neither the prefix nor the content scan finds the package', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-notices-miss-')) + try { + const store = join(root, 'store') + const other = join(store, 'other-pkg@1.0.0', 'node_modules', 'other-pkg') + mkdirSync(other, { recursive: true }) + writeFileSync(join(other, 'package.json'), JSON.stringify({ name: 'other-pkg', version: '1.0.0' })) + + expect(virtualManifest(store, '@scope/missing')).toBeUndefined() + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) + describe('parseVendoredRows', () => { it('reads the committed vendor manifest table', () => { const rows = parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8')) diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index a4a1f4c343..6b790829d5 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -172,8 +172,13 @@ type VirtualManifest = Manifest & { license?: string; repository?: string | { ur * long names (a peer-suffixed name past the length limit becomes * `_`), so a content scan falls back over the whole store when * the prefix misses. + * + * @param virtual - the `.pnpm` virtual store directory to scan. + * @param name - the external package name, exactly as `node_modules` spells it. + * @returns the parsed manifest, or `undefined` when neither the prefix match + * nor the content scan finds the package's `package.json`. */ -function virtualManifest(virtual: string, name: string): VirtualManifest | undefined { +export function virtualManifest(virtual: string, name: string): VirtualManifest | undefined { const prefix = `${name.replace('/', '+')}@` const entry = readdirSync(virtual).find(dir => dir.startsWith(prefix)) if (entry !== undefined) {