claude-harness/dsh/tests/selfcheck.test.mjs

474 lines
15 KiB
JavaScript

import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { join } from 'node:path'
import test from 'node:test'
import { apply } from '../bundle/index.js'
import { collectSelfCheck } from '../bundle/lib/selfcheck.js'
import { checkSddWrite } from '../bundle/lib/gates.js'
import { registerCheckCommand } from '../bundle/command-check.js'
import { cleanup, git, makeRepo, write } from './helpers.mjs'
const FEATURES = ['guards', 'bootstrap', 'commands', 'workingDirectory']
/** A ctx-like with every service the bundle depends on. */
function fullCtx() {
return {
tools: { guard() {} },
on() {},
inject() {},
workingDirectory: { get() {} },
}
}
test('collectSelfCheck ok on full ctx-like', () => {
const report = collectSelfCheck(fullCtx())
assert.deepEqual(
report.map((entry) => entry.feature),
FEATURES,
)
for (const entry of report) {
assert.equal(entry.ok, true, `${entry.feature} ok`)
assert.equal(typeof entry.detail, 'string', `${entry.feature} detail is a string`)
assert.ok(entry.detail.length > 0, `${entry.feature} detail is non-empty`)
}
})
test('collectSelfCheck flags missing tools.guard', () => {
const ctx = fullCtx()
ctx.tools = {}
const report = collectSelfCheck(ctx)
const guards = report.find((entry) => entry.feature === 'guards')
assert.equal(guards.ok, false)
assert.match(guards.detail, /tools\.guard/)
for (const entry of report.filter((entry) => entry.feature !== 'guards')) {
assert.equal(entry.ok, true, `${entry.feature} stays ok`)
}
})
test('collectSelfCheck flags missing on()', () => {
const ctx = fullCtx()
ctx.on = undefined
const report = collectSelfCheck(ctx)
const bootstrap = report.find((entry) => entry.feature === 'bootstrap')
assert.equal(bootstrap.ok, false)
for (const entry of report.filter((entry) => entry.feature !== 'bootstrap')) {
assert.equal(entry.ok, true, `${entry.feature} stays ok`)
}
})
test('collectSelfCheck skips features disabled by config', () => {
const report = collectSelfCheck(fullCtx(), {
guards: false,
bootstrap: false,
roleCommand: false,
checkCommand: false,
})
assert.deepEqual(
report.map((entry) => entry.feature),
['workingDirectory'],
)
})
test('collectSelfCheck does not flag a disabled feature that is also missing', () => {
const ctx = fullCtx()
ctx.tools = {}
ctx.on = undefined
const report = collectSelfCheck(ctx, { guards: false, bootstrap: false })
assert.deepEqual(
report.map((entry) => entry.feature),
['commands', 'workingDirectory'],
)
assert.ok(report.every((entry) => entry.ok), 'nothing not-ok in the report')
const noInject = fullCtx()
noInject.inject = undefined
const commandsOff = collectSelfCheck(noInject, { roleCommand: false, checkCommand: false })
assert.ok(!commandsOff.some((entry) => entry.feature === 'commands'), 'commands skipped')
assert.ok(commandsOff.every((entry) => entry.ok), 'missing inject not flagged')
const roleOnly = collectSelfCheck(noInject, { roleCommand: false })
assert.ok(roleOnly.some((entry) => entry.feature === 'commands'), 'commands still checked')
assert.equal(
roleOnly.find((entry) => entry.feature === 'commands').ok,
false,
'checkCommand alone still needs ctx.inject',
)
})
test('registerCheckCommand builds report text', async () => {
let captured
const commands = {
register(def) {
captured = def
},
}
registerCheckCommand({ commands, collect: () => collectSelfCheck(fullCtx()) })
assert.equal(captured.name, 'dsh-harness-check')
const okResult = await captured.handler({})
assert.match(okResult.text, /guards: ok/)
assert.match(okResult.text, /all ok/)
const brokenCtx = fullCtx()
brokenCtx.tools = {}
registerCheckCommand({ commands, collect: () => collectSelfCheck(brokenCtx) })
const badResult = await captured.handler({})
assert.match(badResult.text, /guards: not-ok/)
assert.match(badResult.text, /problem/)
})
/** Logger fake that interpolates `%s` and records error lines. */
function recordingLogger(errors) {
return {
info() {},
warn() {},
error(fmt, ...args) {
const line = fmt.replace(/%s/g, () => String(args.shift() ?? ''))
errors.push(line)
},
}
}
test('apply does not throw when ctx.on and ctx.inject are missing', () => {
const calls = { guard: 0 }
const errors = []
const ctx = {
tools: { guard() { calls.guard += 1 } },
workingDirectory: { get() {} },
logger: recordingLogger(errors),
}
assert.doesNotThrow(() => apply(ctx))
assert.equal(calls.guard, 1, 'guards feature stays wired')
assert.ok(
errors.some((line) => line.includes('self-check: bootstrap not-ok')),
'bootstrap not-ok is logged',
)
assert.ok(
errors.some((line) => line.includes('self-check: commands not-ok')),
'commands not-ok is logged',
)
})
test('apply wires guards, bootstrap, and commands with a full ctx', () => {
const calls = { guard: 0, on: 0, inject: 0 }
const ctx = {
tools: { guard() { calls.guard += 1 } },
on() { calls.on += 1 },
inject() { calls.inject += 1 },
workingDirectory: { get() {} },
logger: { info() {}, warn() {}, error() {} },
}
assert.doesNotThrow(() => apply(ctx))
assert.equal(calls.guard, 1, 'guards wired')
assert.equal(calls.on, 1, 'bootstrap wired')
assert.equal(calls.inject, 2, 'commands wired (role/check + /approve)')
})
// ---------------------------------------------------------------------------
// Approval gates (Task 3): apply() wires the A/B/C gates after the hooks.
// ---------------------------------------------------------------------------
const PLAN_TEXT = [
'# Plan X',
'',
'**Spec:** `openspec/changes/demo/`',
'',
'## Task 1',
'',
'Do the work.',
'',
].join('\n')
/**
* A throwaway repository on a feature branch (the hooks must pass) with the
* change artifacts and one demo plan. `design` writes a design-approved
* approval.md; `planSha` additionally writes the plan section (default: the
* real sha256 of the fixture plan).
*/
function gateFixture({ design = false, planSha } = {}) {
const repo = makeRepo()
git(repo, ['checkout', '-b', 'feat/x'])
write(join(repo, 'openspec', 'changes', 'demo', 'proposal.md'), '# Proposal\n')
write(join(repo, 'openspec', 'changes', 'demo', 'design.md'), '# Design\n')
const planPath = join(repo, 'docs', 'superpowers', 'plans', 'plan-x.md')
write(planPath, PLAN_TEXT)
if (design || planSha !== undefined) {
const sections = []
if (design) {
sections.push('design:\n by: owner\n at: 2026-10-25T10:00:00.000Z')
}
if (planSha !== undefined) {
sections.push(`plan:\n by: owner\n at: 2026-10-25T11:00:00.000Z\n sha256: ${planSha}`)
}
write(join(repo, 'openspec', 'changes', 'demo', 'approval.md'), `${sections.join('\n\n')}\n`)
}
return { repo, planPath }
}
function planShaOf(planText) {
return createHash('sha256').update(planText).digest('hex')
}
/** apply() against a fake ctx: captured guard + inject, session cwd = repo. */
function gateHarness(repo, config = {}) {
const guardFns = []
const injected = []
const ctx = {
tools: { guard(fn) { guardFns.push(fn) } },
on() {},
inject(names, callback) { injected.push({ names, callback }) },
workingDirectory: { get() { return repo } },
get() { return undefined },
logger: { info() {}, warn() {}, error() {} },
}
apply(ctx, config)
return {
guard: (exec) => guardFns[0]({ agent: { session: 's1' }, ...exec }),
injected,
}
}
function call(name, args) {
return { name, arguments: args }
}
test('apply: guard denies plan write without design approval', () => {
const { repo } = gateFixture()
try {
const harness = gateHarness(repo)
const reason = harness.guard(call('write', {
file_path: join(repo, 'docs', 'superpowers', 'plans', 'plan-new.md'),
content: PLAN_TEXT,
}))
assert.match(reason ?? '', /BLOCKED \[approval-gates\]: design not approved; run \/approve demo design/)
} finally {
cleanup(repo)
}
})
test('apply: guard allows plan write after approval.md with design', () => {
const { repo } = gateFixture({ design: true })
try {
const harness = gateHarness(repo)
const reason = harness.guard(call('write', {
file_path: join(repo, 'docs', 'superpowers', 'plans', 'plan-new.md'),
content: PLAN_TEXT,
}))
assert.equal(reason, undefined)
} finally {
cleanup(repo)
}
})
test('apply: guard denies subagent dispatch referencing unapproved plan', () => {
const { repo } = gateFixture()
try {
const harness = gateHarness(repo)
const reason = harness.guard(call('subagent', {
prompt: 'Implement docs/superpowers/plans/plan-x.md Task 1.',
}))
assert.match(reason ?? '', /BLOCKED \[approval-gates\]: plan not approved; run \/approve demo plan/)
} finally {
cleanup(repo)
}
})
test('apply: guard allows dispatch after plan approval (sha match)', () => {
const { repo } = gateFixture({ design: true, planSha: planShaOf(PLAN_TEXT) })
try {
const harness = gateHarness(repo)
const reason = harness.guard(call('subagent', {
prompt: 'Implement docs/superpowers/plans/plan-x.md Task 1.',
}))
assert.equal(reason, undefined)
} finally {
cleanup(repo)
}
})
test('apply: bash heredoc into approval.md denied', () => {
const { repo } = gateFixture()
try {
const harness = gateHarness(repo)
const reason = harness.guard(call('bash', {
command: "cat > openspec/changes/demo/approval.md <<'EOF'\nby: me\nEOF\n",
}))
assert.match(reason ?? '', /BLOCKED \[approval-gates\]: approval\.md is written only by \/approve/)
} finally {
cleanup(repo)
}
})
test('apply: bash write under .superpowers/sdd/<plan>/ denied without approval', () => {
const { repo } = gateFixture()
try {
const harness = gateHarness(repo)
const reason = harness.guard(call('bash', {
command: 'mkdir -p .superpowers/sdd/plan-x && printf "x" > .superpowers/sdd/plan-x/progress.md',
}))
assert.match(reason ?? '', /BLOCKED \[approval-gates\]: plan not approved; run \/approve demo plan/)
} finally {
cleanup(repo)
}
})
test('apply: bash write under .superpowers/sdd/<plan>/ allowed with approval', () => {
const { repo } = gateFixture({ design: true, planSha: planShaOf(PLAN_TEXT) })
try {
const harness = gateHarness(repo)
const reason = harness.guard(call('bash', {
command: 'mkdir -p .superpowers/sdd/plan-x && printf "x" > .superpowers/sdd/plan-x/progress.md',
}))
assert.equal(reason, undefined)
} finally {
cleanup(repo)
}
})
test('gates: checkSddWrite sha-mismatch deny', () => {
const { repo } = gateFixture({ design: true, planSha: 'a'.repeat(64) })
try {
const result = checkSddWrite({
filePath: join(repo, '.superpowers', 'sdd', 'plan-x', 'progress.md'),
repoRoot: repo,
})
assert.equal(result.deny, 'plan changed after approval; re-approve')
} finally {
cleanup(repo)
}
})
test('gates: bare .superpowers/sdd/<name> dir ungated', () => {
const { repo } = gateFixture()
try {
const result = checkSddWrite({
filePath: join(repo, '.superpowers', 'sdd', 'plan-x'),
repoRoot: repo,
})
assert.equal(result.deny, undefined)
} finally {
cleanup(repo)
}
})
test('apply: approvalGates:false disables all gates', () => {
const { repo } = gateFixture()
try {
const harness = gateHarness(repo, { approvalGates: false })
assert.equal(
harness.guard(call('write', {
file_path: join(repo, 'docs', 'superpowers', 'plans', 'plan-new.md'),
content: PLAN_TEXT,
})),
undefined,
)
assert.equal(
harness.guard(call('subagent', {
prompt: 'Implement docs/superpowers/plans/plan-x.md Task 1.',
})),
undefined,
)
assert.equal(
harness.guard(call('bash', {
command: "cat > openspec/changes/demo/approval.md <<'EOF'\nby: me\nEOF\n",
})),
undefined,
)
} finally {
cleanup(repo)
}
})
test('apply: /approve registered when commands present; absent userQuestions still registers', () => {
const { repo } = gateFixture({ design: true })
try {
const harness = gateHarness(repo)
assert.equal(harness.injected.length, 2, 'role/check and /approve inject blocks ran')
// A cordis-like command context: declared services resolve, any other key
// (e.g. `agent`) throws — the wiring must not read it (T3.1).
const registered = []
const commandCtx = new Proxy(
{ commands: { register(definition) { registered.push(definition) } } },
{
get(target, prop) {
if (prop in target) return target[prop]
throw new Error(`unknown service "${String(prop)}"`)
},
},
)
for (const { callback } of harness.injected) {
assert.doesNotThrow(() => callback(commandCtx), 'the inject callback must not touch ctx.agent')
}
const approve = registered.find((definition) => definition.name === 'approve')
assert.ok(approve, '/approve is registered')
assert.equal(typeof approve.handler, 'function')
assert.equal(approve.input.hint, '<change-id> design|plan')
} finally {
cleanup(repo)
}
})
test('apply: /approve registers on approvalGates alone (role/check commands off)', () => {
const { repo } = gateFixture({ design: true })
try {
const harness = gateHarness(repo, { roleCommand: false, checkCommand: false })
assert.equal(harness.injected.length, 1, 'only the /approve inject block runs')
const registered = []
harness.injected[0].callback({
commands: { register(definition) { registered.push(definition) } },
})
assert.ok(
registered.some((definition) => definition.name === 'approve'),
'/approve is registered without role/check commands',
)
assert.ok(
!registered.some((definition) => definition.name === 'role'),
'/role is not registered',
)
} finally {
cleanup(repo)
}
})
test('apply: approvalGates:false leaves /approve unregistered', () => {
const { repo } = gateFixture()
try {
const harness = gateHarness(repo, { approvalGates: false })
const registered = []
for (const { callback } of harness.injected) {
callback({ commands: { register(definition) { registered.push(definition) } } })
}
assert.ok(
!registered.some((definition) => definition.name === 'approve'),
'/approve is not registered when approvalGates is off',
)
} finally {
cleanup(repo)
}
})
test('apply: bash targets prefer approval/plan denies before sdd denies', () => {
const { repo } = gateFixture()
try {
const harness = gateHarness(repo)
const reason = harness.guard(call('bash', {
command: 'printf "y" > .superpowers/sdd/plan-x/progress.md && printf "x" > openspec/changes/demo/approval.md',
}))
assert.match(reason ?? '', /BLOCKED \[approval-gates\]: approval\.md is written only by \/approve/)
} finally {
cleanup(repo)
}
})