634 lines
20 KiB
JavaScript
634 lines
20 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 {
|
|
candidateRoots,
|
|
checkApprovalWrite,
|
|
checkBashTargets,
|
|
checkDispatch,
|
|
checkPlanWrite,
|
|
checkSddWrite,
|
|
extractSpec,
|
|
findChangeRoot,
|
|
findRepoRoot,
|
|
hashPlan,
|
|
isDir,
|
|
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')
|
|
}
|
|
|
|
/**
|
|
* A session repository (no `.git`, as everywhere in these fixtures) whose demo
|
|
* change and plan live only in linked worktrees: `.claude/worktrees/foo` and
|
|
* `.wt/bar`, each with a `.git` *file* exactly like a real linked worktree.
|
|
*/
|
|
function worktreeFixture() {
|
|
const tmp = tempDir('dsh-gates-wt-')
|
|
const repo = join(tmp, 'repo')
|
|
const foo = join(repo, '.claude', 'worktrees', 'foo')
|
|
const bar = join(repo, '.wt', 'bar')
|
|
write(join(foo, '.git'), 'gitdir: /fixture/.git/worktrees/foo\n')
|
|
write(join(bar, '.git'), 'gitdir: /fixture/.git/worktrees/bar\n')
|
|
write(join(foo, 'openspec', 'changes', 'demo', 'proposal.md'), '# Proposal\n')
|
|
write(join(foo, 'openspec', 'changes', 'demo', 'design.md'), '# Design\n')
|
|
const planPath = join(foo, 'docs', 'superpowers', 'plans', 'plan-x.md')
|
|
write(planPath, PLAN_TEXT)
|
|
return { tmp, repo, foo, bar, planPath }
|
|
}
|
|
|
|
/** 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)
|
|
}
|
|
})
|
|
|
|
// issue #76: a session in the main checkout must still see the change and the
|
|
// plan of a linked worktree (`<repo>/.claude/worktrees/<slug>/`, `<repo>/.wt/<slug>/`).
|
|
|
|
test('findRepoRoot: nearest .git upward, fallback to the start directory', () => {
|
|
const tmp = tempDir('dsh-gates-root-')
|
|
try {
|
|
const repo = join(tmp, 'repo')
|
|
write(join(repo, '.git'), 'gitdir: /fixture/.git/worktrees/repo\n')
|
|
const nested = join(repo, 'a', 'b')
|
|
write(join(nested, 'file.txt'), 'x\n')
|
|
assert.equal(findRepoRoot(nested), repo)
|
|
assert.equal(findRepoRoot(repo), repo)
|
|
|
|
const plain = join(tmp, 'plain')
|
|
write(join(plain, 'file.txt'), 'x\n')
|
|
assert.equal(findRepoRoot(plain), plain, 'no .git anywhere -> the dir itself')
|
|
} finally {
|
|
cleanup(tmp)
|
|
}
|
|
})
|
|
|
|
test('candidateRoots: existing worktrees under .claude/worktrees and .wt, sorted', () => {
|
|
const { tmp, repo, foo, bar } = worktreeFixture()
|
|
try {
|
|
const aaa = join(repo, '.wt', 'aaa')
|
|
write(join(aaa, '.git'), 'gitdir: /fixture/.git/worktrees/aaa\n')
|
|
write(join(repo, '.claude', 'worktrees', 'plain', 'README.md'), '# no .git\n')
|
|
assert.deepEqual(candidateRoots(repo), [repo, foo, aaa, bar])
|
|
} finally {
|
|
cleanup(tmp)
|
|
}
|
|
})
|
|
|
|
test('isDir: directories only', () => {
|
|
const { tmp, repo } = repoFixture()
|
|
try {
|
|
assert.equal(isDir(repo), true)
|
|
assert.equal(isDir(join(repo, 'openspec')), true)
|
|
assert.equal(isDir(join(repo, 'openspec', 'changes', 'demo', 'proposal.md')), false)
|
|
assert.equal(isDir(join(repo, 'ghost')), false)
|
|
} finally {
|
|
cleanup(tmp)
|
|
}
|
|
})
|
|
|
|
test('findChangeRoot: change in a linked worktree', () => {
|
|
const { tmp, repo, foo } = worktreeFixture()
|
|
try {
|
|
assert.equal(findChangeRoot(repo, 'demo'), foo)
|
|
assert.equal(findChangeRoot(repo, 'ghost'), null)
|
|
} finally {
|
|
cleanup(tmp)
|
|
}
|
|
})
|
|
|
|
test('findChangeRoot: the session root wins over a worktree copy', () => {
|
|
const { tmp, repo } = worktreeFixture()
|
|
try {
|
|
write(join(repo, 'openspec', 'changes', 'demo', 'proposal.md'), '# Proposal\n')
|
|
assert.equal(findChangeRoot(repo, 'demo'), repo)
|
|
} finally {
|
|
cleanup(tmp)
|
|
}
|
|
})
|
|
|
|
test('checkPlanWrite: change and plan in a worktree, design approved there -> ok', () => {
|
|
const { tmp, repo, foo, planPath } = worktreeFixture()
|
|
try {
|
|
writeApproval(foo)
|
|
const result = checkPlanWrite({ filePath: planPath, content: PLAN_TEXT, repoRoot: repo })
|
|
assert.equal(result.deny, undefined)
|
|
} finally {
|
|
cleanup(tmp)
|
|
}
|
|
})
|
|
|
|
test('checkPlanWrite: change in a worktree without design approval -> deny', () => {
|
|
const { tmp, repo, planPath } = worktreeFixture()
|
|
try {
|
|
const result = checkPlanWrite({ filePath: planPath, content: PLAN_TEXT, repoRoot: repo })
|
|
assert.equal(result.deny, 'design not approved; run /approve demo design')
|
|
} finally {
|
|
cleanup(tmp)
|
|
}
|
|
})
|
|
|
|
test('checkDispatch: plan only in a worktree, approved there -> ok', () => {
|
|
const { tmp, repo, foo, planPath } = worktreeFixture()
|
|
try {
|
|
writeApproval(foo, { planSha: shaOf(planPath) })
|
|
const result = checkDispatch({
|
|
prompt: 'Implement task 1. Read docs/superpowers/plans/plan-x.md first.',
|
|
repoRoot: repo,
|
|
})
|
|
assert.equal(result.deny, undefined)
|
|
} finally {
|
|
cleanup(tmp)
|
|
}
|
|
})
|
|
|
|
test('checkDispatch: plan only in a worktree, no approval -> deny', () => {
|
|
const { tmp, repo } = worktreeFixture()
|
|
try {
|
|
const result = checkDispatch({
|
|
prompt: 'Implement task 1. Read docs/superpowers/plans/plan-x.md first.',
|
|
repoRoot: repo,
|
|
})
|
|
assert.equal(result.deny, 'plan not approved; run /approve demo plan')
|
|
} finally {
|
|
cleanup(tmp)
|
|
}
|
|
})
|
|
|
|
test('checkSddWrite: plan only in a worktree, approved there -> ok', () => {
|
|
const { tmp, repo, foo, planPath } = worktreeFixture()
|
|
try {
|
|
writeApproval(foo, { planSha: shaOf(planPath) })
|
|
for (const ledgerRoot of [repo, foo]) {
|
|
const result = checkSddWrite({
|
|
filePath: join(ledgerRoot, '.superpowers', 'sdd', 'plan-x', 'progress.md'),
|
|
repoRoot: repo,
|
|
})
|
|
assert.equal(result.deny, undefined, `ledger under ${ledgerRoot}`)
|
|
}
|
|
} finally {
|
|
cleanup(tmp)
|
|
}
|
|
})
|
|
|
|
test('checkSddWrite: plan only in a worktree, no approval -> deny', () => {
|
|
const { tmp, repo } = worktreeFixture()
|
|
try {
|
|
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)
|
|
}
|
|
})
|