diff --git a/CHANGELOG.md b/CHANGELOG.md index 965f4ce..8f25e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable project changes are recorded here. Dates use ISO 8601 (`YYYY-MM-DD`) ## [Unreleased] ### Added +- Every subcommand refuses a flag it does not accept, exits 2, and names the unknown flag and the accepted ones. Nothing runs after that refusal (#33). - The pre-push hook gates the commit being pushed, at that sha, in a temporary worktree. It reads the local sha from stdin and does not gate the checked-out HEAD. The base is the most recent `proofline next` pin for the task, or the parent of that commit, and a refusal prints the base it used. `proofline next --base ` records a newer pin. A worktree with no evidence still gates, and stale evidence in the pushing worktree cannot block a commit that passes at its own sha (#21, #22, #23). - `proofline contract sign` writes `proofline.json.sig`, a signature over the canonical contract by the agentkey owner role. `proofline contract verify-sig` checks it against `guard/owner.pub` only. Once that pin is present, `next`, `verify`, `gate`, `brief` and both hooks exit 2 with `contract not owner-signed` when the signature is missing, altered or from another key. `proofline contract propose` writes `proposals/.diff` and prints the hash. - `proofline setup` installs the hooks, creates the owner key through agentkey and sets approvals to `.proofline/approvals`. `proofline task` writes a proposal from plain words and prints its hash. `proofline contract approve` shows the diff and, on y, writes the marker and applies the bytes. `proofline watch` prints the active task until the gate is PASS. diff --git a/bin/proofline.mjs b/bin/proofline.mjs index d4c3b7f..8d3dbbf 100755 --- a/bin/proofline.mjs +++ b/bin/proofline.mjs @@ -75,8 +75,55 @@ const HELP = `proofline: independent V&V and delivery controller mcp MCP server on stdio Global: --json --root Exit: 0 PASS, 1 FAIL, 2 BLOCKED, 3 usage`; +const GLOBAL_FLAGS = ['json', 'root']; +const ACCEPTED_FLAGS = { + setup: ['agent'], + task: [], + init: ['approvals', 'no-hooks'], + 'contract create': ['from', 'id', 'title', 'phase', 'owner', 'description', 'depends', 'scope'], + 'contract merge': ['owners'], + 'contract coverage': ['contracts'], + 'contract scaffold': ['title', 'scope', 'kind', 'id'], + 'contract approve': [], + 'contract sign': [], + 'contract verify-sig': [], + 'contract propose': [], + 'contract validate': ['base'], + 'contract show': [], + next: ['owner', 'base'], + brief: ['owner', 'base'], + verify: ['claims', 'claims-file', 'base', 'fresh'], + gate: ['claims', 'claims-file', 'base', 'fresh', 'all', 'ci', 'skip-pushed'], + report: [], + status: ['owner'], + 'evidence record': ['data'], + 'evidence verify': [], + 'evidence log': [], + 'verify-receipt': ['keys', 'sigstore', 'recompute', 'base'], + receipt: ['base', 'role', 'task', 'vault'], + hook: [], + watch: [], + connect: [], + mcp: [], +}; + +function refuseUnknownFlags(cmd, sub) { + if (!cmd) return; + const key = sub ? `${cmd} ${sub}` : cmd; + const accepted = ACCEPTED_FLAGS[key] ?? ACCEPTED_FLAGS[cmd]; + if (!accepted) return; + const allow = new Set([...GLOBAL_FLAGS, ...accepted]); + const bad = Object.keys(flags).filter((k) => !allow.has(k)); + if (!bad.length) return; + const named = bad.map((k) => `--${k}`).join(', '); + const ok = [...allow].sort().map((k) => `--${k}`).join(', '); + console.error(`proofline: unknown flag ${named}\naccepted: ${ok}`); + process.exit(2); +} + try { const [cmd, sub] = pos; + refuseUnknownFlags(cmd, sub); switch (cmd) { case 'setup': { const agent = flags.agent === undefined ? 'none' : flags.agent; diff --git a/test/cli_unknown_flags.test.mjs b/test/cli_unknown_flags.test.mjs new file mode 100644 index 0000000..72c9516 --- /dev/null +++ b/test/cli_unknown_flags.test.mjs @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { BIN, repo } from './helpers.mjs'; + +const run = (args, cwd) => spawnSync(process.execPath, [BIN, ...args], { + cwd, encoding: 'utf8', timeout: 2000, +}); + +const COMMANDS = [ + ['setup'], + ['task', 'plain words'], + ['init'], + ['contract', 'create'], + ['contract', 'merge'], + ['contract', 'coverage'], + ['contract', 'scaffold'], + ['contract', 'approve'], + ['contract', 'sign'], + ['contract', 'verify-sig'], + ['contract', 'propose'], + ['contract', 'validate'], + ['contract', 'show'], + ['next'], + ['brief'], + ['verify'], + ['gate'], + ['report'], + ['status'], + ['evidence', 'record'], + ['evidence', 'verify'], + ['evidence', 'log'], + ['verify-receipt'], + ['receipt'], + ['hook'], + ['watch'], + ['connect'], + ['mcp'], +]; + +test('contract validate --file exits 2 and names the flag', () => { + const r = repo(); + const res = run(['contract', 'validate', '--file', '/tmp/does-not-exist-proposal.json', '--root', r], r); + assert.equal(res.status, 2, res.stderr); + assert.match(res.stderr, /unknown flag --file/); + assert.match(res.stderr, /accepted:/); + assert.doesNotMatch(`${res.stdout}\n${res.stderr}`, /warning:/); +}); + +test('an unknown flag on each subcommand exits 2 and names the flag', () => { + const r = repo(); + for (const args of COMMANDS) { + const res = run([...args, '--not-a-real-flag', '--root', r], r); + assert.equal(res.status, 2, `${args.join(' ')} ${res.stderr}`); + assert.match(res.stderr, /unknown flag --not-a-real-flag/); + assert.match(res.stderr, /accepted:/); + } +}); + +test('accepted flags still run the command', () => { + const r = repo(); + const validate = run(['contract', 'validate', '--root', r], r); + assert.notEqual(validate.status, 2, validate.stderr); + const shown = run(['contract', 'scaffold', '--title', 'A task', '--scope', 'src', '--root', r], r); + assert.equal(shown.status, 0, shown.stderr); + assert.match(shown.stdout, /"title": "A task"/); + const status = run(['status', '--json', '--root', r], r); + assert.notEqual(status.status, 2, status.stderr); + assert.doesNotThrow(() => JSON.parse(status.stdout)); +});