The pipeline had never been run against an actual repository — every test in the suite is either a prose-grep over SKILL.md or a unit test that asserts key presence. Running it against scratch repos (private remote, and public `open/` remote with and without a gitignored STATE.md) found two defects living under a 42/42-green suite. 1. dirty_files truncated the first path. gitOk() trims every command's output, but `git status --porcelain` puts the worktree status in column 2, so a modified-but-unstaged file is " M path". The trim ate the leading space and the fixed slice(3) then ate the first character: app.js was reported as pp.js. Only the first line is affected, which is why it survived — no test asserted dirty_files VALUES, only that the key existed. Porcelain now goes through a non-trimming gitOkRaw(). 2. The commit message claimed a STATE.md update it did not contain. The message was hardcoded to "oppdater STATE.md" regardless of what was staged. On every `open/` repo STATE.md is gitignored, so the handoff commit carries only the --also paths. Git history is the regime's long-term log; it was systematically wrong about its own contents. Also promotes the leak condition from advisory to hard gate. A public remote whose STATE.md is not yet gitignored is the state a FRESH open/ repo starts in, and should_commit_state was true there — the ritual only mentioned leak_warning, then committed. It now lands in errors[] (step 2 stops on a non-empty errors[]), should_commit_state is false, and --commit refuses to stage STATE.md. Explicit --also paths are still honoured: the gate protects STATE.md, not the commit as a whole. And corrects SKILL.md's justification for the single-line rule. It claimed a wrapped rationale= replaces the board's next step with garbage; board.sh in repo-mailbox 0.20.3 tracks a comment to its closer, so that no longer follows. The rule stands, restated with the risk that is still real: a rationale containing the closer sequence ends its own comment early. Tests 42 -> 48, all six written failing first. Still unverified: that /graceful-handoff loads as a user command (#26251), and that a cross-plugin Skill invocation of repo-mailbox:route passes from a sub-scoped skill. Both need the catalog ref bumped so the version is installed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvLY4FwbzY157oVqwnkHD8
270 lines
14 KiB
JavaScript
270 lines
14 KiB
JavaScript
// handoff-pipeline.test.mjs — Tests for scripts/handoff-pipeline.mjs (v3.0 STATE helper).
|
|
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { execFileSync, spawn } from 'node:child_process';
|
|
import { existsSync, writeFileSync, rmSync, mkdtempSync, realpathSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import { tmpdir } from 'node:os';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const SCRIPT = join(__dirname, '..', '..', 'scripts', 'handoff-pipeline.mjs');
|
|
|
|
function makeTempRepo({ remote = null, gitignoreState = false } = {}) {
|
|
// realpath: on macOS tmpdir() is a /var → /private/var symlink, but
|
|
// `git rev-parse --show-toplevel` returns the canonical path. Canonicalise
|
|
// here so derived expected paths match the script's git-resolved output.
|
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'gh-pipeline-')));
|
|
execFileSync('git', ['init', '-q'], { cwd: dir });
|
|
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir });
|
|
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir });
|
|
if (remote) execFileSync('git', ['remote', 'add', 'origin', remote], { cwd: dir });
|
|
writeFileSync(join(dir, 'README.md'), '# test\n', 'utf-8');
|
|
if (gitignoreState) writeFileSync(join(dir, '.gitignore'), 'STATE.md\n', 'utf-8');
|
|
execFileSync('git', ['add', '.'], { cwd: dir });
|
|
execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir });
|
|
return dir;
|
|
}
|
|
|
|
function runPipeline(cwd, args = []) {
|
|
return new Promise((resolveP) => {
|
|
const child = spawn('node', [SCRIPT, ...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout.on('data', (d) => (stdout += d.toString()));
|
|
child.stderr.on('data', (d) => (stderr += d.toString()));
|
|
child.on('close', (code) => resolveP({ code, stdout, stderr }));
|
|
});
|
|
}
|
|
|
|
// ---------- classifyRemote (unit) ----------
|
|
|
|
test('classifyRemote: none / public / private', async () => {
|
|
const { classifyRemote } = await import(pathToFileURL(SCRIPT).href);
|
|
assert.equal(classifyRemote(null), 'none');
|
|
assert.equal(classifyRemote(''), 'none');
|
|
assert.equal(classifyRemote('https://github.com/foo/bar.git'), 'public');
|
|
assert.equal(classifyRemote('ssh://git@git.fromaitochitta.com/open/graceful-handoff.git'), 'public');
|
|
assert.equal(classifyRemote('ssh://git@git.fromaitochitta.com/ktg/secret.git'), 'private');
|
|
});
|
|
|
|
// ---------- --plan ----------
|
|
|
|
test('--plan returns JSON with required keys', async () => {
|
|
const repo = makeTempRepo();
|
|
const result = await runPipeline(repo, ['--plan']);
|
|
assert.equal(result.code, 0, `non-zero exit: ${result.stderr}`);
|
|
const json = JSON.parse(result.stdout);
|
|
for (const k of ['state_path', 'state_exists', 'state_gitignored', 'remote_class',
|
|
'should_be_local_only', 'should_commit_state', 'git_status', 'dirty_files',
|
|
'recent_commits', 'line_budget', 'errors']) {
|
|
assert.ok(k in json, `missing key: ${k}`);
|
|
}
|
|
assert.ok(json.git_status.branch, 'branch missing');
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
test('--plan (default mode) targets <repoRoot>/STATE.md when none exists', async () => {
|
|
const repo = makeTempRepo();
|
|
const result = await runPipeline(repo, []); // default = plan
|
|
const json = JSON.parse(result.stdout);
|
|
assert.equal(json.state_exists, false);
|
|
assert.equal(json.state_path, join(repo, 'STATE.md'));
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
test('--plan resolves nearest existing STATE.md (subdir wins over root)', async () => {
|
|
const repo = makeTempRepo();
|
|
const sub = join(repo, 'plugins', 'x');
|
|
execFileSync('mkdir', ['-p', sub]);
|
|
writeFileSync(join(repo, 'STATE.md'), '# root\n');
|
|
writeFileSync(join(sub, 'STATE.md'), '# sub\n');
|
|
const result = await runPipeline(sub, ['--plan']);
|
|
const json = JSON.parse(result.stdout);
|
|
assert.equal(json.state_path, join(sub, 'STATE.md'));
|
|
assert.equal(json.state_exists, true);
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
test('--plan: public open/ remote → should_be_local_only, leak_warning when STATE not gitignored', async () => {
|
|
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/open/foo.git' });
|
|
const result = await runPipeline(repo, ['--plan']);
|
|
const json = JSON.parse(result.stdout);
|
|
assert.equal(json.remote_class, 'public');
|
|
assert.equal(json.should_be_local_only, true);
|
|
assert.ok(json.leak_warning, 'expected leak_warning when public remote and STATE not gitignored');
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
test('--plan: public remote + STATE gitignored → no leak_warning, should_commit_state false', async () => {
|
|
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/open/foo.git', gitignoreState: true });
|
|
const result = await runPipeline(repo, ['--plan']);
|
|
const json = JSON.parse(result.stdout);
|
|
assert.equal(json.remote_class, 'public');
|
|
assert.equal(json.state_gitignored, true);
|
|
assert.equal(json.should_commit_state, false);
|
|
assert.equal(json.leak_warning, null);
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
test('--plan: private remote → should_commit_state true, not local-only', async () => {
|
|
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' });
|
|
const result = await runPipeline(repo, ['--plan']);
|
|
const json = JSON.parse(result.stdout);
|
|
assert.equal(json.remote_class, 'private');
|
|
assert.equal(json.should_be_local_only, false);
|
|
assert.equal(json.should_commit_state, true);
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
test('--plan: dirty_files keeps the first character of a worktree-only change', async () => {
|
|
// Regression (smoke-test 2026-08-09): `git status --porcelain` encodes the
|
|
// worktree status in column 2, so a modified-but-unstaged file is " M path".
|
|
// gitOk() trimmed the whole command output, eating that leading space, and
|
|
// the fixed slice(3) then ate the first character of the FIRST path:
|
|
// "app.js" was reported as "pp.js". Only the first line is affected, which is
|
|
// why it survived every existing test — none asserted dirty_files values.
|
|
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' });
|
|
writeFileSync(join(repo, 'README.md'), '# test modified\n', 'utf-8'); // tracked → " M README.md"
|
|
writeFileSync(join(repo, 'zz-untracked.txt'), 'x\n', 'utf-8'); // sorts after → "?? zz-..."
|
|
const result = await runPipeline(repo, ['--plan']);
|
|
const json = JSON.parse(result.stdout);
|
|
assert.ok(
|
|
json.dirty_files.includes('README.md'),
|
|
`first dirty path truncated: ${JSON.stringify(json.dirty_files)}`
|
|
);
|
|
assert.ok(json.dirty_files.includes('zz-untracked.txt'), 'untracked path missing');
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
// ---------- leak hard-gate (public remote + STATE.md not gitignored) ----------
|
|
|
|
test('--plan: public remote + STATE not gitignored BLOCKS the ritual (errors, no commit)', async () => {
|
|
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/open/foo.git' });
|
|
const result = await runPipeline(repo, ['--plan']);
|
|
const json = JSON.parse(result.stdout);
|
|
assert.equal(json.should_commit_state, false, 'must not advertise STATE.md as committable');
|
|
assert.ok(
|
|
json.errors.some(e => /gitignore/i.test(e)),
|
|
`expected a blocking leak error naming the remedy, got ${JSON.stringify(json.errors)}`
|
|
);
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
test('--commit refuses to stage STATE.md on a public remote when it is not gitignored', async () => {
|
|
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/open/foo.git' });
|
|
writeFileSync(join(repo, 'STATE.md'), '# STATE would leak\n');
|
|
const result = await runPipeline(repo, ['--commit']);
|
|
const json = JSON.parse(result.stdout);
|
|
const tracked = execFileSync('git', ['ls-files', 'STATE.md'], { cwd: repo, encoding: 'utf-8' }).trim();
|
|
assert.equal(tracked, '', 'STATE.md must never become tracked on a public remote');
|
|
assert.ok(
|
|
json.errors.some(e => /gitignore/i.test(e)),
|
|
`expected a blocking leak error, got ${JSON.stringify(json.errors)}`
|
|
);
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
// ---------- --commit ----------
|
|
|
|
test('--commit on private repo stages and commits ONLY STATE.md', async () => {
|
|
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' });
|
|
writeFileSync(join(repo, 'STATE.md'), '# STATE\n');
|
|
writeFileSync(join(repo, 'unrelated.txt'), 'user work\n');
|
|
const result = await runPipeline(repo, ['--commit']);
|
|
const json = JSON.parse(result.stdout);
|
|
assert.ok(json.actions_taken.includes('committed'), `expected committed, got ${JSON.stringify(json.actions_taken)}`);
|
|
const head = execFileSync('git', ['show', '--name-only', '--pretty=', 'HEAD'], { cwd: repo, encoding: 'utf-8' })
|
|
.trim().split('\n').filter(Boolean);
|
|
assert.deepEqual(head, ['STATE.md'], `HEAD should contain only STATE.md, got ${JSON.stringify(head)}`);
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
test('--commit never stages unrelated dirty files (no git add -A regression)', async () => {
|
|
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' });
|
|
writeFileSync(join(repo, 'STATE.md'), '# STATE\n');
|
|
writeFileSync(join(repo, 'unrelated-1.txt'), 'user work\n');
|
|
writeFileSync(join(repo, 'unrelated-2.md'), '# notes\n');
|
|
await runPipeline(repo, ['--commit']);
|
|
const status = execFileSync('git', ['status', '--porcelain'], { cwd: repo, encoding: 'utf-8' });
|
|
assert.match(status, /unrelated-1\.txt/);
|
|
assert.match(status, /unrelated-2\.md/);
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
test('--commit with --also includes the explicit related path', async () => {
|
|
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' });
|
|
writeFileSync(join(repo, 'STATE.md'), '# STATE\n');
|
|
writeFileSync(join(repo, 'code.mjs'), 'export const x = 1;\n');
|
|
writeFileSync(join(repo, 'untouched.txt'), 'leave me\n');
|
|
await runPipeline(repo, ['--commit', '--also', 'code.mjs']);
|
|
const head = execFileSync('git', ['show', '--name-only', '--pretty=', 'HEAD'], { cwd: repo, encoding: 'utf-8' })
|
|
.trim().split('\n').filter(Boolean).sort();
|
|
assert.deepEqual(head, ['STATE.md', 'code.mjs']);
|
|
const status = execFileSync('git', ['status', '--porcelain'], { cwd: repo, encoding: 'utf-8' });
|
|
assert.match(status, /untouched\.txt/);
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
test('--commit on public repo does NOT commit gitignored STATE.md', async () => {
|
|
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/open/foo.git', gitignoreState: true });
|
|
writeFileSync(join(repo, 'STATE.md'), '# STATE local-only\n');
|
|
const result = await runPipeline(repo, ['--commit']);
|
|
const json = JSON.parse(result.stdout);
|
|
assert.ok(
|
|
json.actions_taken.some(a => /local-only-skipped|intet-å-committe/.test(a)),
|
|
`expected local-only skip, got ${JSON.stringify(json.actions_taken)}`
|
|
);
|
|
assert.ok(!json.actions_taken.includes('committed'), 'should not commit gitignored STATE.md');
|
|
// STATE.md stays untracked/ignored, working tree otherwise clean
|
|
const tracked = execFileSync('git', ['ls-files', 'STATE.md'], { cwd: repo, encoding: 'utf-8' }).trim();
|
|
assert.equal(tracked, '', 'STATE.md must not become tracked on a public repo');
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
test('--commit message does not claim a STATE.md update when STATE.md was skipped', async () => {
|
|
// On every `open/` repo STATE.md is gitignored, so the handoff commit carries
|
|
// only the explicit --also paths. The message claimed "oppdater STATE.md"
|
|
// regardless, making git history — the regime's long-term log — systematically
|
|
// wrong about what the commit contains.
|
|
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/open/foo.git', gitignoreState: true });
|
|
writeFileSync(join(repo, 'STATE.md'), '# STATE local-only\n');
|
|
writeFileSync(join(repo, 'code.mjs'), 'export const x = 1;\n');
|
|
const result = await runPipeline(repo, ['--commit', '--also', 'code.mjs']);
|
|
const json = JSON.parse(result.stdout);
|
|
assert.ok(json.actions_taken.includes('committed'), `expected a commit, got ${JSON.stringify(json.actions_taken)}`);
|
|
assert.doesNotMatch(
|
|
json.commit_message,
|
|
/oppdater STATE\.md/,
|
|
`commit claims a STATE.md update it does not contain: ${json.commit_message}`
|
|
);
|
|
assert.match(json.commit_message, /local-only/, 'message should say why STATE.md is absent');
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
test('--commit on detached HEAD is detected (no commit attempted)', async () => {
|
|
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' });
|
|
const sha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf-8' }).trim();
|
|
execFileSync('git', ['checkout', '-q', sha], { cwd: repo });
|
|
writeFileSync(join(repo, 'STATE.md'), '# STATE\n');
|
|
const result = await runPipeline(repo, ['--commit']);
|
|
const json = JSON.parse(result.stdout);
|
|
assert.ok(json.errors.some(e => /detached HEAD/i.test(e)), `expected detached HEAD error, got ${JSON.stringify(json.errors)}`);
|
|
assert.ok(!json.actions_taken.includes('committed'), 'should not commit on detached HEAD');
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|
|
|
|
// ---------- --dry-run ----------
|
|
|
|
test('--dry-run writes nothing and creates no commit', async () => {
|
|
const repo = makeTempRepo({ remote: 'ssh://git@git.fromaitochitta.com/ktg/secret.git' });
|
|
const before = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf-8' }).trim();
|
|
const result = await runPipeline(repo, ['--dry-run']);
|
|
const json = JSON.parse(result.stdout);
|
|
assert.equal(json.mode, 'dry-run');
|
|
assert.ok(!existsSync(join(repo, 'STATE.md')), 'dry-run must not write STATE.md');
|
|
const after = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf-8' }).trim();
|
|
assert.equal(before, after, 'dry-run must not create a commit');
|
|
rmSync(repo, { recursive: true, force: true });
|
|
});
|