fix(fs): address review — rename to dsh-fs-policy, fs/*-intent events, RFC currency, ENOTDIR
Rename per review naming decisions: - package dsh-file-context → dsh-fs-policy (dir, package name, plugin name, tsconfig refs, importers, type-equiv manifest, generated catalog + module-graph) - events fs/write-expectation → fs/write-intent, fs/edit-expectation → fs/edit-intent (fs/observed unchanged); type FsWriteExpectation → FsWriteIntent, "expectation" wording → "intent" throughout - exported FileContextExec → FsPolicyExec Make the implemented RFCs describe what shipped, not the superseded designs: the 2026-06-17 capability-seam + tool-schemas RFCs no longer place policy on ctx.fs or use full/partial-view authorization, and the fsspec RFC's ctx.fileContext service prose is rewritten to the fs/* event-gate reality (freshness-based auth). Sharpen docs/rfc/implemented/AGENTS.md: a rename is a fact to fix IN PLACE — the "new RFC" escape hatch is for macro decision reversals only, not renames. Code fixes from review: - fsio.ts resolveLocalTarget/probe translate ENOTDIR (a parent path segment is a file) into the structured FsError taxonomy instead of leaking a raw Node error; resolve reports FS_NOT_FOUND, probe reports absent. Regression tests proven to fail on the unfixed code. - tool-fs HMR test now asserts prompt sections (not just tool schemas) are withdrawn on disposal. - fs/observed is a plain (unguarded) ctx.emit: correct the fs-policy comment, filesystem.md, and tool-fs module doc that wrongly claimed the tool "contains" a throwing listener; a throw surfaces as the tool's isError result. - drop the false "loaded by the default product config" claim (no config wires the fs tools yet), the duplicate ctx.bash service-map row, the stale FileReadRequest catalog link-map entry, and the fs/fs README EOF blank line; correct the dsh-fs package.json description.
This commit is contained in:
@@ -6,7 +6,7 @@ The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepse
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
|
||||
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
|
||||
// ctx.fs is now the local backend; load @deepseek-ai/dsh-file-context for the
|
||||
// ctx.fs is now the local backend; load @deepseek-ai/dsh-fs-policy for the
|
||||
// freshness policy gate and @deepseek-ai/dsh-tool-fs to expose read/write/edit.
|
||||
```
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*
|
||||
* This is the PROVIDER layer: it hands back decoded whole-file text (validated
|
||||
* UTF-8, binary rejected) — never line windows or numbered lines, which are
|
||||
* model-facing read policy owned by `@deepseek-ai/dsh-file-context`. Large files
|
||||
* model-facing read policy owned by `@deepseek-ai/dsh-fs-policy`. Large files
|
||||
* stream their text in chunks so a huge file never has to be held whole in
|
||||
* memory; the binary/NUL sample and cross-chunk UTF-8 decoding stay here.
|
||||
*
|
||||
@@ -35,6 +35,16 @@ function isENOENT(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
|
||||
}
|
||||
|
||||
/**
|
||||
* A path component that is expected to be a directory is a regular file (e.g.
|
||||
* resolving `afile/child.txt` when `afile` is a file). Like `ENOENT`, the target
|
||||
* cannot exist — so the resolution/probe paths treat it as "absent" rather than
|
||||
* letting a raw Node error escape without the structured `FsError` taxonomy.
|
||||
*/
|
||||
function isENOTDIR(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOTDIR'
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'AbortError'
|
||||
}
|
||||
@@ -119,6 +129,10 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
|
||||
// Prefer the file's own realpath (resolves a symlinked file to its target).
|
||||
return { displayPath, targetKey: FsTargetKey(await realpath(displayPath)) }
|
||||
} catch (error: unknown) {
|
||||
// A path component is a file, not a directory (e.g. "afile/child.txt" where
|
||||
// "afile" is a regular file): the target can neither exist nor be created,
|
||||
// so surface the structured taxonomy instead of a raw Node ENOTDIR.
|
||||
if (isENOTDIR(error)) throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, 'FS_NOT_FOUND')
|
||||
/* v8 ignore next -- non-ENOENT realpath failure needs a permission/IO fault; ENOENT falls through to ancestor resolution. */
|
||||
if (!isENOENT(error)) throw error
|
||||
}
|
||||
@@ -150,8 +164,10 @@ export async function probe(absolutePath: string): Promise<PathInfo | null> {
|
||||
const type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other'
|
||||
return { version: versionOf(info), mode: info.mode & 0o777, type, size: info.size }
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next 2 -- a non-ENOENT stat failure needs a permission/IO fault; surface it. */
|
||||
if (!isENOENT(error)) throw error
|
||||
// ENOENT (no such file) and ENOTDIR (a parent segment is a file) both mean
|
||||
// the target is absent; any other stat failure is a real permission/IO fault.
|
||||
/* v8 ignore next -- a non-ENOENT/ENOTDIR stat failure needs a permission/IO fault; surface it. */
|
||||
if (!isENOENT(error) && !isENOTDIR(error)) throw error
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import type {
|
||||
FsEditRequest,
|
||||
FsInfo,
|
||||
FsTarget,
|
||||
FsWriteExpectation,
|
||||
FsWriteIntent,
|
||||
FsWriteOutcome,
|
||||
} from '@deepseek-ai/dsh-fs'
|
||||
import {
|
||||
@@ -120,7 +120,7 @@ export class LocalFileSystem extends FileSystem {
|
||||
override async writeText(
|
||||
target: FsTarget,
|
||||
content: string,
|
||||
expected?: FsWriteExpectation,
|
||||
expected?: FsWriteIntent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<FsWriteOutcome> {
|
||||
return this.withLock(target.targetKey, async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* file/streamed text reads, atomic guarded writes (createIfAbsent /
|
||||
* replaceIfVersion), version-guarded literal edits, concurrency races, symlink
|
||||
* identity, and HMR/disposal. Read WINDOWING is policy and lives in
|
||||
* `dsh-file-context`, so it is not exercised here.
|
||||
* `dsh-fs-policy`, so it is not exercised here.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Cordis-free tests for the raw local-filesystem I/O: path resolution, probe,
|
||||
* whole-file/streamed text reads, binary/UTF-8 rejection, atomic-write temp
|
||||
* safety, literal edit matching, and line-ending handling. Line WINDOWING is
|
||||
* policy and lives in `dsh-file-context`, so it is not tested here.
|
||||
* policy and lives in `dsh-fs-policy`, so it is not tested here.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
writeFileAtomic,
|
||||
} from '@deepseek-ai/dsh-fs-local'
|
||||
import type { LocalTarget } from '@deepseek-ai/dsh-fs-local'
|
||||
import { FsTargetKey } from '@deepseek-ai/dsh-fs'
|
||||
import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
let dir: string
|
||||
beforeEach(async () => {
|
||||
@@ -88,6 +88,16 @@ describe('resolveLocalTarget', () => {
|
||||
it('rejects a blank path', async () => {
|
||||
await expect(resolveLocalTarget(dir, ' ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('rejects a path whose ancestor is a file with a structured FsError (ENOTDIR)', async () => {
|
||||
// "afile" is a regular file, so "afile/child.txt" hits ENOTDIR on realpath;
|
||||
// the raw Node error must be translated into the FsError taxonomy so the tool
|
||||
// result keeps its { name, code } metadata.
|
||||
await writeFile(join(dir, 'afile'), 'i am a file')
|
||||
const err = await resolveLocalTarget(dir, 'afile/child.txt').then(() => undefined, (e: unknown) => e)
|
||||
expect(err).toBeInstanceOf(FsError)
|
||||
expect(err).toMatchObject({ code: 'FS_NOT_FOUND' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('probe', () => {
|
||||
@@ -128,6 +138,11 @@ describe('probe', () => {
|
||||
await new Promise<void>((resolve) => { server.close(() => { resolve() }) })
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null when an ancestor path segment is a file (ENOTDIR), not a raw throw', async () => {
|
||||
await writeFile(join(dir, 'afile'), 'i am a file')
|
||||
expect(await probe(join(dir, 'afile', 'child.txt'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readWholeText', () => {
|
||||
|
||||
Reference in New Issue
Block a user