@marcomigozzi/trailhead 0.15.0 → 0.16.0

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.
Files changed (96) hide show
  1. package/README.it.md +45 -9
  2. package/README.md +46 -10
  3. package/bin/auto-intro-docs.test.js +72 -0
  4. package/bin/auto-merge-docs.test.js +33 -0
  5. package/bin/auto-merge-ticket-docs.test.js +32 -0
  6. package/bin/auto-review-rounds-docs.test.js +45 -0
  7. package/bin/auto-run-docs.test.js +60 -0
  8. package/bin/auto-scope-docs.test.js +33 -0
  9. package/bin/auto-slots-docs.test.js +45 -0
  10. package/bin/auto-uat-docs.test.js +47 -0
  11. package/bin/grilling-holistic-review-docs.test.js +90 -0
  12. package/bin/lib/codex-projection.js +5 -5
  13. package/bin/lib/codex-projection.test.js +2 -2
  14. package/bin/needs-you-docs.test.js +41 -0
  15. package/bin/pick-stress-test-docs.test.js +105 -0
  16. package/bin/trailhead.js +3 -3
  17. package/bin/trailhead.test.js +17 -18
  18. package/package.json +1 -1
  19. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  20. package/plugins/trailhead/commands/auto.md +2 -2
  21. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  22. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  23. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  24. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  25. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  26. package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
  27. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  28. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  29. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  30. package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
  31. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  32. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  33. package/plugins/trailhead/hooks/mod/README.md +180 -39
  34. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  35. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  36. package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
  37. package/plugins/trailhead/hooks/mod/map-pane.tsx +526 -92
  38. package/plugins/trailhead/hooks/mod/register.tsx +298 -39
  39. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  40. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  41. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  42. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  43. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  44. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  45. package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
  46. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  47. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  48. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  49. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  50. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  51. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
  52. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
  53. package/plugins/trailhead/skills/_shared/choices.md +7 -2
  54. package/plugins/trailhead/skills/_shared/configuration-reference.md +3 -3
  55. package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
  56. package/plugins/trailhead/skills/_shared/substrate.md +4 -1
  57. package/plugins/trailhead/skills/_shared/teamwork.md +2 -2
  58. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
  59. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  60. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  61. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  62. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  63. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +4 -4
  64. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  65. package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
  66. package/plugins/trailhead/skills/trailhead-work/SKILL.md +21 -10
  67. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  68. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +6 -6
  69. package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
  70. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  71. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  72. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  73. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  74. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  75. package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
  76. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  77. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  78. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  79. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  80. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  81. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  82. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  83. package/plugins/trailhead/tests/map-pane-ui.test.ts +1732 -142
  84. package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
  85. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  86. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  87. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  88. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  89. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  90. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  91. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  92. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  93. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  94. package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
  95. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  96. package/plugins/trailhead/types/index.d.ts +26 -1
@@ -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`);
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/statusline-trailhead.sh (barra contesto, --context-only).
3
+ // Run: node statusline-context.test.js
4
+ // No framework: plain asserts, same style as work-state.test.js.
5
+ const assert = require('assert');
6
+ const { spawnSync } = require('child_process');
7
+ const path = require('path');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+
11
+ const script = path.resolve(__dirname, '..', '..', 'templates', 'statusline-trailhead.sh');
12
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-statusline-ctx-'));
13
+ const home = path.join(root, 'home');
14
+ const cfg = path.join(root, 'cfg');
15
+ const proj = path.join(root, 'proj');
16
+ for (const d of [home, cfg, path.join(proj, '.claude')]) fs.mkdirSync(d, { recursive: true });
17
+
18
+ const SETTINGS = {
19
+ user: path.join(cfg, 'settings.json'),
20
+ project: path.join(proj, '.claude', 'settings.json'),
21
+ local: path.join(proj, '.claude', 'settings.local.json'),
22
+ };
23
+
24
+ let passed = 0;
25
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
26
+ const eq = (name, got, want) => { assert.strictEqual(got, want, `${name}: got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`); passed++; };
27
+
28
+ function setSettings(files) {
29
+ for (const f of Object.values(SETTINGS)) fs.rmSync(f, { force: true });
30
+ for (const [k, v] of Object.entries(files || {})) {
31
+ fs.writeFileSync(SETTINGS[k], typeof v === 'string' ? v : JSON.stringify(v));
32
+ }
33
+ }
34
+
35
+ // Esegue lo script e ritorna { pct, shown, out }
36
+ function ctx(tok, { env = {}, settings = {}, modelId = 'claude-opus-5-5[1m]', size = 1000000 } = {}) {
37
+ setSettings(settings);
38
+ const base = { ...process.env };
39
+ for (const k of ['CLAUDE_CODE_AUTO_COMPACT_WINDOW', 'CLAUDE_AUTOCOMPACT_PCT_OVERRIDE',
40
+ 'DISABLE_AUTO_COMPACT', 'DISABLE_COMPACT', 'CONTEXT_LIMIT', 'CONTEXT_USABLE_RATIO']) delete base[k];
41
+ const input = {
42
+ model: { id: modelId, display_name: 'Test' },
43
+ workspace: { project_dir: proj, current_dir: proj },
44
+ context_window: { context_window_size: size, current_usage: { input_tokens: tok } },
45
+ };
46
+ const r = spawnSync('bash', [script, '--context-only'], {
47
+ input: JSON.stringify(input),
48
+ env: { ...base, NO_COLOR: '1', TRAILHEAD_STATUSLINE_USAGE: '0', HOME: home, CLAUDE_CONFIG_DIR: cfg, ...env },
49
+ encoding: 'utf8',
50
+ });
51
+ const out = r.stdout.trim();
52
+ const m = out.match(/^\[.{10}\] (\d+)k\/(\d+)k \((\d+)%\)$/);
53
+ assert.ok(m, `unexpected output: ${JSON.stringify(out)} ${r.stderr}`);
54
+ return { pct: Number(m[3]), shown: `${m[1]}k/${m[2]}k`, out };
55
+ }
56
+ const pct = (tok, o) => ctx(tok, o).pct;
57
+
58
+ // --- default 1M: soglia = 1M - 33k = 967k ---
59
+ eq('default 966k', pct(966000), 100);
60
+ eq('default 967k', pct(967000), 100);
61
+ eq('default 900k', pct(900000), 93);
62
+ ok('default 800k not 100', pct(800000) < 100);
63
+ eq('display full window', ctx(900000).shown, '900k/1000k');
64
+
65
+ // --- autoCompactWindow ---
66
+ const win650 = { autoCompactWindow: 650000 };
67
+ eq('top-level 617k', pct(617000, { settings: { user: win650 } }), 100);
68
+ ok('top-level 600k', pct(600000, { settings: { user: win650 } }) < 100);
69
+ const perModel = { modelSettings: { 'claude-opus-5-5': { autoCompactWindow: 650000 } } };
70
+ eq('per-model 617k', pct(617000, { settings: { user: perModel } }), 100);
71
+ ok('per-model 600k', pct(600000, { settings: { user: perModel } }) < 100);
72
+ eq('per-model beats top-level', pct(617000, {
73
+ settings: { user: { autoCompactWindow: 900000, ...perModel } } }), 100);
74
+ eq('per-model beats top-level (larger top)', pct(300000, {
75
+ settings: { user: { autoCompactWindow: 400000, modelSettings: { 'claude-opus-5-5': { autoCompactWindow: 1000000 } } } } }), 31);
76
+ eq('local beats user', pct(617000, { settings: { user: { autoCompactWindow: 1000000 }, local: win650 } }), 100);
77
+ eq('local beats project', pct(617000, { settings: { project: { autoCompactWindow: 1000000 }, local: win650 } }), 100);
78
+ eq('project beats user', pct(617000, { settings: { user: { autoCompactWindow: 1000000 }, project: win650 } }), 100);
79
+ eq('invalid json skipped', pct(617000, { settings: { user: win650, local: '{nope' } }), 100);
80
+ eq('non-positive setting ignored', pct(900000, { settings: { user: { autoCompactWindow: -5 } } }), 93);
81
+ eq('non-numeric setting ignored', pct(900000, { settings: { user: { autoCompactWindow: 'big' } } }), 93);
82
+ eq('invalid higher file value falls to lower', pct(617000, {
83
+ settings: { user: win650, local: { autoCompactWindow: 'x' } } }), 100);
84
+
85
+ // --- env CLAUDE_CODE_AUTO_COMPACT_WINDOW ---
86
+ eq('env beats setting', pct(617000, { env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: '650000' },
87
+ settings: { user: { autoCompactWindow: 1000000 } } }), 100);
88
+ for (const bad of ['abc', '0', '-100']) {
89
+ eq(`env ${bad} ignored`, pct(900000, { env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: bad } }), 93);
90
+ }
91
+ eq('env bad falls back to setting', pct(617000, { env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: 'abc' },
92
+ settings: { user: win650 } }), 100);
93
+ eq('window above model capped', pct(966000, { env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: '5000000' } }), 100);
94
+ eq('window above model capped 900k', pct(900000, { env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: '5000000' } }), 93);
95
+
96
+ // --- CLAUDE_AUTOCOMPACT_PCT_OVERRIDE ---
97
+ eq('pct 50 at threshold', pct(500000, { env: { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: '50' } }), 100);
98
+ eq('pct 50 at 450k', pct(450000, { env: { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: '50' } }), 90);
99
+ for (const v of ['100', '101', '0', '-3', 'abc']) {
100
+ eq(`pct ${v} no change`, pct(900000, { env: { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: v } }), 93);
101
+ }
102
+ eq('pct 1 -> threshold 10k', pct(10000, { env: { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: '1' } }), 100);
103
+ eq('pct 1 at 5k', pct(5000, { env: { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: '1' } }), 50);
104
+
105
+ // --- disabilitato ---
106
+ eq('DISABLE_AUTO_COMPACT', pct(400000, { env: { DISABLE_AUTO_COMPACT: '1' } }), 40);
107
+ eq('DISABLE_COMPACT true', pct(400000, { env: { DISABLE_COMPACT: 'TRUE' } }), 40);
108
+ eq('disabled ignores settings and pct', pct(400000, { env: { DISABLE_AUTO_COMPACT: 'yes', CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: '10' },
109
+ settings: { user: win650 } }), 40);
110
+ eq('falsy DISABLE ignored', pct(900000, { env: { DISABLE_AUTO_COMPACT: '0' } }), 93);
111
+ eq('DISABLE_COMPACT on', pct(400000, { env: { DISABLE_COMPACT: 'on' } }), 40);
112
+ eq('DISABLE false ignored', pct(900000, { env: { DISABLE_COMPACT: 'false' } }), 93);
113
+
114
+ // --- per-modello in un file meno specifico batte top-level in uno piu' specifico ---
115
+ eq('per-model user beats top-level local', pct(617000, {
116
+ settings: { user: perModel, local: { autoCompactWindow: 1000000 } } }), 100);
117
+
118
+ // --- fallback tabella modello (niente context_window_size): 200k, soglia 167k ---
119
+ eq('model table fallback', pct(100000, { size: 0 }), 60);
120
+ eq('model table fallback display', ctx(100000, { size: 0 }).shown, '100k/200k');
121
+
122
+ // --- tetto modello ---
123
+ eq('CONTEXT_LIMIT caps display', ctx(100000, { env: { CONTEXT_LIMIT: '200000' } }).shown, '100k/200k');
124
+ eq('CONTEXT_LIMIT pct (threshold 167k)', pct(100000, { env: { CONTEXT_LIMIT: '200000' } }), 60);
125
+
126
+ // --- override manuale CONTEXT_USABLE_RATIO ---
127
+ eq('ratio 0.8 old behaviour', pct(800000, { env: { CONTEXT_USABLE_RATIO: '0.8' } }), 100);
128
+ eq('ratio 0.8 at 400k', pct(400000, { env: { CONTEXT_USABLE_RATIO: '0.8' } }), 50);
129
+ eq('ratio bypasses window setting', pct(400000, { env: { CONTEXT_USABLE_RATIO: '0.8' }, settings: { user: win650 } }), 50);
130
+ eq('ratio invalid ignored', pct(900000, { env: { CONTEXT_USABLE_RATIO: '2' } }), 93);
131
+
132
+ // --- bordo soglia: finestra 34k -> soglia 1000 ---
133
+ const edge = { env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: '34000' } };
134
+ eq('edge threshold-1', pct(994, edge), 99);
135
+ eq('edge threshold', pct(1000, edge), 100);
136
+ // finestra minuscola: soglia mai sotto 1
137
+ eq('tiny window guard', pct(5, { env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: '10' } }), 100);
138
+
139
+ fs.rmSync(root, { recursive: true, force: true });
140
+ console.log(`statusline-context.test.js: ${passed} assertions passed`);
@@ -0,0 +1,169 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/trailhead-uat-followup.js (#281). Run: node uat-followup.test.js
3
+ // No framework: plain asserts. A fake tracker sits behind an injected `gh` runner;
4
+ // one end-to-end run uses a fake `gh` on PATH.
5
+ const assert = require('assert');
6
+ const { spawnSync } = require('child_process');
7
+ const path = require('path');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+
11
+ const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-uat-followup.js');
12
+ const { main } = require(scriptPath);
13
+
14
+ let passed = 0;
15
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
16
+
17
+ // Fake tracker behind `gh api graphql`: answers GraphQL-shaped pages, 'pageSize' issues each,
18
+ // following the `after` cursor (a page index as a string).
19
+ function makeTracker(init = {}) {
20
+ const t = { issues: init.issues || [], fail: !!init.fail, raw: init.raw, pageSize: init.pageSize || 100, respond: init.respond, calls: [], argLists: [] };
21
+ t.gh = (args) => {
22
+ t.calls.push(args.join(' '));
23
+ t.argLists.push(args);
24
+ if (t.fail) return { status: 1, stdout: '', stderr: 'forced failure' };
25
+ if (t.raw !== undefined) return { status: 0, stdout: t.raw, stderr: '' };
26
+ const afterIdx = args.findIndex((x) => typeof x === 'string' && x.startsWith('after='));
27
+ const page = afterIdx < 0 ? 0 : Number(args[afterIdx].slice('after='.length));
28
+ if (t.respond) return { status: 0, stdout: JSON.stringify(t.respond(page)), stderr: '' };
29
+ const slice = t.issues.slice(page * t.pageSize, (page + 1) * t.pageSize);
30
+ const more = (page + 1) * t.pageSize < t.issues.length;
31
+ const pageInfo = { hasNextPage: more, endCursor: more ? String(page + 1) : null };
32
+ return { status: 0, stdout: JSON.stringify({ data: { repository: { issues: { nodes: slice, pageInfo } } } }), stderr: '' };
33
+ };
34
+ return t;
35
+ }
36
+
37
+ function run(t, argv) {
38
+ let out = '';
39
+ let errOut = '';
40
+ const code = main(argv, { gh: t.gh, stdout: { write: (s) => { out += s; } }, stderr: { write: (s) => { errOut += s; } } });
41
+ let json = null;
42
+ try { json = JSON.parse(out.trim()); } catch { /* not json */ }
43
+ return { code, out, errOut, json };
44
+ }
45
+
46
+ const URL_ = (n, repo = 'o/r') => `https://github.com/${repo}/issues/${n}`;
47
+ const fu = (number, state, body, title = `UAT ${number}`) => ({ number, title, state, body });
48
+ const base = ['find', '--parent', '281', '--repo', 'o/r'];
49
+
50
+ // --- matching ----------------------------------------------------------------
51
+ {
52
+ // A URL whose number runs into letters is not a pointer to that issue.
53
+ const t = makeTracker({ issues: [fu(310, 'OPEN', 'UAT of: [P](https://github.com/o/r/issues/281abc)')] });
54
+ const r = run(t, base);
55
+ ok('URL with trailing letters after the number does not match', r.code === 0 && r.json.open.length === 0 && r.json.reuse === null);
56
+ }
57
+ {
58
+ const t = makeTracker({ issues: [
59
+ fu(300, 'OPEN', `Intro\n\nUAT of: [Parent](${URL_(281)})\n\n- [ ] step`),
60
+ fu(301, 'OPEN', 'UAT of: #281\n- [ ] step'),
61
+ fu(302, 'OPEN', 'See the UAT of: [P](' + URL_(281) + ') mid-line'),
62
+ fu(303, 'OPEN', `UAT of: [P](${URL_(28)})`),
63
+ fu(304, 'OPEN', `UAT of: [P](${URL_(2810)})`),
64
+ fu(305, 'OPEN', 'UAT of: #28'),
65
+ fu(306, 'OPEN', 'UAT of: #2810'),
66
+ fu(307, 'OPEN', `UAT of: [P](${URL_(281, 'other/r')})`),
67
+ fu(308, 'OPEN', 'no pointer at all'),
68
+ fu(309, 'OPEN', `UAT of: [P](${URL_(281, 'O/R')})`),
69
+ fu(310, 'OPEN', `UAT of: [P](${URL_(281)})\r\n- [ ] step`),
70
+ ] });
71
+ const r = run(t, base);
72
+ const nums = r.json.open.map((x) => x.number);
73
+ ok('matches by URL, by #n, case-insensitive repo, CRLF', JSON.stringify(nums) === '[300,301,309,310]');
74
+ ok('ignores mid-line, prefix numbers, other repo, no pointer', !nums.some((n) => [302, 303, 304, 305, 306, 307, 308].includes(n)));
75
+ ok('prints one JSON line with title', r.code === 0 && r.json.status === 'ok' && r.json.parent === 281 && r.json.open[0].title === 'UAT 300' && r.out.trim().split('\n').length === 1);
76
+ ok('reuse is the lowest open', r.json.reuse === 300);
77
+ const a0 = t.calls[0];
78
+ ok('one graphql call with the label and both states, never issue list or search', t.calls.length === 1 && a0.includes('api graphql') && a0.includes('labels:["trailhead:uat"]') && a0.includes('states:[OPEN,CLOSED]') && !a0.includes('issue list') && !a0.includes('--search'));
79
+ ok('owner and name go as string fields, no cursor on the first page', a0.includes('-f owner=o') && a0.includes('-f name=r') && !a0.includes('after='));
80
+ }
81
+
82
+ // --- open vs closed ------------------------------------------------------------
83
+ {
84
+ const t = makeTracker({ issues: [
85
+ fu(12, 'CLOSED', 'UAT of: #281'),
86
+ fu(11, 'CLOSED', 'UAT of: #281'),
87
+ fu(20, 'OPEN', 'UAT of: #281'),
88
+ fu(15, 'OPEN', 'UAT of: #281'),
89
+ ] });
90
+ const r = run(t, base);
91
+ ok('splits open and closed ascending', JSON.stringify(r.json.open.map((x) => x.number)) === '[15,20]' && JSON.stringify(r.json.closed.map((x) => x.number)) === '[11,12]');
92
+ ok('reuse picks the lowest open of several', r.json.reuse === 15);
93
+ }
94
+ {
95
+ const t = makeTracker({ issues: [fu(11, 'CLOSED', 'UAT of: #281')] });
96
+ const r = run(t, base);
97
+ ok('only closed: reported, reuse null', r.code === 0 && r.json.closed.length === 1 && r.json.open.length === 0 && r.json.reuse === null);
98
+ }
99
+ {
100
+ const r = run(makeTracker({ issues: [] }), base);
101
+ ok('none found: empty lists, reuse null', r.code === 0 && r.json.reuse === null && r.json.open.length === 0 && r.json.closed.length === 0);
102
+ }
103
+
104
+ // --- errors --------------------------------------------------------------------
105
+ {
106
+ const r = run(makeTracker({ fail: true }), base);
107
+ ok('gh failure is a fail-closed error', r.code === 1 && r.json.status === 'error' && typeof r.json.reason === 'string');
108
+ const b = run(makeTracker({ raw: 'not json' }), base);
109
+ ok('bad JSON is an error', b.code === 1 && b.json.status === 'error');
110
+ const o = run(makeTracker({ raw: '{"a":1}' }), base);
111
+ ok('JSON without data.repository is an error', o.code === 1 && o.json.status === 'error');
112
+ const er = run(makeTracker({ raw: JSON.stringify({ errors: [{ message: 'boom' }], data: null }) }), base);
113
+ ok('errors array is an error', er.code === 1 && er.json.status === 'error' && /boom/.test(er.json.reason));
114
+ const nr = run(makeTracker({ raw: JSON.stringify({ data: { repository: null } }) }), base);
115
+ ok('null repository is an error', nr.code === 1 && nr.json.status === 'error');
116
+ const nn = run(makeTracker({ raw: JSON.stringify({ data: { repository: { issues: { pageInfo: { hasNextPage: false, endCursor: null } } } } }) }), base);
117
+ ok('missing nodes is an error', nn.code === 1 && nn.json.status === 'error');
118
+ const np = run(makeTracker({ raw: JSON.stringify({ data: { repository: { issues: { nodes: [] } } } }) }), base);
119
+ ok('missing pageInfo is an error', np.code === 1 && np.json.status === 'error');
120
+ const nc = run(makeTracker({ raw: JSON.stringify({ data: { repository: { issues: { nodes: [], pageInfo: { hasNextPage: true, endCursor: null } } } } }) }), base);
121
+ ok('hasNextPage without a cursor is an error', nc.code === 1 && nc.json.status === 'error');
122
+ const endless = makeTracker({ respond: (p) => ({ data: { repository: { issues: { nodes: [], pageInfo: { hasNextPage: true, endCursor: String(p + 1) } } } } }) });
123
+ const cap = run(endless, base);
124
+ ok('page cap exceeded is an error, never a partial answer', cap.code === 1 && cap.json.status === 'error' && /page/.test(cap.json.reason) && endless.calls.length === 100);
125
+ }
126
+
127
+ // --- pagination ----------------------------------------------------------------
128
+ {
129
+ const t = makeTracker({ pageSize: 2, issues: [
130
+ fu(1, 'OPEN', 'x'), fu(2, 'CLOSED', 'x'), fu(3, 'OPEN', 'x'),
131
+ fu(4, 'OPEN', 'UAT of: #281'), fu(5, 'CLOSED', 'UAT of: #281'),
132
+ ] });
133
+ const r = run(t, base);
134
+ ok('follow-up on a later page is found', r.code === 0 && r.json.reuse === 4 && r.json.closed[0].number === 5);
135
+ ok('three pages, cursor passed on later ones only', t.calls.length === 3 && !t.calls[0].includes('after=') && t.calls[1].includes('-f after=1') && t.calls[2].includes('-f after=2'));
136
+ ok('no page uses issue list or search', t.calls.every((c) => c.includes('api graphql') && !c.includes('issue list') && !c.includes('--search')));
137
+ }
138
+
139
+ // --- usage ---------------------------------------------------------------------
140
+ for (const argv of [
141
+ [], ['bogus'], ['find'], ['find', '--parent', '281'], ['find', '--repo', 'o/r'],
142
+ ['find', '--parent', '0', '--repo', 'o/r'], ['find', '--parent', 'abc', '--repo', 'o/r'], ['find', '--parent', '-3', '--repo', 'o/r'],
143
+ ['find', '--parent', '281', '--repo', 'nope'], ['find', '--parent'], ['find', '--parent', '281', '--repo'],
144
+ ['find', '--parent', '281', '--repo', 'o/r', '--bogus', 'x'],
145
+ ]) {
146
+ const t = makeTracker();
147
+ const r = run(t, argv);
148
+ ok(`usage error exit 2: ${argv.join(' ') || '(none)'}`, r.code === 2 && t.calls.length === 0 && r.errOut.length > 0 && r.out === '');
149
+ }
150
+
151
+ // --- end-to-end with a fake gh on PATH ------------------------------------------
152
+ {
153
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'uat-followup-'));
154
+ const binDir = path.join(tmp, 'bin');
155
+ fs.mkdirSync(binDir);
156
+ const payload = JSON.stringify({ data: { repository: { issues: { nodes: [fu(7, 'OPEN', 'UAT of: #281', 'Follow-up')], pageInfo: { hasNextPage: false, endCursor: null } } } } });
157
+ fs.writeFileSync(path.join(binDir, 'gh'), `#!/bin/sh\ncat <<'EOF_JSON'\n${payload}\nEOF_JSON\n`, { mode: 0o755 });
158
+ const env = { ...process.env, PATH: binDir + path.delimiter + process.env.PATH };
159
+ const res = spawnSync('node', [scriptPath, ...base], { encoding: 'utf8', env });
160
+ ok('e2e: finds the follow-up through gh on PATH', res.status === 0 && JSON.parse(res.stdout.trim()).reuse === 7);
161
+ fs.writeFileSync(path.join(binDir, 'gh'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
162
+ const bad = spawnSync('node', [scriptPath, ...base], { encoding: 'utf8', env });
163
+ ok('e2e: failing gh exits 1 with a JSON error line', bad.status === 1 && JSON.parse(bad.stdout.trim()).status === 'error');
164
+ const use = spawnSync('node', [scriptPath, '--bogus'], { encoding: 'utf8' });
165
+ ok('e2e: usage error exits 2', use.status === 2);
166
+ fs.rmSync(tmp, { recursive: true, force: true });
167
+ }
168
+
169
+ console.log(`uat-followup.test.js: ${passed} checks passed`);