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/') } 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/') } 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/', ) 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/', ) } 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 (`/.claude/worktrees//`, `/.wt//`). 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) } })