@marcomigozzi/trailhead 0.15.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.it.md +52 -14
- package/README.md +53 -15
- package/bin/auto-intro-docs.test.js +72 -0
- package/bin/auto-merge-docs.test.js +33 -0
- package/bin/auto-merge-ticket-docs.test.js +32 -0
- package/bin/auto-review-rounds-docs.test.js +45 -0
- package/bin/auto-run-docs.test.js +60 -0
- package/bin/auto-scope-docs.test.js +33 -0
- package/bin/auto-slots-docs.test.js +45 -0
- package/bin/auto-uat-docs.test.js +47 -0
- package/bin/config-dir-templates-docs.test.js +52 -0
- package/bin/decision-builds-docs.test.js +75 -0
- package/bin/fog-vs-ticket-docs.test.js +56 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -0
- package/bin/lib/codex-projection.js +13 -6
- package/bin/lib/codex-projection.test.js +25 -2
- package/bin/lib/host-descriptor.js +14 -1
- package/bin/lib/host-descriptor.test.js +18 -0
- package/bin/lib/runtime-command-literals.test.js +184 -0
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -0
- package/bin/report-status-docs.test.js +1 -1
- package/bin/trailhead.js +3 -3
- package/bin/trailhead.test.js +17 -18
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
- package/plugins/trailhead/agents/trailhead-verify.md +2 -2
- package/plugins/trailhead/commands/auto.md +2 -2
- package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
- package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
- package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
- package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
- package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
- package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.js +50 -21
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +50 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +68 -1
- package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +161 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
- package/plugins/trailhead/hooks/mod/README.md +185 -39
- package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
- package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
- package/plugins/trailhead/hooks/mod/map-pane.tsx +557 -99
- package/plugins/trailhead/hooks/mod/register.tsx +298 -39
- package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
- package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
- package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
- package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
- package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
- package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +336 -23
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +321 -0
- package/plugins/trailhead/skills/_shared/choices.md +7 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -5
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
- package/plugins/trailhead/skills/_shared/substrate.md +4 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +5 -5
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +2 -2
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +4 -3
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +23 -12
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -6
- package/plugins/trailhead/templates/statusline-trailhead.sh +80 -15
- package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
- package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
- package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
- package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
- package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +67 -3
- package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
- package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1839 -72
- package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
- package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
- package/plugins/trailhead/tests/session-registry.test.ts +50 -0
- package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
- package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
- package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
- package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
- package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- package/plugins/trailhead/types/index.d.ts +26 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for templates/trailhead-needs-you.js. Run: node needs-you.test.js
|
|
3
|
+
// No framework: plain asserts, same style as uat-followup.test.js.
|
|
4
|
+
//
|
|
5
|
+
// Layers covered:
|
|
6
|
+
// 1. main() (park, clear) against a fake tracker behind an injected `gh` and guard.
|
|
7
|
+
// 2. End-to-end: the script itself with a fake `gh` on PATH.
|
|
8
|
+
const assert = require('assert');
|
|
9
|
+
const { spawnSync } = require('child_process');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
|
|
14
|
+
const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-needs-you.js');
|
|
15
|
+
const { main } = require(scriptPath);
|
|
16
|
+
|
|
17
|
+
let passed = 0;
|
|
18
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
19
|
+
|
|
20
|
+
const fakeGuard = { scan: (text) => (/ghp_[A-Za-z0-9]{20,}/.test(text) ? 'github token' : null) };
|
|
21
|
+
|
|
22
|
+
// --- fake tracker ------------------------------------------------------------
|
|
23
|
+
// One issue: labels [names], assignees [logins], comments [bodies]. failOn names the
|
|
24
|
+
// gh step that fails: ensure | view | comment | unassign | label | remove.
|
|
25
|
+
function makeTracker(init = {}) {
|
|
26
|
+
const t = { labels: init.labels || [], assignees: init.assignees || [], comments: init.comments || [], failOn: init.failOn || null, badJson: !!init.badJson, calls: [], ensured: false };
|
|
27
|
+
const res = (stdout, status = 0) => ({ status, stdout, stderr: status ? 'forced failure' : '' });
|
|
28
|
+
const step = (name, fn) => (t.failOn === name ? res('', 1) : fn());
|
|
29
|
+
t.gh = (args) => {
|
|
30
|
+
t.calls.push(args.join(' '));
|
|
31
|
+
const [a, b] = args;
|
|
32
|
+
if (a === 'label' && b === 'create') return step('ensure', () => { t.ensured = true; return res(''); });
|
|
33
|
+
if (a === 'issue' && b === 'view') {
|
|
34
|
+
return step('view', () => (t.badJson ? res('not json') : res(JSON.stringify({
|
|
35
|
+
labels: t.labels.map((name) => ({ name })),
|
|
36
|
+
assignees: t.assignees.map((login) => ({ login })),
|
|
37
|
+
comments: t.comments.map((body) => ({ body })),
|
|
38
|
+
}))));
|
|
39
|
+
}
|
|
40
|
+
if (a === 'issue' && b === 'comment') return step('comment', () => { t.comments.push(args[args.indexOf('--body') + 1]); return res(''); });
|
|
41
|
+
if (a === 'issue' && b === 'edit') {
|
|
42
|
+
if (args.includes('--remove-assignee')) return step('unassign', () => { t.assignees = []; return res(''); });
|
|
43
|
+
if (args.includes('--add-label')) return step('label', () => { t.labels.push(args[args.indexOf('--add-label') + 1]); return res(''); });
|
|
44
|
+
if (args.includes('--remove-label')) return step('remove', () => { const l = args[args.indexOf('--remove-label') + 1]; t.labels = t.labels.filter((x) => x !== l); return res(''); });
|
|
45
|
+
}
|
|
46
|
+
return res('', 1);
|
|
47
|
+
};
|
|
48
|
+
return t;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function run(t, argv, extra = {}) {
|
|
52
|
+
let out = '';
|
|
53
|
+
let errOut = '';
|
|
54
|
+
const code = main(argv, { gh: t.gh, guard: fakeGuard, stdout: { write: (s) => { out += s; } }, stderr: { write: (s) => { errOut += s; } }, ...extra });
|
|
55
|
+
let json = null;
|
|
56
|
+
try { json = JSON.parse(out.trim()); } catch { /* not json */ }
|
|
57
|
+
return { code, out, json, errOut };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const park = (reason = 'Decide between A and B') => ['park', '--issue', '7', '--reason', reason, '--repo', 'o/r'];
|
|
61
|
+
const clear = ['clear', '--issue', '7', '--repo', 'o/r'];
|
|
62
|
+
const idx = (t, re) => t.calls.findIndex((c) => re.test(c));
|
|
63
|
+
|
|
64
|
+
// --- park: order and output ----------------------------------------------------
|
|
65
|
+
{
|
|
66
|
+
const t = makeTracker({ assignees: ['me'], labels: ['trailhead:ticket'] });
|
|
67
|
+
const r = run(t, park());
|
|
68
|
+
ok('park ok, one JSON line', r.code === 0 && r.json.status === 'ok' && r.out.trim().split('\n').length === 1);
|
|
69
|
+
ok('park output fields', r.json.issue === 7 && r.json.commented === true && r.json.unassigned === true && r.json.labelled === true);
|
|
70
|
+
ok('comment body is NEEDS YOU, a blank line, then the reason', t.comments[0] === 'NEEDS YOU\n\nDecide between A and B');
|
|
71
|
+
const iE = idx(t, /^label create trailhead:needs-you .*--force/);
|
|
72
|
+
const iV = idx(t, /^issue view 7 --json labels,assignees,comments/);
|
|
73
|
+
const iC = idx(t, /^issue comment 7/);
|
|
74
|
+
const iU = idx(t, /--remove-assignee @me/);
|
|
75
|
+
const iL = idx(t, /--add-label trailhead:needs-you/);
|
|
76
|
+
ok('call order: ensure, view, comment, unassign, label', iE >= 0 && iE < iV && iV < iC && iC < iU && iU < iL);
|
|
77
|
+
ok('label ensured with the color and description', t.calls[iE].includes('--color D93F0B') && t.calls[iE].includes('--description'));
|
|
78
|
+
ok('-R passed on every call', t.calls.every((c) => c.includes('-R o/r')));
|
|
79
|
+
ok('final state: unassigned and labelled', t.assignees.length === 0 && t.labels.includes('trailhead:needs-you'));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- park: idempotence ---------------------------------------------------------
|
|
83
|
+
{
|
|
84
|
+
const t = makeTracker({ assignees: ['me'] });
|
|
85
|
+
run(t, park());
|
|
86
|
+
const before = t.comments.length;
|
|
87
|
+
const r = run(t, park(' Decide between A\nand B '));
|
|
88
|
+
ok('same reason (whitespace-normalised) posts no second comment', t.comments.length === before && r.json.commented === false);
|
|
89
|
+
ok('re-park on an already-labelled unassigned ticket is ok', r.code === 0 && r.json.unassigned === false && r.json.labelled === false && t.labels.filter((l) => l === 'trailhead:needs-you').length === 1);
|
|
90
|
+
const r2 = run(t, park('A different reason'));
|
|
91
|
+
ok('a different reason posts a new comment', r2.json.commented === true && t.comments.length === before + 1);
|
|
92
|
+
const r3 = run(t, park('Decide between A and B'));
|
|
93
|
+
ok('only the latest NEEDS YOU comment is compared', r3.json.commented === true);
|
|
94
|
+
const t2 = makeTracker({ comments: ['NEEDS YOUR attention\n\nx', 'unrelated'] });
|
|
95
|
+
ok('a non NEEDS YOU comment does not dedup', run(t2, park('x')).json.commented === true);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- park: guards and failures --------------------------------------------------
|
|
99
|
+
{
|
|
100
|
+
const t = makeTracker({ assignees: ['me'] });
|
|
101
|
+
const r = run(t, park('token ghp_abcdefghijklmnopqrstuvwxyz0123'));
|
|
102
|
+
ok('a secret in the reason writes nothing', r.code === 1 && r.json.status === 'error' && /secret/.test(r.json.reason) && t.calls.length === 0);
|
|
103
|
+
const t2 = makeTracker();
|
|
104
|
+
const r2 = run(t2, park(), { guard: null });
|
|
105
|
+
ok('a missing guard fails closed', r2.code === 1 && r2.json.status === 'error' && t2.calls.length === 0);
|
|
106
|
+
}
|
|
107
|
+
for (const [failOn, later] of [['ensure', ['view', 'comment', 'unassign', 'label']], ['view', ['comment', 'unassign', 'label']], ['comment', ['unassign', 'label']], ['unassign', ['label']], ['label', []]]) {
|
|
108
|
+
const t = makeTracker({ assignees: ['me'], failOn });
|
|
109
|
+
const r = run(t, park());
|
|
110
|
+
ok(`failing ${failOn} is status error, exit 1`, r.code === 1 && r.json.status === 'error' && r.json.reason.length > 0);
|
|
111
|
+
ok(`after ${failOn} fails, later steps do not run`, later.every((s) => !t.calls.some((c) => ({ view: /issue view/, comment: /issue comment/, unassign: /--remove-assignee/, label: /--add-label/ })[s].test(c))));
|
|
112
|
+
}
|
|
113
|
+
{
|
|
114
|
+
const t = makeTracker({ assignees: ['me'], failOn: 'unassign' });
|
|
115
|
+
run(t, park());
|
|
116
|
+
ok('a failed unassign leaves no label', !t.labels.includes('trailhead:needs-you'));
|
|
117
|
+
const tb = makeTracker({ badJson: true });
|
|
118
|
+
const r = run(tb, park());
|
|
119
|
+
ok('unparseable view JSON is an error, nothing written', r.code === 1 && r.json.status === 'error' && tb.comments.length === 0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- clear ----------------------------------------------------------------------
|
|
123
|
+
{
|
|
124
|
+
const t = makeTracker({ labels: ['trailhead:ticket', 'trailhead:needs-you'] });
|
|
125
|
+
const r = run(t, clear);
|
|
126
|
+
ok('clear removes the label when present', r.code === 0 && r.json.status === 'ok' && r.json.issue === 7 && r.json.cleared === true && !t.labels.includes('trailhead:needs-you'));
|
|
127
|
+
const r2 = run(t, clear);
|
|
128
|
+
ok('clear is a no-op when absent', r2.code === 0 && r2.json.cleared === false && t.calls.filter((c) => /--remove-label/.test(c)).length === 1);
|
|
129
|
+
const tf = makeTracker({ labels: ['trailhead:needs-you'], failOn: 'remove' });
|
|
130
|
+
ok('clear errors on a failed removal', run(tf, clear).code === 1);
|
|
131
|
+
const tv = makeTracker({ failOn: 'view' });
|
|
132
|
+
const rv = run(tv, clear);
|
|
133
|
+
ok('clear errors on a failed read', rv.code === 1 && rv.json.status === 'error');
|
|
134
|
+
ok('clear errors on unparseable JSON', run(makeTracker({ badJson: true }), clear).code === 1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- usage ----------------------------------------------------------------------
|
|
138
|
+
for (const argv of [[], ['bogus'], ['park'], ['park', '--issue', '7', '--repo', 'o/r'], ['park', '--issue', 'x', '--reason', 'r', '--repo', 'o/r'],
|
|
139
|
+
['park', '--issue', '7', '--reason', ' ', '--repo', 'o/r'], ['park', '--issue', '7', '--reason', 'r'], ['clear', '--issue', '7'], ['clear', '--repo', 'o/r'],
|
|
140
|
+
['clear', '--issue', '7', '--repo', 'nope'], ['clear', '--issue', '7', '--repo', 'o/r', '--reason', 'r'], ['clear', '--issue']]) {
|
|
141
|
+
const t = makeTracker();
|
|
142
|
+
const r = run(t, argv);
|
|
143
|
+
ok(`usage error exit 2: ${argv.join(' ')}`, r.code === 2 && t.calls.length === 0 && r.errOut.length > 0);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// --- end-to-end with a fake gh on PATH --------------------------------------------
|
|
147
|
+
{
|
|
148
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'needs-you-'));
|
|
149
|
+
const binDir = path.join(tmp, 'bin');
|
|
150
|
+
fs.mkdirSync(binDir);
|
|
151
|
+
const log = path.join(tmp, 'calls.log');
|
|
152
|
+
const script = `#!/bin/sh
|
|
153
|
+
echo "$*" >> "${log}"
|
|
154
|
+
case "$*" in
|
|
155
|
+
"issue view"*)
|
|
156
|
+
if grep -q -- '--add-label' "${log}"; then L='{"name":"trailhead:needs-you"}'; else L=''; fi
|
|
157
|
+
echo "{\\"labels\\":[$L],\\"assignees\\":[{\\"login\\":\\"me\\"}],\\"comments\\":[]}" ;;
|
|
158
|
+
*) exit 0 ;;
|
|
159
|
+
esac
|
|
160
|
+
`;
|
|
161
|
+
fs.writeFileSync(path.join(binDir, 'gh'), script, { mode: 0o755 });
|
|
162
|
+
const env = { ...process.env, PATH: binDir + path.delimiter + process.env.PATH };
|
|
163
|
+
const res = spawnSync('node', [scriptPath, ...park('Needs a human call')], { encoding: 'utf8', env });
|
|
164
|
+
const j = JSON.parse(res.stdout.trim());
|
|
165
|
+
ok('e2e: park prints one ok JSON line', res.status === 0 && j.status === 'ok' && j.commented === true && j.unassigned === true);
|
|
166
|
+
const calls = fs.readFileSync(log, 'utf8');
|
|
167
|
+
ok('e2e: comment before unassign before label', calls.indexOf('issue comment') < calls.indexOf('--remove-assignee') && calls.indexOf('--remove-assignee') < calls.indexOf('--add-label'));
|
|
168
|
+
const cl = spawnSync('node', [scriptPath, ...clear], { encoding: 'utf8', env });
|
|
169
|
+
ok('e2e: clear removes the present label', cl.status === 0 && JSON.parse(cl.stdout.trim()).cleared === true && fs.readFileSync(log, 'utf8').includes('--remove-label trailhead:needs-you'));
|
|
170
|
+
fs.writeFileSync(path.join(binDir, 'gh'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
|
|
171
|
+
const bad = spawnSync('node', [scriptPath, ...clear], { encoding: 'utf8', env });
|
|
172
|
+
ok('e2e: gh failing exits 1 with an error line', bad.status === 1 && JSON.parse(bad.stdout.trim()).status === 'error');
|
|
173
|
+
ok('e2e: usage error exits 2', spawnSync('node', [scriptPath, '--bogus'], { encoding: 'utf8' }).status === 2);
|
|
174
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.log(`needs-you.test.js: ${passed} checks passed`);
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for templates/trailhead-plan-review-rounds.js. Run: node plan-review-rounds.test.js
|
|
3
|
+
// No framework: plain asserts, same style as auto-slot.test.js.
|
|
4
|
+
//
|
|
5
|
+
// Layers covered:
|
|
6
|
+
// 1. ask: config lookup, roster resolution, host drop, unknown names, options menu.
|
|
7
|
+
// 2. round: converged / continue / exhausted / no-progress / ceiling and their precedence.
|
|
8
|
+
// 3. Usage errors (exit 2).
|
|
9
|
+
// 4. End-to-end: the script itself through spawnSync.
|
|
10
|
+
const assert = require('assert');
|
|
11
|
+
const { spawnSync } = require('child_process');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
|
|
16
|
+
const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-plan-review-rounds.js');
|
|
17
|
+
const { main } = require(scriptPath);
|
|
18
|
+
|
|
19
|
+
let passed = 0;
|
|
20
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
21
|
+
|
|
22
|
+
function run(argv, deps = {}) {
|
|
23
|
+
let out = '';
|
|
24
|
+
let errOut = '';
|
|
25
|
+
const code = main(argv, { stdout: { write: (s) => { out += s; } }, stderr: { write: (s) => { errOut += s; } }, ...deps });
|
|
26
|
+
let json = null;
|
|
27
|
+
try { json = JSON.parse(out.trim()); } catch { /* not json */ }
|
|
28
|
+
return { code, out, json, errOut };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const tmp = () => fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'plan-review-rounds-')));
|
|
32
|
+
const rm = (d) => fs.rmSync(d, { recursive: true, force: true });
|
|
33
|
+
|
|
34
|
+
// A project root and a global config dir holding the given config objects (null = no file).
|
|
35
|
+
function setup(project, global) {
|
|
36
|
+
const root = tmp();
|
|
37
|
+
const gdir = tmp();
|
|
38
|
+
if (project !== null && project !== undefined) {
|
|
39
|
+
fs.mkdirSync(path.join(root, '.trailhead'), { recursive: true });
|
|
40
|
+
fs.writeFileSync(path.join(root, '.trailhead', 'config.json'), typeof project === 'string' ? project : JSON.stringify(project));
|
|
41
|
+
}
|
|
42
|
+
if (global !== null && global !== undefined) {
|
|
43
|
+
fs.mkdirSync(path.join(gdir, 'trailhead'), { recursive: true });
|
|
44
|
+
fs.writeFileSync(path.join(gdir, 'trailhead', 'config.json'), typeof global === 'string' ? global : JSON.stringify(global));
|
|
45
|
+
}
|
|
46
|
+
return { root, gdir, done: () => { rm(root); rm(gdir); } };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Deps with a fixed set of binaries "on PATH" and a canned kimi --help.
|
|
50
|
+
const deps = (gdir, installed, kimiHelp = '--output-format json') => ({
|
|
51
|
+
env: { CLAUDE_CONFIG_DIR: gdir, PATH: '/nowhere' },
|
|
52
|
+
which: (name) => installed.includes(name),
|
|
53
|
+
kimiHelp: () => kimiHelp,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const ask = (s, installed, extra = [], kimiHelp) => run(['ask', '--repo', s.root, ...extra], deps(s.gdir, installed, kimiHelp));
|
|
57
|
+
const values = (r) => r.json.options.map((o) => o.value);
|
|
58
|
+
|
|
59
|
+
// --- ask: skip ---------------------------------------------------------------
|
|
60
|
+
{
|
|
61
|
+
const s = setup({}, null);
|
|
62
|
+
const r = ask(s, ['codex', 'gemini']);
|
|
63
|
+
ok('ask: plan_review unset is skip off', r.code === 0 && r.json.status === 'skip' && r.json.reason === 'off' && r.json.configured === 2);
|
|
64
|
+
s.done();
|
|
65
|
+
}
|
|
66
|
+
{
|
|
67
|
+
const s = setup({ plan_review: 'off', 'plan_review.rounds': 4 }, null);
|
|
68
|
+
const r = ask(s, ['codex']);
|
|
69
|
+
ok('ask: plan_review off is skip off with the configured rounds', r.json.status === 'skip' && r.json.reason === 'off' && r.json.configured === 4);
|
|
70
|
+
s.done();
|
|
71
|
+
}
|
|
72
|
+
{
|
|
73
|
+
const s = setup({ plan_review: 'on' }, null);
|
|
74
|
+
const r = ask(s, []);
|
|
75
|
+
ok('ask: on with nothing installed is skip no-reviewer', r.code === 0 && r.json.status === 'skip' && r.json.reason === 'no-reviewer');
|
|
76
|
+
const own = ask(s, ['claude']);
|
|
77
|
+
ok('ask: only the host CLI installed is skip no-reviewer', own.json.status === 'skip' && own.json.reason === 'no-reviewer');
|
|
78
|
+
const codexHost = ask(s, ['codex'], ['--host', 'codex']);
|
|
79
|
+
ok('ask: --host codex drops codex', codexHost.json.status === 'skip' && codexHost.json.reason === 'no-reviewer');
|
|
80
|
+
const claudeOnCodex = ask(s, ['claude'], ['--host', 'codex']);
|
|
81
|
+
ok('ask: --host codex keeps claude as an independent reviewer', claudeOnCodex.json.status === 'ask' && JSON.stringify(claudeOnCodex.json.reviewers) === '["claude"]');
|
|
82
|
+
s.done();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- ask: roster resolution --------------------------------------------------
|
|
86
|
+
{
|
|
87
|
+
const s = setup({ plan_review: 'on' }, null);
|
|
88
|
+
const r = ask(s, ['claude', 'codex', 'gemini', 'agy', 'cursor-agent', 'not-in-roster']);
|
|
89
|
+
ok('ask: on resolves the whole roster minus the host, in roster order', r.json.status === 'ask' && JSON.stringify(r.json.reviewers) === JSON.stringify(['gemini', 'codex', 'agy', 'cursor-agent']));
|
|
90
|
+
ok('ask: on ignores binaries outside the roster', !r.json.reviewers.includes('not-in-roster') && r.json.unknown.length === 0);
|
|
91
|
+
ok('ask: reports the configured count', r.json.configured === 2);
|
|
92
|
+
s.done();
|
|
93
|
+
}
|
|
94
|
+
{
|
|
95
|
+
const s = setup({ plan_review: 'codex, gemini' }, null);
|
|
96
|
+
const r = ask(s, ['codex', 'gemini', 'qwen']);
|
|
97
|
+
ok('ask: a comma list restricts the candidates', JSON.stringify(r.json.reviewers) === JSON.stringify(['codex', 'gemini']));
|
|
98
|
+
const only = ask(s, ['gemini']);
|
|
99
|
+
ok('ask: a listed CLI that is not installed is not counted', JSON.stringify(only.json.reviewers) === '["gemini"]' && only.json.unknown.length === 0);
|
|
100
|
+
s.done();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// --- ask: kimi probe ---------------------------------------------------------
|
|
104
|
+
{
|
|
105
|
+
const s = setup({ plan_review: 'kimi' }, null);
|
|
106
|
+
ok('ask: kimi counts when --help mentions --output-format', ask(s, ['kimi'], [], 'usage: kimi --output-format').json.status === 'ask');
|
|
107
|
+
const legacy = ask(s, ['kimi'], [], 'usage: kimi [--print]');
|
|
108
|
+
ok('ask: legacy kimi (no --output-format) does not count', legacy.json.status === 'skip' && legacy.json.reason === 'no-reviewer');
|
|
109
|
+
const noHelp = ask(s, ['kimi'], [], null);
|
|
110
|
+
ok('ask: a failing kimi --help does not count', noHelp.json.status === 'skip');
|
|
111
|
+
let probes = 0;
|
|
112
|
+
const o = run(['ask', '--repo', s.root], { ...deps(s.gdir, []), kimiHelp: () => { probes++; return '--output-format'; } });
|
|
113
|
+
ok('ask: kimi is not probed when its binary is absent', probes === 0 && o.json.status === 'skip');
|
|
114
|
+
s.done();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- ask: unknown names ------------------------------------------------------
|
|
118
|
+
{
|
|
119
|
+
const s = setup({ plan_review: 'codex,my-local,tpyo' }, null);
|
|
120
|
+
const r = ask(s, ['codex', 'my-local']);
|
|
121
|
+
ok('ask: a custom reviewer counts when its binary is on PATH', JSON.stringify(r.json.reviewers) === JSON.stringify(['codex', 'my-local']));
|
|
122
|
+
ok('ask: a custom reviewer on PATH is not unknown', !r.json.unknown.includes('my-local'));
|
|
123
|
+
ok('ask: an unknown name with no binary is listed, not counted, and warned', JSON.stringify(r.json.unknown) === '["tpyo"]' && !r.json.reviewers.includes('tpyo') && r.json.warnings.some((w) => w.includes('tpyo')));
|
|
124
|
+
const typoOnly = setup({ plan_review: 'tpyo' }, null);
|
|
125
|
+
const t = ask(typoOnly, ['codex']);
|
|
126
|
+
ok('ask: a list of only unknown absent names is skip no-reviewer, still reporting unknown', t.json.status === 'skip' && t.json.reason === 'no-reviewer' && JSON.stringify(t.json.unknown) === '["tpyo"]');
|
|
127
|
+
typoOnly.done();
|
|
128
|
+
s.done();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- ask: options menu -------------------------------------------------------
|
|
132
|
+
{
|
|
133
|
+
const mk = (rounds) => {
|
|
134
|
+
const s = setup({ plan_review: 'codex', 'plan_review.rounds': rounds }, null);
|
|
135
|
+
const r = ask(s, ['codex']);
|
|
136
|
+
s.done();
|
|
137
|
+
return r;
|
|
138
|
+
};
|
|
139
|
+
const r2 = mk(2);
|
|
140
|
+
ok('options: n=2 is config(2) Recommended first, then 1, 3, until', JSON.stringify(values(r2)) === JSON.stringify(['config', '1', '3', 'until']) && r2.json.options[0].recommended === true && r2.json.options[0].label === 'As in config (2)');
|
|
141
|
+
ok('options: only the first entry is recommended', r2.json.options.slice(1).every((o) => !o.recommended));
|
|
142
|
+
ok('options: every entry has a human label', r2.json.options.every((o) => typeof o.label === 'string' && o.label.length > 0));
|
|
143
|
+
ok('options: n=1 is config, 2, 3, until', JSON.stringify(values(mk(1))) === JSON.stringify(['config', '2', '3', 'until']));
|
|
144
|
+
ok('options: n=3 is config, 1, 2, until', JSON.stringify(values(mk(3))) === JSON.stringify(['config', '1', '2', 'until']));
|
|
145
|
+
const r5 = mk(5);
|
|
146
|
+
ok('options: n=5 is config(5), 1, 2, until', JSON.stringify(values(r5)) === JSON.stringify(['config', '1', '2', 'until']) && r5.json.options[0].label === 'As in config (5)');
|
|
147
|
+
ok('options: never more than four entries', [1, 2, 3, 4, 7, 12].every((n) => mk(n).json.options.length <= 4));
|
|
148
|
+
ok('options: the numbered-list fallback lists all five', JSON.stringify(r2.json.fallback.map((o) => o.value)) === JSON.stringify(['config', '1', '2', '3', 'until']));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- ask: rounds fallback ----------------------------------------------------
|
|
152
|
+
{
|
|
153
|
+
for (const bad of [0, -1, 1.5, 'two', null]) {
|
|
154
|
+
const s = setup({ plan_review: 'codex', 'plan_review.rounds': bad }, null);
|
|
155
|
+
const r = ask(s, ['codex']);
|
|
156
|
+
ok(`rounds: ${JSON.stringify(bad)} falls back to 2 with a warning`, r.code === 0 && r.json.configured === 2 && r.json.warnings.some((w) => w.includes('plan_review.rounds')));
|
|
157
|
+
s.done();
|
|
158
|
+
}
|
|
159
|
+
const s = setup({ plan_review: 'codex' }, null);
|
|
160
|
+
const r = ask(s, ['codex']);
|
|
161
|
+
ok('rounds: unset is 2 without a warning', r.json.configured === 2 && r.json.warnings.length === 0);
|
|
162
|
+
s.done();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- ask: config lookup ------------------------------------------------------
|
|
166
|
+
{
|
|
167
|
+
const s = setup({ 'plan_review.rounds': 3 }, { plan_review: 'codex', 'plan_review.rounds': 5 });
|
|
168
|
+
const r = ask(s, ['codex']);
|
|
169
|
+
ok('config: keys resolve one by one, nearest wins (global plan_review, project rounds)', r.json.status === 'ask' && r.json.configured === 3 && JSON.stringify(r.json.reviewers) === '["codex"]');
|
|
170
|
+
s.done();
|
|
171
|
+
}
|
|
172
|
+
{
|
|
173
|
+
const s = setup({ plan_review: 'off' }, { plan_review: 'on' });
|
|
174
|
+
ok('config: the project value beats the global one', ask(s, ['codex']).json.reason === 'off');
|
|
175
|
+
s.done();
|
|
176
|
+
}
|
|
177
|
+
{
|
|
178
|
+
const s = setup(null, { plan_review: 'on' });
|
|
179
|
+
ok('config: a global-only config applies', ask(s, ['codex']).json.status === 'ask');
|
|
180
|
+
s.done();
|
|
181
|
+
}
|
|
182
|
+
{
|
|
183
|
+
const s = setup({ plan_review: 'on' }, null);
|
|
184
|
+
const home = tmp();
|
|
185
|
+
fs.mkdirSync(path.join(home, '.claude', 'trailhead'), { recursive: true });
|
|
186
|
+
fs.writeFileSync(path.join(home, '.claude', 'trailhead', 'config.json'), JSON.stringify({ 'plan_review.rounds': 4 }));
|
|
187
|
+
const r = run(['ask', '--repo', s.root], { env: { HOME: home, PATH: '/nowhere' }, which: (n) => n === 'codex', kimiHelp: () => null });
|
|
188
|
+
ok('config: without CLAUDE_CONFIG_DIR the global file is ~/.claude/trailhead/config.json', r.json.configured === 4);
|
|
189
|
+
rm(home);
|
|
190
|
+
s.done();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// --- ask: invalid config -----------------------------------------------------
|
|
194
|
+
{
|
|
195
|
+
const bad = setup('{not json', null);
|
|
196
|
+
const r = ask(bad, ['codex']);
|
|
197
|
+
ok('error: an unparseable project config is status error, exit 1', r.code === 1 && r.json.status === 'error' && /JSON/.test(r.json.reason));
|
|
198
|
+
bad.done();
|
|
199
|
+
const arr = setup('[1]', null);
|
|
200
|
+
ok('error: a non-object config is status error', ask(arr, ['codex']).json.status === 'error');
|
|
201
|
+
arr.done();
|
|
202
|
+
const gl = setup({}, '{nope');
|
|
203
|
+
ok('error: an unparseable global config is status error', ask(gl, ['codex']).code === 1);
|
|
204
|
+
gl.done();
|
|
205
|
+
const typ = setup({ plan_review: 7 }, null);
|
|
206
|
+
ok('error: a non-string plan_review is status error', ask(typ, ['codex']).json.status === 'error');
|
|
207
|
+
typ.done();
|
|
208
|
+
const empty = setup({ plan_review: ' , ' }, null);
|
|
209
|
+
ok('error: a plan_review list with no name is status error', ask(empty, ['codex']).json.status === 'error');
|
|
210
|
+
empty.done();
|
|
211
|
+
}
|
|
212
|
+
{
|
|
213
|
+
const s = setup({ plan_review: 'on' }, null);
|
|
214
|
+
const failing = { readFileSync: () => { const e = new Error('EACCES: denied'); e.code = 'EACCES'; throw e; } };
|
|
215
|
+
const r = run(['ask', '--repo', s.root], { ...deps(s.gdir, ['codex']), fs: failing });
|
|
216
|
+
ok('error: an unreadable config (not ENOENT) is status error', r.code === 1 && r.json.status === 'error' && /cannot read/.test(r.json.reason));
|
|
217
|
+
s.done();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- ask: real PATH scan -----------------------------------------------------
|
|
221
|
+
{
|
|
222
|
+
const bin = tmp();
|
|
223
|
+
fs.writeFileSync(path.join(bin, 'gemini'), '#!/bin/sh\n', { mode: 0o755 });
|
|
224
|
+
fs.writeFileSync(path.join(bin, 'qwen'), '#!/bin/sh\n', { mode: 0o644 });
|
|
225
|
+
fs.mkdirSync(path.join(bin, 'codex'));
|
|
226
|
+
const s = setup({ plan_review: 'on' }, null);
|
|
227
|
+
const r = run(['ask', '--repo', s.root], { env: { CLAUDE_CONFIG_DIR: s.gdir, PATH: bin }, kimiHelp: () => null });
|
|
228
|
+
ok('path: the default scan finds executable files only', r.json.status === 'ask' && JSON.stringify(r.json.reviewers) === '["gemini"]');
|
|
229
|
+
s.done();
|
|
230
|
+
rm(bin);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// --- round -------------------------------------------------------------------
|
|
234
|
+
const c = (key, text) => ({ key, text: text || `full text of ${key}` });
|
|
235
|
+
function round(history, choice, configured, extra = {}) {
|
|
236
|
+
const dir = tmp();
|
|
237
|
+
const file = path.join(dir, 'history.json');
|
|
238
|
+
fs.writeFileSync(file, typeof history === 'string' ? history : JSON.stringify(history));
|
|
239
|
+
const r = run(['round', '--choice', choice, '--configured', String(configured), '--history', file], extra);
|
|
240
|
+
rm(dir);
|
|
241
|
+
return r;
|
|
242
|
+
}
|
|
243
|
+
const decision = (r) => r.json.decision;
|
|
244
|
+
|
|
245
|
+
ok('round: a latest round with no concern is converged', decision(round([[c('a')], []], '2', 2)) === 'converged' && round([[]], 'until', 2).json.rounds === 1);
|
|
246
|
+
ok('round: converged carries no surviving', !('surviving' in round([[]], '1', 2).json));
|
|
247
|
+
ok('round: a fixed choice with rounds left continues', decision(round([[c('a')]], '2', 2)) === 'continue' && !('surviving' in round([[c('a')]], '2', 2).json));
|
|
248
|
+
{
|
|
249
|
+
const r = round([[c('a', 'A is wrong because X'), c('b')], [c('a', 'A is still wrong because X')]], '2', 2);
|
|
250
|
+
ok('round: a fixed choice with rounds spent is exhausted', decision(r) === 'exhausted');
|
|
251
|
+
ok('round: exhausted surviving carries the full {key,text} of the latest round', JSON.stringify(r.json.surviving) === JSON.stringify([c('a', 'A is still wrong because X')]));
|
|
252
|
+
}
|
|
253
|
+
ok('round: config resolves to --configured', decision(round([[c('a')]], 'config', 1)) === 'exhausted' && decision(round([[c('a')]], 'config', 3)) === 'continue');
|
|
254
|
+
ok('round: exhausted is not reported for a fixed choice before its rounds', decision(round([[c('a')], [c('b')]], '3', 2)) === 'continue');
|
|
255
|
+
ok('round: a fixed choice never reports no-progress', decision(round([[c('a')], [c('a')]], '3', 2)) === 'continue');
|
|
256
|
+
ok('round: until continues while concerns change', decision(round([[c('a')], [c('a'), c('b')], [c('b')]], 'until', 2)) === 'continue');
|
|
257
|
+
ok('round: until with round 1 never reports no-progress', decision(round([[c('a')]], 'until', 2)) === 'continue');
|
|
258
|
+
{
|
|
259
|
+
const r = round([[c('a'), c('b')], [c('a'), c('b'), c('c', 'new one')]], 'until', 2);
|
|
260
|
+
ok('round: until with none of the previous concerns resolved is no-progress', decision(r) === 'no-progress' && r.json.surviving.length === 3 && r.json.surviving[2].text === 'new one');
|
|
261
|
+
}
|
|
262
|
+
ok('round: until with one previous concern resolved keeps going', decision(round([[c('a'), c('b')], [c('a')]], 'until', 2)) === 'continue');
|
|
263
|
+
ok('round: keys are compared normalised', decision(round([[c(' Plan Gap ')], [c('plan gap')]], 'until', 2)) === 'no-progress');
|
|
264
|
+
ok('round: until at 5 rounds with progress is ceiling', decision(round([[c('a')], [c('b')], [c('c')], [c('d')], [c('e')]], 'until', 2)) === 'ceiling');
|
|
265
|
+
ok('round: ceiling carries surviving', round([[c('a')], [c('b')], [c('c')], [c('d')], [c('e')]], 'until', 2).json.surviving[0].key === 'e');
|
|
266
|
+
ok('round: until at 4 rounds still continues', decision(round([[c('a')], [c('b')], [c('c')], [c('d')]], 'until', 2)) === 'continue');
|
|
267
|
+
ok('round: precedence: converged beats ceiling', decision(round([[c('a')], [c('b')], [c('c')], [c('d')], []], 'until', 2)) === 'converged');
|
|
268
|
+
ok('round: precedence: no-progress beats ceiling at round 5', decision(round([[c('a')], [c('b')], [c('c')], [c('d')], [c('d')]], 'until', 2)) === 'no-progress');
|
|
269
|
+
ok('round: precedence: converged beats exhausted', decision(round([[c('a')], []], '1', 1)) === 'converged');
|
|
270
|
+
ok('round: output is one status ok JSON line with the round count', round([[c('a')]], '2', 2).json.status === 'ok' && round([[c('a')]], '2', 2).json.rounds === 1 && round([[c('a')]], '2', 2).out.trim().split('\n').length === 1);
|
|
271
|
+
|
|
272
|
+
// --- usage errors ------------------------------------------------------------
|
|
273
|
+
{
|
|
274
|
+
const u = (argv) => run(argv);
|
|
275
|
+
ok('usage: no command exits 2', u([]).code === 2);
|
|
276
|
+
ok('usage: unknown command exits 2', u(['go', '--repo', '/x']).code === 2);
|
|
277
|
+
ok('usage: ask without --repo exits 2', u(['ask']).code === 2);
|
|
278
|
+
ok('usage: ask with a bad --host exits 2', u(['ask', '--repo', '/x', '--host', 'vim']).code === 2);
|
|
279
|
+
ok('usage: unknown flag exits 2', u(['ask', '--repo', '/x', '--bogus', '1']).code === 2);
|
|
280
|
+
ok('usage: a flag without a value exits 2', u(['ask', '--repo']).code === 2);
|
|
281
|
+
ok('usage: round without --choice exits 2', u(['round', '--configured', '2', '--history', '/x']).code === 2);
|
|
282
|
+
ok('usage: round with a bad --choice exits 2', u(['round', '--choice', '4', '--configured', '2', '--history', '/x']).code === 2);
|
|
283
|
+
ok('usage: round without --configured exits 2', u(['round', '--choice', '1', '--history', '/x']).code === 2);
|
|
284
|
+
ok('usage: round with a non-positive --configured exits 2', u(['round', '--choice', '1', '--configured', '0', '--history', '/x']).code === 2);
|
|
285
|
+
ok('usage: round without --history exits 2', u(['round', '--choice', '1', '--configured', '2']).code === 2);
|
|
286
|
+
ok('usage: errors go to stderr, stdout stays empty', u(['ask']).errOut.startsWith('usage:') && u(['ask']).out === '');
|
|
287
|
+
ok('usage: history that is not JSON exits 2', round('{nope', '2', 2).code === 2);
|
|
288
|
+
ok('usage: history that is not an array exits 2', round({ a: 1 }, '2', 2).code === 2);
|
|
289
|
+
ok('usage: an empty history exits 2', round([], '2', 2).code === 2);
|
|
290
|
+
ok('usage: a round that is not an array exits 2', round([{}], '2', 2).code === 2);
|
|
291
|
+
ok('usage: a concern that is a bare string exits 2', round([['a']], '2', 2).code === 2);
|
|
292
|
+
ok('usage: a concern with an empty key exits 2', round([[{ key: ' ', text: 'x' }]], '2', 2).code === 2);
|
|
293
|
+
ok('usage: a concern with an empty text exits 2', round([[{ key: 'a', text: '' }]], '2', 2).code === 2);
|
|
294
|
+
ok('usage: a concern with a non-string text exits 2', round([[{ key: 'a', text: 3 }]], '2', 2).code === 2);
|
|
295
|
+
const missing = run(['round', '--choice', '1', '--configured', '2', '--history', '/nowhere/h.json']);
|
|
296
|
+
ok('usage: a missing history file is status error, exit 1', missing.code === 1 && missing.json.status === 'error');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// --- end-to-end --------------------------------------------------------------
|
|
300
|
+
{
|
|
301
|
+
const s = setup({ plan_review: 'on' }, null);
|
|
302
|
+
const bin = tmp();
|
|
303
|
+
fs.writeFileSync(path.join(bin, 'gemini'), '#!/bin/sh\n', { mode: 0o755 });
|
|
304
|
+
const res = spawnSync(process.execPath, [scriptPath, 'ask', '--repo', s.root], { encoding: 'utf8', env: { ...process.env, CLAUDE_CONFIG_DIR: s.gdir, PATH: bin } });
|
|
305
|
+
const j = JSON.parse(res.stdout.trim());
|
|
306
|
+
ok('e2e: ask prints one JSON line and exits 0', res.status === 0 && j.status === 'ask' && JSON.stringify(j.reviewers) === '["gemini"]' && j.options.length === 4);
|
|
307
|
+
const dir = tmp();
|
|
308
|
+
const file = path.join(dir, 'h.json');
|
|
309
|
+
fs.writeFileSync(file, JSON.stringify([[c('a')]]));
|
|
310
|
+
const rr = spawnSync(process.execPath, [scriptPath, 'round', '--choice', '1', '--configured', '2', '--history', file], { encoding: 'utf8' });
|
|
311
|
+
const rj = JSON.parse(rr.stdout.trim());
|
|
312
|
+
ok('e2e: round prints the decision and exits 0', rr.status === 0 && rj.decision === 'exhausted' && rj.surviving[0].key === 'a');
|
|
313
|
+
ok('e2e: usage error exits 2', spawnSync(process.execPath, [scriptPath, '--bogus'], { encoding: 'utf8' }).status === 2);
|
|
314
|
+
const bad = setup('{nope', null);
|
|
315
|
+
ok('e2e: an invalid config exits 1', spawnSync(process.execPath, [scriptPath, 'ask', '--repo', bad.root], { encoding: 'utf8', env: { ...process.env, CLAUDE_CONFIG_DIR: bad.gdir } }).status === 1);
|
|
316
|
+
bad.done(); s.done(); rm(bin); rm(dir);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
console.log(`plan-review-rounds.test.js: ${passed} checks passed`);
|
|
@@ -16,13 +16,15 @@ const fs = require('fs');
|
|
|
16
16
|
const os = require('os');
|
|
17
17
|
|
|
18
18
|
const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-report-status.js');
|
|
19
|
-
const { checkReport, TOKENS, CONTRACTS, FAMILY_OF } = require(scriptPath);
|
|
19
|
+
const { checkReport, TOKENS, CONTRACTS, FAMILY_OF, VERIFY_CLASSES } = require(scriptPath);
|
|
20
20
|
const techDir = path.resolve(__dirname, '..', '..', 'skills', '_shared', 'techniques');
|
|
21
21
|
|
|
22
22
|
let passed = 0;
|
|
23
23
|
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
24
24
|
|
|
25
25
|
const K = 'critical: 0 · warning: 1 · info: 2'; // the counts line
|
|
26
|
+
const K1 = 'critical: 1 · warning: 0 · info: 0';
|
|
27
|
+
const K2 = 'critical: 2 · warning: 1 · info: 0';
|
|
26
28
|
const V = 'verify';
|
|
27
29
|
const C = 'code-review';
|
|
28
30
|
const KEYS = ['action', 'verdict', 'problem', 'report', 'fields', 'body'];
|
|
@@ -119,6 +121,60 @@ const rows = [
|
|
|
119
121
|
['two counts lines', C, `status: clean\n${K}\nbody\n${K}\n`, ['reask', null, 'counts']],
|
|
120
122
|
['two different counts lines', C, `status: clean\n${K}\ncritical: 1 · warning: 1 · info: 2\n`, ['reask', null, 'counts']],
|
|
121
123
|
['counts only inside a code fence', C, `status: clean\n\`\`\`\n${K}\n\`\`\`\n`, ['reask', null, 'counts']],
|
|
124
|
+
|
|
125
|
+
// --- verdict vs content: code-review fails closed on an unclosed Critical ---
|
|
126
|
+
['clean with critical 2 and no ledger (ticket repro)', C, `status: clean\n${K2}\n\n- critical: SQL injection\n`,
|
|
127
|
+
['normalise', 'issues_found', null, `status: issues_found\n${K2}\n\n- critical: SQL injection\n`]],
|
|
128
|
+
['clean, critical 1, C1 fixed', C, `status: clean\n${K1}\nC1 fixed (abc123)\n`, ['accept', 'clean', null]],
|
|
129
|
+
['clean, critical 1, C1 deferred', C, `status: clean\n${K1}\n- C1: deferred → #12\n`, ['accept', 'clean', null]],
|
|
130
|
+
['clean, critical 1, C1 accepted', C, `status: clean\n${K1}\nC1 accepted: false positive\n`, ['accept', 'clean', null]],
|
|
131
|
+
['clean, critical 2, only C1 closed', C, `status: clean\n${K2}\nC1 fixed (a)\n`,
|
|
132
|
+
['normalise', 'issues_found', null, `status: issues_found\n${K2}\nC1 fixed (a)\n`]],
|
|
133
|
+
['clean, critical 1, C1 open', C, `status: clean\n${K1}\nC1 open\n`,
|
|
134
|
+
['normalise', 'issues_found', null, `status: issues_found\n${K1}\nC1 open\n`]],
|
|
135
|
+
['clean, critical 1, C1 fixed and C2 open', C, `status: clean\n${K1}\nC1 fixed (a)\nC2 open\n`,
|
|
136
|
+
['normalise', 'issues_found', null, `status: issues_found\n${K1}\nC1 fixed (a)\nC2 open\n`]],
|
|
137
|
+
['clean, critical 1, only a reclassified pointer', C, `status: clean\n${K1}\nC1 reclassified as W3\n`,
|
|
138
|
+
['normalise', 'issues_found', null, `status: issues_found\n${K1}\nC1 reclassified as W3\n`]],
|
|
139
|
+
['clean, critical 1, ledger line only inside a fence', C, `status: clean\n${K1}\n\`\`\`\nC1 fixed (a)\n\`\`\`\n`,
|
|
140
|
+
['normalise', 'issues_found', null, `status: issues_found\n${K1}\n\`\`\`\nC1 fixed (a)\n\`\`\`\n`]],
|
|
141
|
+
['clean, critical 2, a repeated C1 line counts once', C, `status: clean\n${K2}\nC1 fixed (a)\nc1 fixed (b)\n`,
|
|
142
|
+
['normalise', 'issues_found', null, `status: issues_found\n${K2}\nC1 fixed (a)\nc1 fixed (b)\n`]],
|
|
143
|
+
['finding prose with open after a closed ledger line stays clean', C, `status: clean\n${K1}\nC1: fixed (abc)\nC1 file.js:3 handler leaves the socket open\n`, ['accept', 'clean', null]],
|
|
144
|
+
['open redirect prose with a fixed ledger stays clean', C, `status: clean\n${K1}\nC1 fixed (abc)\nC1 open redirect in login.js:9\n`, ['accept', 'clean', null]],
|
|
145
|
+
['a lone fixed-size prose line does not close the Critical', C, `status: clean\n${K1}\nC1 fixed-size buffer overflow in x.js:3: open\n`,
|
|
146
|
+
['normalise', 'issues_found', null, `status: issues_found\n${K1}\nC1 fixed-size buffer overflow in x.js:3: open\n`]],
|
|
147
|
+
['bold id and en-dash separator ledger line', C, `status: clean\n${K1}\n- **C1** – fixed (abc)\n`, ['accept', 'clean', null]],
|
|
148
|
+
['verify subclass is not a class line', V, 'status: met\n- a, subclass: unmet\n', ['accept', 'met', null]],
|
|
149
|
+
['an unparsed open line for an uncounted Critical fails closed', C, `status: clean\n${K2}\nC1 fixed (a)\nC2 fixed (b)\nC3 open because x\n`,
|
|
150
|
+
['normalise', 'issues_found', null, `status: issues_found\n${K2}\nC1 fixed (a)\nC2 fixed (b)\nC3 open because x\n`]],
|
|
151
|
+
['em dash separator closes the Critical', C, `status: clean\n${K1}\nC1 ${String.fromCharCode(0x2014)} fixed (abc)\n`, ['accept', 'clean', null]],
|
|
152
|
+
['still open fails closed', C, `status: clean\n${K1}\nC1: still open\nC2 fixed (a)\n`,
|
|
153
|
+
['normalise', 'issues_found', null, `status: issues_found\n${K1}\nC1: still open\nC2 fixed (a)\n`]],
|
|
154
|
+
['bracketed open fails closed', C, `status: clean\n${K1}\nC1 [open]\nC2 fixed (a)\n`,
|
|
155
|
+
['normalise', 'issues_found', null, `status: issues_found\n${K1}\nC1 [open]\nC2 fixed (a)\n`]],
|
|
156
|
+
['a reclassified Critical does not gate the rest', C, `status: clean\n${K1}\nC1 reclassified as W3\nC2 fixed (a)\n`, ['accept', 'clean', null]],
|
|
157
|
+
['clean, critical 0, warnings open never gate', C, `status: clean\n${K}\nW1 open\n`, ['accept', 'clean', null]],
|
|
158
|
+
['issues_found is never changed', C, `status: issues_found\n${K1}\nC1 fixed (a)\n`, ['accept', 'issues_found', null]],
|
|
159
|
+
|
|
160
|
+
// --- verdict vs content: verify raises the tag to the highest claim class ---
|
|
161
|
+
['verify met with a met claim', V, 'status: met\n- claim 1, class: met\n', ['accept', 'met', null]],
|
|
162
|
+
['verify met with an unmet claim', V, 'status: met\n- Claim 2: login wired, class: unmet\n',
|
|
163
|
+
['normalise', 'gaps_found', null, 'status: gaps_found\n- Claim 2: login wired, class: unmet\n']],
|
|
164
|
+
['verify met with a partial claim', V, 'status: met\n- claim 1, class: partial\n',
|
|
165
|
+
['normalise', 'gaps_found', null, 'status: gaps_found\n- claim 1, class: partial\n']],
|
|
166
|
+
['verify met with an inconclusive claim', V, 'status: met\n- claim 1, class: inconclusive\n reason: unrunnable-check\n',
|
|
167
|
+
['normalise', 'inconclusive', null, 'status: inconclusive\n- claim 1, class: inconclusive\n reason: unrunnable-check\n']],
|
|
168
|
+
['verify met with inconclusive and unmet', V, 'status: met\n- a, class: inconclusive\n- b, class: unmet\n',
|
|
169
|
+
['normalise', 'gaps_found', null, 'status: gaps_found\n- a, class: inconclusive\n- b, class: unmet\n']],
|
|
170
|
+
['verify inconclusive with an unmet claim', V, 'status: inconclusive\n- b, class: unmet\n',
|
|
171
|
+
['normalise', 'gaps_found', null, 'status: gaps_found\n- b, class: unmet\n']],
|
|
172
|
+
['verify gaps_found with only met claims stays', V, 'status: gaps_found\n- a, class: met\n', ['accept', 'gaps_found', null]],
|
|
173
|
+
['verify inconclusive with only met claims stays', V, 'status: inconclusive\n- a, class: met\n', ['accept', 'inconclusive', null]],
|
|
174
|
+
['verify class line inside a fence is ignored', V, 'status: met\n```\n- a, class: unmet\n```\n', ['accept', 'met', null]],
|
|
175
|
+
['verify class line inside a blockquote is ignored', V, 'status: met\n> - a, class: unmet\n', ['accept', 'met', null]],
|
|
176
|
+
['verify upper-case class counts', V, 'status: met\n- a, Class: UNMET\n',
|
|
177
|
+
['normalise', 'gaps_found', null, 'status: gaps_found\n- a, Class: UNMET\n']],
|
|
122
178
|
];
|
|
123
179
|
|
|
124
180
|
for (const [name, technique, input, expected] of rows) {
|
|
@@ -274,6 +330,17 @@ ok('vocabulary: code-review tokens equal the ones code-review.md defines',
|
|
|
274
330
|
ok(`contracts: technique ${technique} maps to the family of trailhead-${technique} in the sync script`, FAMILIES[`trailhead-${technique}`] === family);
|
|
275
331
|
ok(`contracts: TOKENS.${technique} are the ${family} tokens`, JSON.stringify(TOKENS[technique]) === JSON.stringify(CONTRACTS[family].tokens));
|
|
276
332
|
}
|
|
333
|
+
// the reviewing block names the verify class vocabulary and the closing dispositions
|
|
334
|
+
const bullet = (block, prefix) => block.split('\n').find((l) => l.startsWith(`- ${prefix}:`)) || '';
|
|
335
|
+
const classTokens = (text) => [...text.matchAll(/class: <([a-z|]+)>/g)].flatMap((m) => m[1].split('|'));
|
|
336
|
+
ok('contracts: reviewing verify bullet names the checker class vocabulary',
|
|
337
|
+
JSON.stringify(classTokens(bullet(blocks.reviewing, 'trailhead-verify'))) === JSON.stringify(VERIFY_CLASSES));
|
|
338
|
+
const crBullet = bullet(blocks.reviewing, 'trailhead-code-review');
|
|
339
|
+
ok('contracts: reviewing code-review bullet names the closing dispositions',
|
|
340
|
+
['fixed', 'deferred', 'accepted'].every((d) => new RegExp(`\\x60${d}\\x60`).test(crBullet)));
|
|
341
|
+
const verifyDoc = fs.readFileSync(path.join(techDir, 'verify.md'), 'utf8');
|
|
342
|
+
ok('contracts: verify.md names the same class vocabulary',
|
|
343
|
+
classTokens(verifyDoc.replace(/class: ([a-z]+(?: \| [a-z]+)+)/g, (m, g) => `class: <${g.replace(/ \| /g, '|')}>`)).join() === VERIFY_CLASSES.join());
|
|
277
344
|
ok('contracts: every non-review agent has a technique here',
|
|
278
345
|
Object.entries(FAMILIES).filter(([, f]) => f !== 'reviewing').every(([a]) => FAMILY_OF[a.replace(/^trailhead-/, '')] !== undefined));
|
|
279
346
|
}
|
|
@@ -633,6 +633,23 @@ const SEARCH_VERB_CONFIG = {
|
|
|
633
633
|
awk: { patternFirst: true, valueFlags: new Set(['-f', '-v']), patternFlags: new Set() },
|
|
634
634
|
};
|
|
635
635
|
|
|
636
|
+
// #316: the -f / --file operand of a flag token, or null when the token is not one.
|
|
637
|
+
// Forms: `-f V`, `-fV`, a short cluster whose FIRST value-taking letter is `f`
|
|
638
|
+
// (`-if.env`; after another value letter the rest is that flag's value, so
|
|
639
|
+
// `-ef.env` is a pattern), `--file V`, `--file=V`. `consumed` = extra tokens used.
|
|
640
|
+
function fileFlagOperand(t, next, config) {
|
|
641
|
+
if (t === '--file') return { value: next, consumed: 1 };
|
|
642
|
+
if (t.startsWith('--file=')) return { value: t.slice('--file='.length), consumed: 0 };
|
|
643
|
+
if (t.startsWith('--')) return null;
|
|
644
|
+
for (let i = 1; i < t.length; i++) {
|
|
645
|
+
if (!config.valueFlags.has(`-${t[i]}`)) continue;
|
|
646
|
+
if (t[i] !== 'f') return null;
|
|
647
|
+
const rest = t.slice(i + 1);
|
|
648
|
+
return rest ? { value: rest, consumed: 0 } : { value: next, consumed: 1 };
|
|
649
|
+
}
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
|
|
636
653
|
// Walk a verb's argument tokens (verb itself already stripped), skipping
|
|
637
654
|
// flags (and their separate-token values), an explicit `--` end-of-options
|
|
638
655
|
// marker (which must not itself swallow the next real positional), and the
|
|
@@ -647,6 +664,14 @@ function scanArgsSkippingPattern(args, config, isMatch) {
|
|
|
647
664
|
const t = args[j];
|
|
648
665
|
if (!afterDashDash && t === '--') { afterDashDash = true; continue; }
|
|
649
666
|
if (!afterDashDash && t.startsWith('-') && t !== '-') {
|
|
667
|
+
// #316: the value of -f / --file is a FILE the verb reads, so it is tested.
|
|
668
|
+
const file = fileFlagOperand(t, args[j + 1], config);
|
|
669
|
+
if (file) {
|
|
670
|
+
if (file.value !== undefined && file.value !== '' && isMatch(file.value)) return file.value;
|
|
671
|
+
patternConsumed = true; // the pattern/script comes from that file, so positionals are FILEs
|
|
672
|
+
j += file.consumed;
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
650
675
|
if (config.valueFlags.has(t)) {
|
|
651
676
|
j++; // consume its separate-token value
|
|
652
677
|
if (config.patternFlags && config.patternFlags.has(t)) patternConsumed = true;
|