Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <rev>` 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/<sha256>.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.
Expand Down
47 changes: 47 additions & 0 deletions bin/proofline.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,55 @@ const HELP = `proofline: independent V&V and delivery controller
mcp MCP server on stdio
Global: --json --root <dir> 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;
Expand Down
70 changes: 70 additions & 0 deletions test/cli_unknown_flags.test.mjs
Original file line number Diff line number Diff line change
@@ -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));
});
Loading