feat(dsh): gates — чистые decision-функции гейтов A/B/C и approval io (#59)
This commit is contained in:
parent
4cbcff1c8f
commit
99d7238d90
388
dsh/bundle/lib/gates.js
Normal file
388
dsh/bundle/lib/gates.js
Normal 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
369
dsh/tests/gates.test.mjs
Normal 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)
|
||||
}
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user