diff --git a/dsh/bundle/lib/gates.js b/dsh/bundle/lib/gates.js new file mode 100644 index 0000000..b3deb48 --- /dev/null +++ b/dsh/bundle/lib/gates.js @@ -0,0 +1,388 @@ +/** + * Approval gates A/B/C of the upstream loop: the pure decision core plus the + * approval.md / plan io it needs. + * + * No DSH runtime is imported here. Every decision function takes plain values; + * where it must touch the filesystem it does so only through an explicit + * repository root and only synchronously (`readFileSync` / `existsSync`). + * + * Failure policy mirrors the bash hooks: a call that references no plan is + * never blocked, while a *referenced* plan that cannot be read or tied to an + * approved change is denied (fail closed). Parsing never throws: malformed + * YAML or markdown degrades to "missing", which the gates reject. + */ + +import { createHash } from 'node:crypto' +import { readFileSync, statSync } from 'node:fs' +import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path' + +export const APPROVAL_FILE = 'approval.md' +export const PLANS_DIR = join('docs', 'superpowers', 'plans') +export const CHANGE_DIR = join('openspec', 'changes') + +const SPEC_DENY = 'plan must carry **Spec:** pointing to openspec/changes/' +const APPROVAL_DENY = 'approval.md is written only by /approve' +const PLAN_NO_APPROVAL = 'no plan approval' +const PLAN_CHANGED = 'plan changed after approval' +const PLAN_CHANGED_DENY = 'plan changed after approval; re-approve' + +/** Scalar keys of each approval.md section, in serialization order. */ +const SECTION_FIELDS = { + design: ['by', 'at'], + plan: ['by', 'at', 'sha256'], +} + +/** `openspec/changes/` anywhere in a path (also inside an absolute path). */ +const CHANGE_PATH = /(?:^|\/)openspec\/changes\/([^/\s]+)/u +/** `.superpowers/sdd/` anywhere in a path or a prompt. */ +const SDD_PATH = /(?:^|\/)\.superpowers\/sdd\/([^/\s]+)/u +/** A direct plan reference; the match ends at `.md`, prose may follow it. */ +const DIRECT_PLAN = /^docs\/superpowers\/plans\/([^/\s"'`()<>[\]{}]+\.md)/u + +const DIRECT_PREFIX = 'docs/superpowers/plans/' +const SDD_PREFIX = '.superpowers/sdd/' + +/** + * Characters that end a path token when scanning a prompt backwards. `=` + * separates `--flag=` and `:` ends a prose label like `Plan:`. + */ +const TOKEN_STOP = new Set([ + ' ', + '\t', + '\n', + '\r', + '"', + "'", + '`', + '(', + ')', + '[', + ']', + '{', + '}', + '<', + '>', + ',', + ';', + ':', + '=', + '&', + '|', +]) + +function isFile(path) { + try { + return statSync(path).isFile() + } catch { + return false + } +} + +function readText(path) { + try { + return readFileSync(path, 'utf8') + } catch { + return null + } +} + +/** Strip a comment / quotes / backticks from one yaml scalar. */ +function scalar(raw) { + let value = String(raw ?? '').trim() + const comment = value.search(/\s#/u) + if (comment !== -1 && !/^['"`]/u.test(value)) value = value.slice(0, comment).trim() + if ( + (value.startsWith('`') && value.endsWith('`')) || + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1).trim() + } + return value +} + +/** `openspec/changes/` when the value points into a change, else null. */ +function changeSpec(value) { + const match = CHANGE_PATH.exec(value) + if (!match) return null + const id = match[1] + if (id === '.' || id === '..') return null + return `openspec/changes/${id}` +} + +function isInside(parent, child) { + const rel = relative(parent, child) + return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel) +} + +/** + * Parse the tolerant yaml subset used by approval.md: top-level `design:` and + * `plan:` blocks with scalar `by` / `at` / `sha256` children. A section key is + * kept even when its block is empty; unknown sections and malformed lines are + * ignored. Any non-string input yields `{}` — the caller passes null when the + * file is missing. + */ +export function parseApproval(text) { + const parsed = {} + if (typeof text !== 'string' || text === '') return parsed + + let section = null + for (const rawLine of text.split(/\r?\n/u)) { + const line = rawLine.replace(/[ \t]+$/u, '') + if (line.trim() === '' || /^\s*#/u.test(line)) continue + + const top = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/u.exec(line) + if (top) { + // Own-key check, not `in`: a crafted `__proto__:`/`constructor:` line + // must be ignored instead of touching the prototype. + const known = Object.prototype.hasOwnProperty.call(SECTION_FIELDS, top[1]) + section = known ? {} : null + if (section) parsed[top[1]] = section + continue + } + + if (section === null) continue + const child = /^\s+([A-Za-z0-9_-]+)\s*:\s*(.*)$/u.exec(line) + if (!child) continue + const value = scalar(child[2]) + if (value !== '') section[child[1]] = value + } + return parsed +} + +/** + * Deterministic approval.md text: sections in `design`, `plan` order, fields + * in a fixed order, one blank line between sections, no trailing spaces, and + * a final newline. An absent or non-object section is skipped. + */ +export function serializeApproval(parsed) { + const sections = [] + for (const [section, fields] of Object.entries(SECTION_FIELDS)) { + const source = parsed?.[section] + if (!source || typeof source !== 'object') continue + const lines = [`${section}:`] + for (const field of fields) { + const value = source[field] + if (value === undefined || value === null) continue + const text = String(value).trim() + if (text === '') continue + lines.push(` ${field}: ${text}`) + } + sections.push(lines.join('\n')) + } + return sections.length === 0 ? '' : `${sections.join('\n\n')}\n` +} + +/** + * Normalized spec path named by the plan's `**Spec:**` line, or null. + * + * Backticks, a markdown link target and a trailing slash are stripped. A path + * into `openspec/changes//...` is normalized to `openspec/changes/`; + * any other path (legacy `docs/superpowers/specs/...`) is returned as written + * so the caller can reject it explicitly (D-9). + */ +export function extractSpec(content) { + if (typeof content !== 'string' || content === '') return null + const match = /^[ \t]*\*\*Spec:\*\*[ \t]*(.+?)[ \t]*$/mu.exec(content) + if (!match) return null + + let value = match[1].trim() + const backtick = /^`([^`]+)`/u.exec(value) + const link = /^\[[^\]]*\]\(([^)]+)\)/u.exec(value) + if (backtick) value = backtick[1] + else if (link) value = link[1] + else value = value.split(/\s+/u)[0] + + value = value + .trim() + .replace(/^[`"']+/u, '') + .replace(/[`"']+$/u, '') + .replace(/^\.\//u, '') + .replace(/[/.,;:]+$/u, '') + if (value === '') return null + return changeSpec(value) ?? value +} + +/** + * Change id of a spec path: `openspec/changes/(/...)` -> ``. + * Legacy `docs/superpowers/specs/...` and everything else -> null. + */ +export function resolveChangeFromSpec(specPath) { + if (typeof specPath !== 'string' || specPath === '') return null + const change = changeSpec(specPath) + return change === null ? null : change.slice('openspec/changes/'.length) +} + +/** + * Plan file behind an sdd ledger path: `.superpowers/sdd/(/...)` -> + * `/docs/superpowers/plans/.md`, when that file exists. + */ +export function resolvePlanFromSdd(sddPath, repoRoot) { + if (typeof sddPath !== 'string' || sddPath === '' || !repoRoot) return null + const match = SDD_PATH.exec(sddPath) + if (!match) return null + const name = match[1].replace(/[/.,;:]+$/u, '') + if (name === '' || name === '.' || name === '..') return null + const planPath = join(resolve(repoRoot), PLANS_DIR, `${name}.md`) + return isFile(planPath) ? planPath : null +} + +/** sha256 hex of the plan content; '' when the file cannot be read. */ +export function hashPlan(planPath) { + try { + return createHash('sha256').update(readFileSync(planPath)).digest('hex') + } catch { + return '' + } +} + +/** + * Is the plan approved for its current content (D-10)? + * Fails with `no plan approval` when the section or its sha256 is missing and + * with `plan changed after approval` when the hash does not match (a missing + * plan file is a hash mismatch by construction). + */ +export function planApproved(approval, planPath) { + const plan = approval?.plan + if (!plan || typeof plan !== 'object') return { ok: false, reason: PLAN_NO_APPROVAL } + const expected = typeof plan.sha256 === 'string' ? plan.sha256.trim().toLowerCase() : '' + if (expected === '') return { ok: false, reason: PLAN_NO_APPROVAL } + const actual = hashPlan(planPath) + if (actual === '' || actual !== expected) return { ok: false, reason: PLAN_CHANGED } + return { ok: true, reason: '' } +} + +function readApproval(repoRoot, changeId) { + return parseApproval(readText(join(repoRoot, CHANGE_DIR, changeId, APPROVAL_FILE))) +} + +/** + * Gate A: a plan may only be written for a change whose design is approved. + * The `**Spec:**` reference is mandatory and must resolve to an openspec + * change (D-9); a plan written elsewhere is not gated. + */ +export function checkPlanWrite({ filePath, content, repoRoot } = {}) { + if (typeof filePath !== 'string' || filePath === '') return {} + const root = resolve(repoRoot || '.') + const target = resolve(root, filePath) + if (!isInside(join(root, PLANS_DIR), target)) return {} + + const changeId = resolveChangeFromSpec(extractSpec(content)) + if (changeId === null) return { deny: SPEC_DENY } + if (!readApproval(root, changeId).design) { + return { deny: `design not approved; run /approve ${changeId} design` } + } + return {} +} + +/** + * Gate C: `approval.md` is written only by `/approve`, never by a tool (D-1). + * Applies wherever the file lives, not only inside a change directory. + */ +export function checkApprovalWrite({ filePath, repoRoot } = {}) { + if (typeof filePath !== 'string' || filePath === '') return {} + const target = resolve(resolve(repoRoot || '.'), filePath) + if (basename(target) === APPROVAL_FILE) return { deny: APPROVAL_DENY } + return {} +} + +function tokenStart(text, index) { + let start = index + while (start > 0 && !TOKEN_STOP.has(text[start - 1])) start -= 1 + return start +} + +/** + * First plan reference in one string: a direct `docs/superpowers/plans/x.md` + * path (absolute or repository-relative) or an `.superpowers/sdd/x/` ledger + * path. Returns null when the string references no plan. + */ +function planReference(text) { + if (typeof text !== 'string' || text === '') return null + const found = [] + + const directAt = text.indexOf(DIRECT_PREFIX) + if (directAt !== -1) { + const match = DIRECT_PLAN.exec(text.slice(directAt)) + if (match) { + const start = tokenStart(text, directAt) + found.push({ index: directAt, path: text.slice(start, directAt + match[0].length) }) + } + } + + const sddAt = text.indexOf(SDD_PREFIX) + if (sddAt !== -1) { + const match = SDD_PATH.exec(text.slice(sddAt)) + if (match) { + const name = match[1].replace(/[/.,;:]+$/u, '') + if (name !== '' && name !== '.' && name !== '..') { + found.push({ index: sddAt, name }) + } + } + } + + if (found.length === 0) return null + found.sort((left, right) => left.index - right.index) + return found[0] +} + +/** Deny text for an unapproved plan; `` stays literal when unknown. */ +function denyPlanNotApproved(changeId) { + return { deny: `plan not approved; run /approve ${changeId ?? ''} plan` } +} + +function checkReferencedPlan(repoRoot, planPath) { + const content = readText(planPath) + if (content === null) return denyPlanNotApproved() + const changeId = resolveChangeFromSpec(extractSpec(content)) + if (changeId === null) return denyPlanNotApproved() + + const verdict = planApproved(readApproval(repoRoot, changeId), planPath) + if (verdict.ok) return {} + if (verdict.reason === PLAN_NO_APPROVAL) return denyPlanNotApproved(changeId) + return { deny: PLAN_CHANGED_DENY } +} + +/** + * Gate B: a dispatch may only start when the plan it references is approved + * for its current content (D-10). The check covers the prompt and, when + * given, the workflow script; no plan reference means no gate. A reference to + * a missing or spec-less plan fails closed. + */ +export function checkDispatch({ prompt, script, repoRoot } = {}) { + const root = resolve(repoRoot || '.') + const reference = planReference(prompt) ?? planReference(script) + if (reference === null) return {} + + let planPath + if (typeof reference.name === 'string') { + planPath = + resolvePlanFromSdd(`${SDD_PREFIX}${reference.name}`, root) ?? + join(root, PLANS_DIR, `${reference.name}.md`) + } else { + planPath = isAbsolute(reference.path) + ? resolve(reference.path) + : resolve(root, reference.path) + } + return checkReferencedPlan(root, planPath) +} + +/** + * Gates A and C for a list of absolute write targets (from + * `harness_target.py targets`). First denial wins. A target under plans/ has + * no content here, so it can never carry a `**Spec:**` line and is denied — + * exactly the bash-heredoc case the tool-level gates would otherwise miss. + */ +export function checkBashTargets(targets, repoRoot) { + if (!Array.isArray(targets)) return {} + const root = resolve(repoRoot || '.') + for (const raw of targets) { + if (typeof raw !== 'string' || raw === '') continue + const target = resolve(root, raw) + const approval = checkApprovalWrite({ filePath: target, repoRoot: root }) + if (approval.deny !== undefined) return approval + const plan = checkPlanWrite({ filePath: target, content: null, repoRoot: root }) + if (plan.deny !== undefined) return plan + } + return {} +} diff --git a/dsh/tests/gates.test.mjs b/dsh/tests/gates.test.mjs new file mode 100644 index 0000000..e208ab7 --- /dev/null +++ b/dsh/tests/gates.test.mjs @@ -0,0 +1,369 @@ +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, + 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/') + } 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) + } +})