voyage/tests/lib/artifact-commit-trailer.test.mjs
Kjell Tore Guttormsen 1621352e67
fix(approval): a trailer with a blank or padded name is refused
Review finding 8420915b.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:40:10 +02:00

171 lines
8.8 KiB
JavaScript

// tests/lib/artifact-commit-trailer.test.mjs
// Approval commits carry the caller's `Co-Authored-By:` trailer (plan step 13).
// The CLI takes `--trailer "<line>"`; a missing, multi-line or malformed one
// (an unfilled `{model}`, no `<email>`, another key) or one holding
// `Claude-Session:` is refused with exit 1 before any git call, so "nothing to
// commit" cannot mask it. A usage error (`--store`) stays exit 2 and comes
// first. Every call in commands/*.md passes the trailer, and none carries a
// `Claude-Session:` line.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { readFileSync, readdirSync, realpathSync, rmSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from '../helpers/child-env.mjs';
import { artifactRepo, git } from '../helpers/artifact-repo.mjs';
import { commitArtifacts } from '../../lib/util/artifact-commit.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const CLI = join(ROOT, 'lib', 'util', 'artifact-commit.mjs');
const TRAILER = 'Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>';
function run(args, cwd) {
return spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8', env: childEnv() });
}
const head = (d) => git(d, 'rev-parse', 'HEAD').stdout.trim();
test('a passed trailer ends the commit message and git parses it as a trailer', () => {
const { d, brief } = artifactRepo({ prefix: 'artifact-trailer-', project: '2026-10-12-trailer' });
try {
const r = run(['--store', 'local', '--artifact', brief, '--message', 'docs(voyage): approve intent trailer',
'--trailer', TRAILER], d);
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.equal(JSON.parse(r.stdout).committed, true);
const body = git(d, 'log', '-1', '--format=%B').stdout.trimEnd().split('\n');
assert.equal(body[0], 'docs(voyage): approve intent trailer');
assert.equal(body[body.length - 1], TRAILER, 'the trailer is the last line of the message');
const parsed = git(d, 'log', '-1', '--format=%(trailers:key=Co-Authored-By,valueonly)').stdout.trim();
assert.equal(parsed, 'Claude Opus 5.5 <noreply@anthropic.com>');
} finally { rmSync(d, { recursive: true, force: true }); }
});
const REFUSED = [
['a two-line value', `${TRAILER}\nCo-Authored-By: Other <other@example.invalid>`],
['a value holding Claude-Session:', 'Co-Authored-By: Claude-Session: https://example.invalid/x <noreply@anthropic.com>'],
['a value without <email>', 'Co-Authored-By: Claude Opus 5.5'],
['a non-Co-Authored-By key', 'Signed-off-by: Claude Opus 5.5 <noreply@anthropic.com>'],
['the unfilled {model} placeholder', 'Co-Authored-By: Claude {model} <noreply@anthropic.com>'],
['an empty {model} (two spaces before the address)', 'Co-Authored-By: Claude <noreply@anthropic.com>'],
['a name of three spaces', 'Co-Authored-By: <noreply@anthropic.com>'],
['two spaces after the colon', 'Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>'],
['two spaces inside the name', 'Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>'],
];
for (const [name, value] of REFUSED) {
test(`refused with exit 1, nothing committed: ${name}`, () => {
const { d, brief } = artifactRepo({ prefix: 'artifact-trailer-', project: '2026-10-12-trailer' });
try {
const before = head(d);
const r = run(['--store', 'local', '--artifact', brief, '--message', 'x', '--trailer', value], d);
assert.equal(r.status, 1, r.stdout + r.stderr);
const out = JSON.parse(r.stdout);
assert.equal(out.committed, false);
assert.match(out.reason, /trailer/i);
assert.equal(head(d), before, 'HEAD unchanged');
} finally { rmSync(d, { recursive: true, force: true }); }
});
}
// Guard rows: these must stay accepted (review 8420915b tightened the name).
const ACCEPTED = [
['the session trailer', TRAILER],
['a name with a parenthesis', 'Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>'],
];
for (const [name, value] of ACCEPTED) {
test(`accepted and committed as the last line: ${name}`, () => {
const { d, brief } = artifactRepo({ prefix: 'artifact-trailer-', project: '2026-10-12-trailer' });
try {
const r = run(['--store', 'local', '--artifact', brief, '--message', 'x', '--trailer', value], d);
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.equal(JSON.parse(r.stdout).committed, true);
const body = git(d, 'log', '-1', '--format=%B').stdout.trimEnd().split('\n');
assert.equal(body[body.length - 1], value, 'the trailer is the last line of the message');
} finally { rmSync(d, { recursive: true, force: true }); }
});
}
test('a call without a trailer exits 1 with HEAD unchanged', () => {
const { d, brief } = artifactRepo({ prefix: 'artifact-trailer-', project: '2026-10-12-trailer' });
try {
const before = head(d);
const r = run(['--store', 'local', '--artifact', brief, '--message', 'x'], d);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(JSON.parse(r.stdout).reason, /--trailer/);
assert.equal(head(d), before, 'HEAD unchanged');
assert.equal(git(d, 'diff', '--cached', '--name-only').stdout.trim(), '', 'nothing staged either');
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('the trailer check comes before any git call: "nothing to commit" does not mask a missing trailer', () => {
const { d, brief } = artifactRepo({ prefix: 'artifact-trailer-', project: '2026-10-12-trailer', withLog: false });
try {
const r = run(['--store', 'local', '--artifact', brief, '--message', 'x'], d);
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(JSON.parse(r.stdout).reason, /--trailer/);
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('commitArtifacts takes the trailer too, and refuses without it', () => {
const { d, brief } = artifactRepo({ prefix: 'artifact-trailer-', project: '2026-10-12-trailer' });
try {
const repoDir = realpathSync(d);
const before = head(d);
const no = commitArtifacts({ repo: repoDir, store: 'local', artifacts: [brief], message: 'x' });
assert.equal(no.exit, 1, JSON.stringify(no));
assert.equal(head(d), before);
const yes = commitArtifacts({ repo: repoDir, store: 'local', artifacts: [brief], message: 'x', trailer: TRAILER });
assert.equal(yes.exit, 0, JSON.stringify(yes));
assert.equal(yes.committed, true);
assert.equal(git(d, 'log', '-1', '--format=%(trailers:key=Co-Authored-By,valueonly)').stdout.trim(),
'Claude Opus 5.5 <noreply@anthropic.com>');
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('usage before trailer: a missing --store is still exit 2, with or without a trailer', () => {
const { d, brief } = artifactRepo({ prefix: 'artifact-trailer-', project: '2026-10-12-trailer' });
try {
assert.equal(run(['--artifact', brief, '--message', 'x'], d).status, 2);
assert.equal(run(['--artifact', brief, '--message', 'x', '--trailer', TRAILER], d).status, 2);
assert.equal(run(['--store', 'cloud', '--artifact', brief, '--message', 'x', '--trailer', 'bad'], d).status, 2);
} finally { rmSync(d, { recursive: true, force: true }); }
});
// ---- the calls in commands/*.md ----
const CMD_DIR = join(ROOT, 'commands');
// A call is `artifact-commit.mjs` followed by its flags, up to the end of the
// line or the closing backtick of an inline call. A prose mention without
// flags is not a call.
const CALL = /artifact-commit\.mjs[ \t]+--[^`\n]*/g;
function calls() {
const out = [];
for (const f of readdirSync(CMD_DIR).filter((n) => n.endsWith('.md')).sort()) {
const text = readFileSync(join(CMD_DIR, f), 'utf8');
for (const m of text.matchAll(CALL)) out.push({ file: f, call: m[0], index: m.index, text });
}
return out;
}
test('every artifact-commit.mjs call in commands/*.md (exactly 3) passes --trailer, and none holds Claude-Session', () => {
const found = calls();
assert.deepEqual(found.map((c) => c.file), ['trekbrief.md', 'trekexecute.md', 'trekplan.md'],
found.map((c) => `${c.file}: ${c.call}`).join('\n'));
for (const c of found) {
assert.match(c.call, /--trailer "Co-Authored-By: Claude \{model\} <noreply@anthropic\.com>"/, `${c.file}: ${c.call}`);
assert.doesNotMatch(c.call, /Claude-Session/, `${c.file}: ${c.call}`);
}
});
test('commands/trekexecute.md says what to do when the approval commit exits 1 or 2', () => {
const c = calls().find((x) => x.file === 'trekexecute.md');
assert.ok(c, 'the inline call in /trekexecute is found');
const after = c.text.slice(c.index, c.index + 800).replace(/\s+/g, ' ');
assert.ok(after.includes('On exit 1 or 2, print the `reason` in one line and continue: the approval is logged but not committed.'),
after);
});