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

469 lines
15 KiB
JavaScript

import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import test from 'node:test'
import {
checkApprovalWrite,
checkBashTargets,
checkDispatch,
checkPlanWrite,
checkSddWrite,
extractSpec,
hashPlan,
parseApproval,
planApproved,
resolveChangeFromSpec,
resolvePlanFromSdd,
serializeApproval,
} from '../bundle/lib/gates.js'
import { cleanup, tempDir, write } from './helpers.mjs'
const PLAN_TEXT = [
'# Plan X',
'',
'**Spec:** `openspec/changes/demo/`',
'',
'## Task 1',
'',
'Do the work.',
'',
].join('\n')
const DESIGN = { by: 'aep', at: '2026-10-25T10:00:00+04:00' }
const PLAN = { by: 'aep', at: '2026-10-25T11:30:00+04:00' }
/** A throwaway repository with the plan/change artifacts the gates read. */
function repoFixture() {
const tmp = tempDir('dsh-gates-')
const repo = join(tmp, 'repo')
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)
return { tmp, repo, planPath }
}
function approvalPath(repo) {
return join(repo, 'openspec', 'changes', 'demo', 'approval.md')
}
/** Independent sha256, so the tests do not trust the implementation's own io. */
function shaOf(path) {
return createHash('sha256').update(readFileSync(path)).digest('hex')
}
/**
* Write approval.md. `design` false omits the design section; leaving `planSha`
* undefined omits the plan section; `null` writes an empty plan section.
*/
function writeApproval(repo, { design = true, planSha } = {}) {
const sections = []
if (design) {
sections.push(['design:', [` by: ${DESIGN.by}`, ` at: ${DESIGN.at}`]])
}
if (planSha !== undefined) {
const lines = [` by: ${PLAN.by}`, ` at: ${PLAN.at}`]
if (planSha !== null) lines.push(` sha256: ${planSha}`)
sections.push(['plan:', lines])
}
const text = `${sections.map(([head, lines]) => [head, ...lines].join('\n')).join('\n\n')}\n`
write(approvalPath(repo), text)
return text
}
test('extractSpec: backticks and trailing slash normalize', () => {
assert.equal(extractSpec('**Spec:** `openspec/changes/demo/`'), 'openspec/changes/demo')
assert.equal(
extractSpec(
'intro\n\n**Spec:** `openspec/changes/demo/` — [proposal.md](openspec/changes/demo/proposal.md)\n',
),
'openspec/changes/demo',
)
assert.equal(extractSpec('**Spec:** openspec/changes/demo/proposal.md'), 'openspec/changes/demo')
assert.equal(extractSpec('no spec line here'), null)
assert.equal(extractSpec(null), null)
})
test('checkPlanWrite: no Spec -> deny', () => {
const { tmp, repo } = repoFixture()
try {
const result = checkPlanWrite({
filePath: join(repo, 'docs', 'superpowers', 'plans', 'plan-new.md'),
content: '# Plan\n\nNo spec reference.\n',
repoRoot: repo,
})
assert.equal(result.deny, 'plan must carry **Spec:** pointing to openspec/changes/<id>')
} finally {
cleanup(tmp)
}
})
test('checkPlanWrite: legacy docs/superpowers/specs -> deny', () => {
const { tmp, repo } = repoFixture()
try {
writeApproval(repo)
const result = checkPlanWrite({
filePath: join(repo, 'docs', 'superpowers', 'plans', 'plan-legacy.md'),
content: '# Plan\n\n**Spec:** docs/superpowers/specs/old/spec.md\n',
repoRoot: repo,
})
assert.equal(result.deny, 'plan must carry **Spec:** pointing to openspec/changes/<id>')
} finally {
cleanup(tmp)
}
})
test('checkPlanWrite: design approved -> ok', () => {
const { tmp, repo, planPath } = repoFixture()
try {
writeApproval(repo)
const result = checkPlanWrite({ filePath: planPath, content: PLAN_TEXT, repoRoot: repo })
assert.equal(result.deny, undefined)
} finally {
cleanup(tmp)
}
})
test('checkPlanWrite: design not approved -> deny with /approve hint', () => {
const { tmp, repo } = repoFixture()
try {
const filePath = join(repo, 'docs', 'superpowers', 'plans', 'plan-new.md')
const missing = checkPlanWrite({ filePath, content: PLAN_TEXT, repoRoot: repo })
assert.equal(missing.deny, 'design not approved; run /approve demo design')
writeApproval(repo, { design: false, planSha: 'deadbeef' })
const planOnly = checkPlanWrite({ filePath, content: PLAN_TEXT, repoRoot: repo })
assert.equal(planOnly.deny, 'design not approved; run /approve demo design')
} finally {
cleanup(tmp)
}
})
test('checkPlanWrite: files outside plans/ are not gated', () => {
const { tmp, repo } = repoFixture()
try {
const result = checkPlanWrite({
filePath: join(repo, 'openspec', 'changes', 'demo', 'proposal.md'),
content: '# Proposal\n',
repoRoot: repo,
})
assert.equal(result.deny, undefined)
} finally {
cleanup(tmp)
}
})
test('checkApprovalWrite: approval.md via tool -> deny', () => {
const { tmp, repo } = repoFixture()
try {
const absolute = checkApprovalWrite({ filePath: approvalPath(repo), repoRoot: repo })
assert.equal(absolute.deny, 'approval.md is written only by /approve')
const relative = checkApprovalWrite({
filePath: 'openspec/changes/demo/approval.md',
repoRoot: repo,
})
assert.equal(relative.deny, 'approval.md is written only by /approve')
const other = checkApprovalWrite({
filePath: join(repo, 'openspec', 'changes', 'demo', 'proposal.md'),
repoRoot: repo,
})
assert.equal(other.deny, undefined)
} finally {
cleanup(tmp)
}
})
test('checkDispatch: prompt plan ref, plan approved -> ok', () => {
const { tmp, repo, planPath } = repoFixture()
try {
writeApproval(repo, { planSha: shaOf(planPath) })
const result = checkDispatch({
prompt: `Implement task 1. Read ${planPath} before you start.`,
repoRoot: repo,
})
assert.equal(result.deny, undefined)
} finally {
cleanup(tmp)
}
})
test('checkDispatch: no plan section -> deny', () => {
const { tmp, repo, planPath } = repoFixture()
try {
writeApproval(repo)
const result = checkDispatch({ prompt: `Read ${planPath}.`, repoRoot: repo })
assert.equal(result.deny, 'plan not approved; run /approve demo plan')
} finally {
cleanup(tmp)
}
})
test('checkDispatch: sha256 mismatch after edit -> deny re-approve', () => {
const { tmp, repo, planPath } = repoFixture()
try {
writeApproval(repo, { planSha: shaOf(planPath) })
write(planPath, `${PLAN_TEXT}\nadded after approval\n`)
const result = checkDispatch({ prompt: `Read ${planPath}.`, repoRoot: repo })
assert.equal(result.deny, 'plan changed after approval; re-approve')
} finally {
cleanup(tmp)
}
})
test('checkDispatch: workflow script with plan ref -> deny/ok same as subagent', () => {
const { tmp, repo, planPath } = repoFixture()
try {
writeApproval(repo, { planSha: shaOf(planPath) })
const direct = checkDispatch({
prompt: 'Run the workflow.',
script: `const plan = ${JSON.stringify(planPath)}\n`,
repoRoot: repo,
})
assert.equal(direct.deny, undefined)
writeApproval(repo)
const sdd = checkDispatch({
prompt: 'Run the workflow.',
script: 'cat .superpowers/sdd/plan-x/progress.md',
repoRoot: repo,
})
assert.equal(sdd.deny, 'plan not approved; run /approve demo plan')
} finally {
cleanup(tmp)
}
})
test('checkDispatch: no plan reference -> ok', () => {
const { tmp, repo } = repoFixture()
try {
const result = checkDispatch({ prompt: 'Answer the question about the weather.', repoRoot: repo })
assert.equal(result.deny, undefined)
assert.equal(checkDispatch({ prompt: '', script: '', repoRoot: repo }).deny, undefined)
} finally {
cleanup(tmp)
}
})
test('resolveChangeFromSpec: only openspec/changes resolve', () => {
assert.equal(resolveChangeFromSpec('openspec/changes/demo'), 'demo')
assert.equal(resolveChangeFromSpec('openspec/changes/demo/proposal.md'), 'demo')
assert.equal(resolveChangeFromSpec('docs/superpowers/specs/demo/spec.md'), null)
assert.equal(resolveChangeFromSpec('openspec/specs/hook-guards/spec.md'), null)
assert.equal(resolveChangeFromSpec(null), null)
})
test('resolvePlanFromSdd: .superpowers/sdd -> existing plan file', () => {
const { tmp, repo, planPath } = repoFixture()
try {
assert.equal(resolvePlanFromSdd('.superpowers/sdd/plan-x/progress.md', repo), planPath)
assert.equal(
resolvePlanFromSdd('/abs/repo/.superpowers/sdd/plan-x/briefs/task-1.md', repo),
planPath,
)
assert.equal(resolvePlanFromSdd('.superpowers/sdd/missing/progress.md', repo), null)
assert.equal(resolvePlanFromSdd('docs/superpowers/plans/plan-x.md', repo), null)
} finally {
cleanup(tmp)
}
})
test('planApproved: happy / missing / mismatch', () => {
const { tmp, repo, planPath } = repoFixture()
try {
const approved = {
design: DESIGN,
plan: { ...PLAN, sha256: shaOf(planPath) },
}
assert.equal(hashPlan(planPath), shaOf(planPath))
assert.equal(planApproved(approved, planPath).ok, true)
assert.deepEqual(planApproved({}, planPath), { ok: false, reason: 'no plan approval' })
assert.deepEqual(planApproved({ design: DESIGN }, planPath), {
ok: false,
reason: 'no plan approval',
})
assert.deepEqual(
planApproved({ plan: { ...PLAN, sha256: 'a'.repeat(64) } }, planPath),
{ ok: false, reason: 'plan changed after approval' },
)
} finally {
cleanup(tmp)
}
})
test('serialize/parse roundtrip preserves sections', () => {
const approval = { design: DESIGN, plan: { ...PLAN, sha256: 'b'.repeat(64) } }
const text = serializeApproval(approval)
assert.equal(
text,
[
'design:',
` by: ${DESIGN.by}`,
` at: ${DESIGN.at}`,
'',
'plan:',
` by: ${PLAN.by}`,
` at: ${PLAN.at}`,
` sha256: ${'b'.repeat(64)}`,
'',
].join('\n'),
)
assert.deepEqual(parseApproval(text), approval)
assert.equal(serializeApproval(parseApproval(text)), text)
assert.doesNotMatch(text, /[ \t]+\n/)
const designOnly = serializeApproval({ design: DESIGN })
assert.doesNotMatch(designOnly, /plan:/)
assert.deepEqual(parseApproval(designOnly), { design: DESIGN })
})
test('parseApproval tolerates malformed and missing input', () => {
assert.deepEqual(parseApproval(null), {})
assert.deepEqual(parseApproval(undefined), {})
assert.deepEqual(parseApproval(''), {})
assert.deepEqual(parseApproval('design:\n by: aep\n at: x\n'), {
design: { by: 'aep', at: 'x' },
})
assert.deepEqual(parseApproval('design:\n\nplan:\n sha256: abc\n'), {
design: {},
plan: { sha256: 'abc' },
})
assert.deepEqual(parseApproval('garbage [not yaml\n stray: value\n'), {})
assert.deepEqual(parseApproval('design:\r\n by: aep\r\n'), { design: { by: 'aep' } })
})
test('checkBashTargets: heredoc-equivalent targets deny', () => {
const { tmp, repo, planPath } = repoFixture()
try {
writeApproval(repo)
assert.equal(checkBashTargets([join(repo, 'a.txt')], repo).deny, undefined)
assert.equal(checkBashTargets([], repo).deny, undefined)
assert.equal(checkBashTargets(undefined, repo).deny, undefined)
assert.equal(
checkBashTargets([approvalPath(repo)], repo).deny,
'approval.md is written only by /approve',
)
assert.equal(
checkBashTargets([planPath], repo).deny,
'plan must carry **Spec:** pointing to openspec/changes/<id>',
)
assert.equal(
checkBashTargets(['openspec/changes/demo/approval.md'], repo).deny,
'approval.md is written only by /approve',
)
// First deny wins, in target order.
assert.equal(
checkBashTargets([approvalPath(repo), planPath], repo).deny,
'approval.md is written only by /approve',
)
assert.equal(
checkBashTargets([planPath, approvalPath(repo)], repo).deny,
'plan must carry **Spec:** pointing to openspec/changes/<id>',
)
} finally {
cleanup(tmp)
}
})
test('checkSddWrite: plan not approved -> deny', () => {
const { tmp, repo } = repoFixture()
try {
writeApproval(repo)
const result = checkSddWrite({
filePath: join(repo, '.superpowers', 'sdd', 'plan-x', 'progress.md'),
repoRoot: repo,
})
assert.equal(result.deny, 'plan not approved; run /approve demo plan')
} finally {
cleanup(tmp)
}
})
test('checkSddWrite: approved plan -> ok, files elsewhere are not gated', () => {
const { tmp, repo, planPath } = repoFixture()
try {
writeApproval(repo, { planSha: shaOf(planPath) })
const ledger = checkSddWrite({
filePath: join(repo, '.superpowers', 'sdd', 'plan-x', 'briefs', 'task-1.md'),
repoRoot: repo,
})
assert.equal(ledger.deny, undefined)
assert.equal(
checkSddWrite({ filePath: join(repo, 'README.md'), repoRoot: repo }).deny,
undefined,
)
assert.equal(
checkSddWrite({
filePath: join(repo, 'docs', 'superpowers', 'plans', 'plan-x.md'),
repoRoot: repo,
}).deny,
undefined,
)
} finally {
cleanup(tmp)
}
})
test('checkSddWrite: missing plan -> fail-closed deny', () => {
const { tmp, repo } = repoFixture()
try {
writeApproval(repo)
const result = checkSddWrite({
filePath: join(repo, '.superpowers', 'sdd', 'ghost-plan', 'progress.md'),
repoRoot: repo,
})
assert.equal(result.deny, 'plan not found: ghost-plan')
} finally {
cleanup(tmp)
}
})
test('checkDispatch: all plan references are checked, not only the first', () => {
const { tmp, repo, planPath } = repoFixture()
try {
const otherPlan = join(repo, 'docs', 'superpowers', 'plans', 'plan-y.md')
write(otherPlan, '# Plan Y\n\n**Spec:** `openspec/changes/demo2/`\n')
writeApproval(repo, { planSha: shaOf(planPath) })
// The first reference is approved; the second one is not. A first-only
// check would pass, so this pin proves every reference is checked.
const result = checkDispatch({
prompt: `Read ${planPath} first, then ${otherPlan}.`,
repoRoot: repo,
})
assert.equal(result.deny, 'plan not approved; run /approve demo2 plan')
} finally {
cleanup(tmp)
}
})
test('checkDispatch: sdd reference with an approved plan -> ok', () => {
const { tmp, repo, planPath } = repoFixture()
try {
writeApproval(repo, { planSha: shaOf(planPath) })
const result = checkDispatch({
prompt: 'Start Mode 1: write .superpowers/sdd/plan-x/progress.md, then the briefs.',
repoRoot: repo,
})
assert.equal(result.deny, undefined)
} finally {
cleanup(tmp)
}
})
test('checkPlanWrite: empty design section counts as not approved', () => {
const { tmp, repo, planPath } = repoFixture()
try {
write(join(repo, 'openspec', 'changes', 'demo', 'approval.md'), 'design:\n')
const result = checkPlanWrite({ filePath: planPath, content: PLAN_TEXT, repoRoot: repo })
assert.equal(result.deny, 'design not approved; run /approve demo design')
} finally {
cleanup(tmp)
}
})