Files
deepseek-harness/packages/fs/fs-policy/tests/policy.spec.ts
Tianyi Cui 30c1863755 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.
2026-07-02 03:12:38 +08:00

193 lines
8.4 KiB
TypeScript

/**
* Tests for the fs-policy PLUGIN: it registers no service, only the
* three `fs/*` listeners. We dispatch those events directly (the unbound
* waterfalls the tool would dispatch, and the `fs/observed` emit) and assert the
* decisions: createIfAbsent vs replaceIfVersion, FS_NOT_OBSERVED for an unread
* edit, observed-state-as-prior-observation (read/write/edit all record),
* multi-owner isolation, single-slot first-wins, and disposal/HMR release.
*
* No `ctx.fs` provider is needed — the plugin does no filesystem I/O; it only
* decides intents and records versions on its own WeakMap.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type { FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import type { FsPolicyExec } from '@deepseek-ai/dsh-fs-policy'
function target(path: string): FsTarget {
return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path }
}
const ownerExec = (session: object): FsPolicyExec => ({ agent: { session } })
/** Dispatch the write-intent waterfall with the bare default thunk. */
function writeIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise<FsWriteIntent | undefined> {
return ctx.waterfall('fs/write-intent', t, actor, () => undefined)
}
/** Dispatch the edit-intent waterfall with the bare default thunk. */
function editIntent(ctx: Context, t: FsTarget, actor: object | undefined): Promise<{ version: FsVersion } | undefined> {
return ctx.waterfall('fs/edit-intent', t, actor, () => undefined)
}
async function setup() {
const ctx = new Context()
const fiber = await ctx.plugin(FsPolicy)
return { ctx, fiber }
}
describe('registration / disposal', () => {
it('registers no service surface (it is a plugin, not ctx.fsPolicy)', async () => {
const { ctx } = await setup()
expect((ctx as Context & { fsPolicy?: unknown }).fsPolicy).toBeUndefined()
})
it('mounts with no inject (reads no services)', async () => {
// It mounts immediately even with nothing else in the context.
const ctx = new Context()
await ctx.plugin(FsPolicy)
// The listener is live: an unobserved write decides createIfAbsent.
expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' })
})
})
describe('write-intent decision', () => {
it('an unobserved target decides createIfAbsent', async () => {
const { ctx } = await setup()
expect(await writeIntent(ctx, target('a.txt'), ownerExec({}))).toEqual({ kind: 'createIfAbsent' })
})
it('a no-owner actor decides createIfAbsent', async () => {
const { ctx } = await setup()
expect(await writeIntent(ctx, target('a.txt'), undefined)).toEqual({ kind: 'createIfAbsent' })
expect(await writeIntent(ctx, target('a.txt'), {})).toEqual({ kind: 'createIfAbsent' })
})
it('an observed target decides replaceIfVersion at the observed version', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v7'), exec)
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v7' })
})
})
describe('edit-intent decision', () => {
it('rejects an unread edit with FS_NOT_OBSERVED', async () => {
const { ctx } = await setup()
await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('rejects an edit with no owner (cannot prove prior observation)', async () => {
const { ctx } = await setup()
await expect(editIntent(ctx, target('a.txt'), undefined)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('returns the observed version as the CAS basis after an observation', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v3'), exec)
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v3' })
})
})
describe('observed-state is the prior-observation record', () => {
it('a read observation authorizes an in-place write at that version', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec) // a read
expect(await writeIntent(ctx, target('a.txt'), exec)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
})
it('a write/edit observation refreshes the basis, so the next edit needs no re-read', async () => {
const { ctx } = await setup()
const exec = ownerExec({})
// A create records v1; the follow-up edit guards against v1 with no read.
ctx.emit('fs/observed', target('a.txt'), FsVersion('v1'), exec)
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v1' })
// The edit records v2; a second edit guards against v2.
ctx.emit('fs/observed', target('a.txt'), FsVersion('v2'), exec)
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v2' })
})
it('a no-owner observation records nothing', async () => {
const { ctx } = await setup()
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), undefined)
// Still unobserved for any owner.
await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
})
describe('multi-owner isolation', () => {
it('owner A observing does not grant owner B edit authority', async () => {
const { ctx } = await setup()
const a = ownerExec({})
const b = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a)
await expect(editIntent(ctx, target('a.txt'), b)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
expect(await editIntent(ctx, target('a.txt'), a)).toEqual({ version: 'v0' })
})
it('each owner records its own observed version independently', async () => {
const { ctx } = await setup()
const a = ownerExec({})
const b = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), a) // A observed v0
// B never observed → createIfAbsent; A still holds v0 → replaceIfVersion.
expect(await writeIntent(ctx, target('a.txt'), b)).toEqual({ kind: 'createIfAbsent' })
expect(await writeIntent(ctx, target('a.txt'), a)).toEqual({ kind: 'replaceIfVersion', version: 'v0' })
})
})
describe('single-slot, first-wins', () => {
it('fully decides the slot without calling next() (the bare default is unreached)', async () => {
const { ctx } = await setup()
let defaultRan = false
const intent = await ctx.waterfall('fs/write-intent', target('a.txt'), ownerExec({}), () => {
defaultRan = true
return undefined
})
expect(intent).toEqual({ kind: 'createIfAbsent' })
expect(defaultRan).toBe(false)
})
it('a SECOND decider registered AFTER fs-policy is not reached (first-wins short-circuit)', async () => {
const { ctx } = await setup()
let secondRan = false
// Registered after fs-policy, so it dispatches second; fs-policy does
// not call next(), so this never runs. (A decider registered BEFORE — or with
// prepend — would instead win: first-wins is by convention, not enforced.)
ctx.on('fs/edit-intent', () => {
secondRan = true
return Promise.resolve(undefined)
})
const exec = ownerExec({})
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec)
await editIntent(ctx, target('a.txt'), exec)
expect(secondRan).toBe(false)
})
})
describe('disposal releases recorded state (HMR safety)', () => {
it('a fresh plugin after disposal starts with no inherited state', async () => {
const ctx = new Context()
const exec = ownerExec({})
const fiber = await ctx.plugin(FsPolicy)
ctx.emit('fs/observed', target('a.txt'), FsVersion('v0'), exec)
expect(await editIntent(ctx, target('a.txt'), exec)).toEqual({ version: 'v0' })
await fiber.dispose()
await ctx.plugin(FsPolicy)
// Same owner object, but state was released on disposal.
await expect(editIntent(ctx, target('a.txt'), exec)).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' })
})
it('no listeners remain after disposal (the gate no longer decides)', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(FsPolicy)
await fiber.dispose()
// With no listener, the waterfall falls through to the bare default.
expect(await writeIntent(ctx, target('a.txt'), ownerExec({}))).toBeUndefined()
})
})