feat(dsh): gates — чистые decision-функции гейтов A/B/C и approval io (#59)

This commit is contained in:
Andrey Epifancev 2026-10-25 01:28:30 +04:00
parent 4cbcff1c8f
commit 99d7238d90
2 changed files with 757 additions and 0 deletions

388
dsh/bundle/lib/gates.js Normal file
View File

@ -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/<id>'
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/<id>` anywhere in a path (also inside an absolute path). */
const CHANGE_PATH = /(?:^|\/)openspec\/changes\/([^/\s]+)/u
/** `.superpowers/sdd/<plan-name>` 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=<path>` and `:` ends a prose label like `Plan:<path>`.
*/
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/<id>` 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/<id>/...` is normalized to `openspec/changes/<id>`;
* 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/<id>(/...)` -> `<id>`.
* 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/<plan-name>(/...)` ->
* `<repoRoot>/docs/superpowers/plans/<plan-name>.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; `<id>` stays literal when unknown. */
function denyPlanNotApproved(changeId) {
return { deny: `plan not approved; run /approve ${changeId ?? '<id>'} 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 {}
}

369
dsh/tests/gates.test.mjs Normal file
View File

@ -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/<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)
}
})