diff --git a/packages/context/agent-instructions/README.i18n.yaml b/packages/context/agent-instructions/README.i18n.yaml index 481606e8b5..1323a03a91 100644 --- a/packages/context/agent-instructions/README.i18n.yaml +++ b/packages/context/agent-instructions/README.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 packages/context/agent-instructions/README.md -README.md: d69a989a455512177798446e4a3e07b95b5f44d1 -README.zh.md: 96a456bd576cacb06382de4b8e34db4b5976327f +README.md: 114202cdd7cd4d2fd1d2c82330d4f373a3735b0d +README.zh.md: 029f87d3b53317b198c011829faf85b41e32c9b3 diff --git a/packages/context/agent-instructions/README.md b/packages/context/agent-instructions/README.md index d69a989a45..114202cdd7 100644 --- a/packages/context/agent-instructions/README.md +++ b/packages/context/agent-instructions/README.md @@ -35,6 +35,8 @@ The first request includes one durable baseline message with the user-global `$D The defaults suit a typical checkout: `.git` marks the project root, `AGENTS.md` and `CLAUDE.md` are the base candidates, and `AGENTS.local.md` and `CLAUDE.local.md` are additive local overlays. Only `maxBytes` is required — it caps the complete rendered baseline so each deployment chooses its prompt budget explicitly. +Root discovery climbs only when a marker probe confirms that the marker is absent. A permission or I/O failure stops discovery and surfaces the host or filesystem-provider error instead of selecting an ancestor project. + ```yaml - name: '@deepseek-ai/dsh-agent-instructions' config: diff --git a/packages/context/agent-instructions/README.zh.md b/packages/context/agent-instructions/README.zh.md index 96a456bd57..029f87d3b5 100644 --- a/packages/context/agent-instructions/README.zh.md +++ b/packages/context/agent-instructions/README.zh.md @@ -35,6 +35,8 @@ kind: "package-reference" 默认设置适合典型检出:`.git` 标记项目根目录,`AGENTS.md` 与 `CLAUDE.md` 是基础候选,`AGENTS.local.md` 与 `CLAUDE.local.md` 是叠加的本地 overlay。只有 `maxBytes` 必填——它限制完整渲染后的基线,让每个部署显式选择自己的提示词预算。 +只有确认项目根标记不存在时,项目根发现才会继续上溯。权限或 I/O 失败会停止发现,并返回 Host 或文件系统提供方的错误,而不会选择祖先项目。 + ```yaml - name: '@deepseek-ai/dsh-agent-instructions' config: diff --git a/packages/context/agent-instructions/src/files.ts b/packages/context/agent-instructions/src/files.ts index 01492e3e91..32388aeb86 100644 --- a/packages/context/agent-instructions/src/files.ts +++ b/packages/context/agent-instructions/src/files.ts @@ -95,6 +95,10 @@ function isMissingPathError(error: unknown): boolean { return error instanceof Error && 'code' in error && (error.code === 'ENOENT' || error.code === 'ENOTDIR') } +function isMissingProviderPathError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'FS_NOT_FOUND' +} + async function nodeStatFile(path: string, signal?: AbortSignal): Promise { try { signal?.throwIfAborted() @@ -147,11 +151,10 @@ async function existsAsMarker(path: string, fileSystem?: FileSystem, signal?: Ab try { const target = await fileSystem.resolve(path, signalOptions(signal)) return await fileSystem.stat(target, signal) !== undefined - } catch { + } catch (error: unknown) { signal?.throwIfAborted() - // TODO(root-marker-unavailable): preserve provider failure separately from - // absence and stop discovery; continuing upward can cross into an ancestor project. - return false + if (isMissingProviderPathError(error)) return false + throw error } } try { @@ -159,9 +162,10 @@ async function existsAsMarker(path: string, fileSystem?: FileSystem, signal?: Ab await stat(path) signal?.throwIfAborted() return true - } catch { + } catch (error: unknown) { signal?.throwIfAborted() - return false + if (isMissingPathError(error)) return false + throw error } } @@ -172,6 +176,7 @@ async function existsAsMarker(path: string, fileSystem?: FileSystem, signal?: Ab * @param fileSystem - optional provider used instead of host filesystem probes. * @param signal - cancellation for provider and host probes. * @returns the discovered project root, or `cwd` when no marker exists. + * @throws the original marker metadata error or cancellation reason when a probe is unavailable. */ export async function findProjectRoot( cwd: string, diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 68b1e5897d..c8c89a03b1 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -77,6 +77,7 @@ async function write(path: string, content: string): Promise { class RecordingFileSystem extends FileSystem { entries = new Map() + missingOnStat = new Set() throwOnStat = new Set() throwOnRead = new Set() omitSizes = new Set() @@ -105,6 +106,9 @@ class RecordingFileSystem extends FileSystem { override async stat(target: FsTarget, signal?: AbortSignal): Promise { if (signal !== undefined) this.signals.push(signal) signal?.throwIfAborted() + if (this.missingOnStat.has(target.targetKey)) { + throw Object.assign(new Error(`not found: ${target.displayPath}`), { code: 'FS_NOT_FOUND' }) + } if (this.throwOnStat.has(target.targetKey)) throw new Error(`stat failed: ${target.displayPath}`) const entry = this.entries.get(target.targetKey) if (entry === undefined) return undefined @@ -2073,6 +2077,30 @@ describe('workspace context request injection', () => { } }) + it('continues provider root discovery when a marker is confirmed absent', async () => { + const root = join(await tempRepo(), 'virtual-repo') + const cwd = join(root, 'pkg') + const home = join(await tempRepo(), 'virtual-home') + const ctx = new Context() + try { + await ctx.plugin(RecordingFileSystem) + const fs = ctx.fs as RecordingFileSystem + fs.missingOnStat.add(join(cwd, '.git')) + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'provider parent rule' }) + await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) + const agent = await stubAgent(cwd) + + await composeBaselinePrefix(ctx, agent) + + expect(derivedText(agent)).toContain('provider parent rule') + } finally { + await ctx.fiber.dispose() + await rm(dirname(root), { recursive: true, force: true }) + await rm(dirname(home), { recursive: true, force: true }) + } + }) + it('keeps the direct provider API usable without an operation signal', async () => { const root = resolve('/virtual/no-signal-repo') const home = resolve('/virtual/no-signal-home') @@ -2291,23 +2319,24 @@ describe('workspace context request injection', () => { } }) - it('treats ctx.fs marker lookup failures as absent root markers', async () => { + it('surfaces ctx.fs marker lookup failures instead of crossing into an ancestor project', async () => { const root = await tempRepo() + const cwd = join(root, 'pkg') const home = await tempRepo() try { - await mkdir(join(root, '.git'), { recursive: true }) - await write(join(root, 'AGENTS.md'), 'repo rule') const ctx = new Context() await ctx.plugin(RecordingFileSystem) const fs = ctx.fs as RecordingFileSystem - fs.throwOnStat.add(join(root, '.git')) - fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'repo rule' }) + fs.throwOnStat.add(join(cwd, '.git')) + fs.entries.set(join(root, '.git'), { type: 'directory' }) + fs.entries.set(join(root, 'AGENTS.md'), { type: 'file', content: 'ancestor rule must not load' }) await mountWorkspaceContextPlugin(ctx, { dshHome: home, maxBytes: 65536 }) - const agent = await stubAgent(root) + const agent = await stubAgent(cwd) - await composeBaselinePrefix(ctx, agent) + await expect(composeBaselinePrefix(ctx, agent)) + .rejects.toThrow(`stat failed: ${join(cwd, '.git')}`) - expect(derivedText(agent)).toContain('repo rule') + expectNoDerivedMessages(agent) } finally { await rm(root, { recursive: true, force: true }) await rm(home, { recursive: true, force: true }) @@ -2540,6 +2569,42 @@ describe('workspace context request injection', () => { await rm(home, { recursive: true, force: true }) } }) + + it('surfaces host marker metadata failures instead of crossing into an ancestor project', async () => { + const root = await tempRepo() + const home = await tempRepo() + try { + const cwd = join(root, 'pkg') + const markerPath = join(cwd, '.git') + const failure = Object.assign(new Error(`permission denied: ${markerPath}`), { + code: 'EACCES', + path: markerPath, + }) + await mkdir(join(root, '.git'), { recursive: true }) + await write(join(root, 'AGENTS.md'), 'ancestor rule must not load') + await mkdir(cwd, { recursive: true }) + vi.resetModules() + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + stat: async (path: string) => { + if (path === markerPath) throw failure + return actual.stat(path) + }, + } + }) + const isolated = await import('@deepseek-ai/dsh-agent-instructions') + + await expect(isolated.loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 })) + .rejects.toBe(failure) + } finally { + vi.doUnmock('node:fs/promises') + vi.resetModules() + await rm(root, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + } + }) }) describe('dynamic nested workspace context injection', () => {