Files
dsh-facet/test/scope.test.js
T
NotExpectedYet 28f20a8aac fix: show the settings card, and make an edit actually take effect
`mode` and `extraRoots` carried no `volatile()` marker, so the framework's
form projection dropped the whole entry from `settings.describe` and the plugin
had no card under Plugins at all. The marker is what creates the card.

It is also what turns each field into a reference rather than a value: the
resolved config hands `apply` a `{get(), [write]}` per volatile field, which is
how a settings write lands in a running plugin without re-applying it. So the
policy and the guard now read both fields at the point of use instead of
snapshotting them at mount, and `sectionOrder`/`contextOrder` stay unmarked
because a prompt section cannot be re-ordered without a remount.

A section is also registered once as a plain string, so the rule could not
follow a live `mode` without freezing at load. The mode-specific sentence moved
into the binding, which is a callback.
2026-09-26 07:13:33 +01:00

121 lines
5.4 KiB
JavaScript

import assert from 'node:assert/strict'
import { test } from 'node:test'
import {
displayPath,
facetContextText,
facetRuleText,
isWideningCall,
outsideReason,
pathArgumentsFor,
widenReason,
} from '../lib/scope.js'
test('pathArgumentsFor reads the path argument of every guarded tool', () => {
assert.deepEqual(pathArgumentsFor('read', { file_path: 'src/a.ts' }), ['src/a.ts'])
assert.deepEqual(pathArgumentsFor('write', { file_path: 'src/a.ts', content: 'x' }), ['src/a.ts'])
assert.deepEqual(pathArgumentsFor('edit', { file_path: 'src/a.ts' }), ['src/a.ts'])
assert.deepEqual(pathArgumentsFor('read_image', { file_path: 'a.png' }), ['a.png'])
assert.deepEqual(pathArgumentsFor('grep', { pattern: 'x', path: 'src' }), ['src'])
assert.deepEqual(pathArgumentsFor('glob', { pattern: '*.ts', path: 'src' }), ['src'])
assert.deepEqual(pathArgumentsFor('str_replace_editor', { path: 'src/a.ts' }), ['src/a.ts'])
assert.deepEqual(pathArgumentsFor('recall', { path: 'src', focus: 'src/controllers' }), [
'src',
'src/controllers',
])
})
test('pathArgumentsFor has no opinion where it cannot have one', () => {
// The deliberate hole: a shell command cannot be judged from argv.
assert.deepEqual(pathArgumentsFor('bash', { command: 'cat /etc/passwd' }), [])
// Unknown tools are not guessed at.
assert.deepEqual(pathArgumentsFor('mcp_something', { path: '/etc' }), [])
// An omitted path means the session directory, which is already in scope.
assert.deepEqual(pathArgumentsFor('grep', { pattern: 'x' }), [])
// Unvalidated JSON must never make the guard throw.
assert.deepEqual(pathArgumentsFor('read', null), [])
assert.deepEqual(pathArgumentsFor('read', 'nonsense'), [])
assert.deepEqual(pathArgumentsFor('read', { file_path: 42 }), [])
assert.deepEqual(pathArgumentsFor('read', { file_path: ' ' }), [])
assert.deepEqual(pathArgumentsFor('grep', { path: { nested: '/etc' } }), [])
})
test('isWideningCall is true only for the one act that needs the user', () => {
assert.equal(isWideningCall('facet', { action: 'add', paths: ['x'] }), true)
// Narrowing and reading cannot show the agent anything new.
assert.equal(isWideningCall('facet', { action: 'remove', paths: ['x'] }), false)
assert.equal(isWideningCall('facet', { action: 'reset' }), false)
assert.equal(isWideningCall('facet', { action: 'status' }), false)
assert.equal(isWideningCall('read', { action: 'add' }), false)
assert.equal(isWideningCall('facet', null), false)
})
test('facetRuleText states the rule and never the binding', () => {
const rule = facetRuleText()
// Mode-neutral by construction. A prompt section is registered once as a plain
// string, and a `volatile()` edit updates the config reference without
// re-applying the plugin — so a `mode` interpolated here would freeze at load
// while the guard went on enforcing the new one. The mode-specific sentence
// lives in the binding, which is a callback.
assert.match(rule, /Work inside the facet\./)
assert.doesNotMatch(rule, /refused|put to the user as a question/)
assert.match(rule, /Never widen the scope on your own initiative/)
// The rule must not name a path: a changing system prompt invalidates the
// prompt cache for the whole conversation.
assert.doesNotMatch(rule, /\/|src\//)
assert.equal(facetContextText({ cwd: '/a', root: '/a/b', extras: [], mode: 'off' }), '')
})
test('facetContextText carries the binding that does change', () => {
const ask = facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' })
assert.equal(
ask,
'facet: /w/src/room\n' +
'outside the facet: put to the user as a question — say in one line what you need and why, then record it with `facet action=add` so the rest of the session does not have to ask again',
)
const deny = facetContextText({ cwd: '/w', root: '/w/src/room', extras: ['/w/src/shared'], mode: 'deny' })
assert.equal(
deny,
'facet: /w/src/room\n' +
'granted outside it: src/shared\n' +
'outside the facet: refused — `facet action=add` asks the user to grant a path, and is the only way to widen the scope',
)
// The binding is the only half that can follow a live edit, so the two modes
// have to read differently here — they no longer differ in the rule.
assert.notEqual(ask, deny)
// Unscoped sessions contribute nothing rather than an empty heading.
assert.equal(facetContextText({ cwd: '/w', root: undefined, extras: [], mode: 'ask' }), '')
assert.equal(facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'off' }), '')
})
test('displayPath prefers the shortest unambiguous form', () => {
assert.equal(displayPath('/w', '/w/src/a.ts'), 'src/a.ts')
assert.equal(displayPath('/w', '/w'), '.')
assert.equal(displayPath('/w', '/other/a.ts'), '/other/a.ts')
assert.equal(displayPath('', '/w/a.ts'), '/w/a.ts')
})
test('outsideReason names the path, the boundary and the way forward', () => {
const reason = outsideReason({
cwd: '/w/src/controllers/room',
path: '/w/src/shared/types.ts',
root: '/w/src/controllers/room',
mode: 'ask',
})
assert.match(reason, /src\/shared\/types\.ts/)
assert.match(reason, /src\/controllers\/room/)
// A denial that does not say how to proceed is one the agent routes around.
assert.match(reason, /facet action=add/)
})
test('widenReason survives an add with no usable paths', () => {
assert.match(widenReason(['src/shared']), /src\/shared/)
assert.match(widenReason([]), /nothing/)
})