474 lines
15 KiB
JavaScript
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)
|
|
}
|
|
})
|