refactor(fs): make dsh-file-context an event-gate plugin, not a method service

Invert the tool↔policy control flow per the file-context event-gate RFC.
dsh-tool-fs becomes the executor — it reads/writes/edits through ctx.fs
directly, owns read windowing, and dispatches fs/write-expectation /
fs/edit-expectation (single-slot waterfalls) plus a contained fs/observed
emit. dsh-file-context drops its ctx.fileContext service and becomes a pure
event-gate plugin (observed-state + read-before-edit + version-guarded
write/edit, decided on those events). The provider's version guard becomes
optional so ctx.fs alone is a complete unconstrained text-storage seam:
removing the policy plugin gracefully loses the policy instead of breaking
the tool at a service-injection boundary.
This commit is contained in:
Dudu-0223
2026-06-28 13:49:02 +08:00
parent d612ebaef1
commit 90dceea0e4
35 changed files with 1229 additions and 793 deletions
@@ -144,6 +144,26 @@ describe('writeText', () => {
.rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
})
it('unconditionally creates a new file with no expectation (bare provider)', async () => {
const target = await fs.resolve('new.txt')
const outcome = await fs.writeText(target, 'fresh')
expect(outcome.operation).toBe('create')
expect(await readFile(join(dir, 'new.txt'), 'utf8')).toBe('fresh')
})
it('unconditionally OVERWRITES an existing file with no expectation (bare provider)', async () => {
await writeFile(join(dir, 'a.txt'), 'old')
const target = await fs.resolve('a.txt')
const outcome = await fs.writeText(target, 'clobbered')
expect(outcome.operation).toBe('update')
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('clobbered')
})
it('rejects writing onto a directory even with no expectation', async () => {
const target = await fs.resolve('.')
await expect(fs.writeText(target, 'x')).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
})
it('releases per-target mutation locks after success and failure', async () => {
const target = await fs.resolve('a.txt')
await fs.writeText(target, 'created', { kind: 'createIfAbsent' })
@@ -173,6 +193,28 @@ describe('editText', () => {
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
})
it('unconditionally edits the current content with no expectation (bare provider)', async () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
const target = await fs.resolve('a.txt')
// No version guard: any current content is edited, regardless of version.
const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false })
expect(outcome.replacements).toBe(1)
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
})
it('reports a missing target as FS_STALE_VERSION even with no expectation (bare provider)', async () => {
const target = await fs.resolve('missing.txt')
await expect(fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: false }))
.rejects.toMatchObject({ code: 'FS_STALE_VERSION' })
})
it('still reports literal-match codes with no expectation (FS_EDIT_NOT_FOUND, unrelated to freshness)', async () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
const target = await fs.resolve('a.txt')
await expect(fs.editText(target, { oldString: 'absent', newString: 'x', replaceAll: false }))
.rejects.toMatchObject({ code: 'FS_EDIT_NOT_FOUND' })
})
it('rejects a deleted target as stale (before matching)', async () => {
await writeFile(join(dir, 'a.txt'), 'hello')
const target = await fs.resolve('a.txt')