@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.
Files changed (116) hide show
  1. package/README.it.md +52 -14
  2. package/README.md +53 -15
  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/config-dir-templates-docs.test.js +52 -0
  12. package/bin/decision-builds-docs.test.js +75 -0
  13. package/bin/fog-vs-ticket-docs.test.js +56 -0
  14. package/bin/grilling-holistic-review-docs.test.js +90 -0
  15. package/bin/lib/codex-projection.js +13 -6
  16. package/bin/lib/codex-projection.test.js +25 -2
  17. package/bin/lib/host-descriptor.js +14 -1
  18. package/bin/lib/host-descriptor.test.js +18 -0
  19. package/bin/lib/runtime-command-literals.test.js +184 -0
  20. package/bin/needs-you-docs.test.js +41 -0
  21. package/bin/pick-stress-test-docs.test.js +105 -0
  22. package/bin/report-status-docs.test.js +1 -1
  23. package/bin/trailhead.js +3 -3
  24. package/bin/trailhead.test.js +17 -18
  25. package/package.json +1 -1
  26. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  27. package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
  28. package/plugins/trailhead/agents/trailhead-verify.md +2 -2
  29. package/plugins/trailhead/commands/auto.md +2 -2
  30. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  31. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  32. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  33. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  34. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  35. package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
  36. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  37. package/plugins/trailhead/hooks/lib/model-defaults-review.js +50 -21
  38. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +50 -0
  39. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  40. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  41. package/plugins/trailhead/hooks/lib/report-status.test.js +68 -1
  42. package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
  43. package/plugins/trailhead/hooks/lib/statusline-context.test.js +161 -0
  44. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  45. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  46. package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
  47. package/plugins/trailhead/hooks/mod/README.md +185 -39
  48. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  49. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  50. package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
  51. package/plugins/trailhead/hooks/mod/map-pane.tsx +557 -99
  52. package/plugins/trailhead/hooks/mod/register.tsx +298 -39
  53. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  54. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  55. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  56. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  57. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  58. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  59. package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
  60. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  61. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  62. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  63. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  64. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  65. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +336 -23
  66. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +321 -0
  67. package/plugins/trailhead/skills/_shared/choices.md +7 -2
  68. package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -5
  69. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  70. package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
  71. package/plugins/trailhead/skills/_shared/substrate.md +4 -1
  72. package/plugins/trailhead/skills/_shared/teamwork.md +5 -5
  73. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
  74. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  75. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  76. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  77. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  78. package/plugins/trailhead/skills/_shared/techniques.md +2 -2
  79. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  80. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  81. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  82. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  83. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +4 -3
  84. package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
  85. package/plugins/trailhead/skills/trailhead-work/SKILL.md +23 -12
  86. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  87. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -6
  88. package/plugins/trailhead/templates/statusline-trailhead.sh +80 -15
  89. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  90. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  91. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  92. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  93. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  94. package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
  95. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  96. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  97. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  98. package/plugins/trailhead/templates/trailhead-report-status.js +67 -3
  99. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  100. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  101. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  102. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  103. package/plugins/trailhead/tests/map-pane-ui.test.ts +1839 -72
  104. package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
  105. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  106. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  107. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  108. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  109. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  110. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  111. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  112. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  113. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  114. package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
  115. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  116. package/plugins/trailhead/types/index.d.ts +26 -1
@@ -0,0 +1,161 @@
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
+ // --- credenziali: il blocco usage legge .credentials.json da CLAUDE_CONFIG_DIR (#320) ---
140
+ // Segnale osservabile: la dir cache viene creata solo se il guard sulle credenziali passa.
141
+ function usageReached({ withCreds }) {
142
+ const credCfg = path.join(root, withCreds ? 'cfg-creds' : 'cfg-nocreds');
143
+ const cache = path.join(root, withCreds ? 'cache-creds' : 'cache-nocreds');
144
+ fs.mkdirSync(credCfg, { recursive: true });
145
+ fs.mkdirSync(cache, { recursive: true });
146
+ if (withCreds) {
147
+ fs.writeFileSync(path.join(credCfg, '.credentials.json'), JSON.stringify({ claudeAiOauth: { accessToken: 'dummy' } }));
148
+ }
149
+ const env = { ...process.env, NO_COLOR: '1', HOME: home, CLAUDE_CONFIG_DIR: credCfg, XDG_CACHE_HOME: cache,
150
+ USAGE_API_TIMEOUT: '1', HTTPS_PROXY: 'http://127.0.0.1:9', https_proxy: 'http://127.0.0.1:9' };
151
+ delete env.TRAILHEAD_STATUSLINE_USAGE;
152
+ const input = { model: { id: 'claude-opus-5-5', display_name: 'Test' }, workspace: { project_dir: proj, current_dir: proj } };
153
+ spawnSync('bash', [script, '--context-only'], { input: JSON.stringify(input), env, encoding: 'utf8' });
154
+ return fs.existsSync(path.join(cache, 'trailhead-statusline'));
155
+ }
156
+ ok('usage block reached with creds under CLAUDE_CONFIG_DIR', usageReached({ withCreds: true }));
157
+ ok('usage block skipped without creds', !usageReached({ withCreds: false }));
158
+
159
+ // retry: il python in background puo' ancora scrivere nella cache mentre si pulisce
160
+ fs.rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
161
+ 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`);
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/trailhead-unblock.js. Run: node unblock.test.js
3
+ // No framework: plain asserts, same style as needs-you.test.js.
4
+ //
5
+ // Layers covered:
6
+ // 1. main() against a fake tracker behind an injected `gh` (multi-page reads included).
7
+ // 2. Usage errors (exit 2).
8
+ // 3. End-to-end: the script itself with a fake `gh` on PATH.
9
+ const assert = require('assert');
10
+ const { spawnSync } = require('child_process');
11
+ const path = require('path');
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+
15
+ const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-unblock.js');
16
+ const { main } = require(scriptPath);
17
+
18
+ let passed = 0;
19
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
20
+
21
+ // --- fake tracker ------------------------------------------------------------
22
+ // dependents: [{number, state, labels}] of the closed issue (paginated output is the
23
+ // concatenation of one JSON object per line, whatever the page size).
24
+ // blockers: { [dependent]: [{number, state}] }. failOn: blocking | blocked_by | edit.
25
+ function makeTracker(init = {}) {
26
+ const t = { dependents: init.dependents || [], blockers: init.blockers || {}, failOn: init.failOn || null, badJson: !!init.badJson, calls: [], edits: [] };
27
+ const res = (stdout, status = 0) => ({ status, stdout, stderr: status ? 'forced failure' : '' });
28
+ const lines = (list) => list.map((o) => JSON.stringify(o)).join('\n') + (list.length ? '\n' : '');
29
+ t.gh = (args) => {
30
+ t.calls.push(args.join(' '));
31
+ if (args[0] === 'api') {
32
+ const url = args.find((a) => a.startsWith('repos/'));
33
+ const m = /issues\/(\d+)\/dependencies\/(blocking|blocked_by)$/.exec(url || '');
34
+ if (!m) return res('', 1);
35
+ if (t.failOn === m[2]) return res('', 1);
36
+ if (t.badJson) return res('not json\n');
37
+ if (m[2] === 'blocking') return res(lines(t.dependents));
38
+ return res(lines(t.blockers[m[1]] || []));
39
+ }
40
+ if (args[0] === 'issue' && args[1] === 'edit') {
41
+ if (t.failOn === 'edit') return res('', 1);
42
+ t.edits.push(args.join(' '));
43
+ const n = Number(args[2]);
44
+ const d = t.dependents.find((x) => x.number === n);
45
+ if (d) d.labels = d.labels.filter((l) => l !== args[args.indexOf('--remove-label') + 1]);
46
+ return res('');
47
+ }
48
+ return res('', 1);
49
+ };
50
+ return t;
51
+ }
52
+
53
+ function run(t, argv, extra = {}) {
54
+ let out = '';
55
+ let errOut = '';
56
+ const code = main(argv, { gh: t.gh, stdout: { write: (s) => { out += s; } }, stderr: { write: (s) => { errOut += s; } }, ...extra });
57
+ let json = null;
58
+ try { json = JSON.parse(out.trim()); } catch { /* not json */ }
59
+ return { code, out, json, errOut };
60
+ }
61
+
62
+ const args = ['--issue', '5', '--repo', 'o/r'];
63
+ const B = 'trailhead:blocked';
64
+
65
+ // --- last blocker closed -> label removed ------------------------------------
66
+ {
67
+ const t = makeTracker({
68
+ dependents: [{ number: 8, state: 'open', labels: [B, 'x'] }],
69
+ blockers: { 8: [{ number: 5, state: 'closed' }, { number: 6, state: 'closed' }] },
70
+ });
71
+ const r = run(t, args);
72
+ ok('unblock: exits 0 with status ok', r.code === 0 && r.json.status === 'ok');
73
+ ok('unblock: the dependent is listed', JSON.stringify(r.json.unblocked) === '[8]' && r.json.stillBlocked.length === 0);
74
+ ok('unblock: the blocked label is removed', t.edits.length === 1 && t.edits[0].includes('--remove-label trailhead:blocked') && t.edits[0].includes('-R o/r'));
75
+ ok('unblock: the reads are paginated', t.calls.filter((c) => c.startsWith('api')).every((c) => c.includes('--paginate')));
76
+ const again = run(t, args);
77
+ ok('unblock: a retry is idempotent (label already gone)', again.code === 0 && again.json.unblocked.length === 0 && t.edits.length === 1);
78
+ }
79
+
80
+ // --- another blocker still open ----------------------------------------------
81
+ {
82
+ const t = makeTracker({
83
+ dependents: [{ number: 8, state: 'open', labels: [B] }],
84
+ blockers: { 8: [{ number: 5, state: 'closed' }, { number: 6, state: 'open' }, { number: 7, state: 'open' }] },
85
+ });
86
+ const r = run(t, args);
87
+ ok('unblock: an open blocker keeps the label', r.code === 0 && r.json.unblocked.length === 0 && t.edits.length === 0);
88
+ ok('unblock: stillBlocked names the open blockers', r.json.stillBlocked.length === 1 && r.json.stillBlocked[0].issue === 8 && JSON.stringify(r.json.stillBlocked[0].openBlockers) === '[6,7]');
89
+ }
90
+
91
+ // --- skipped dependents -------------------------------------------------------
92
+ {
93
+ const t = makeTracker({
94
+ dependents: [
95
+ { number: 8, state: 'open', labels: [] },
96
+ { number: 9, state: 'closed', labels: [B] },
97
+ { number: 10, state: 'open', labels: [B] },
98
+ ],
99
+ blockers: { 10: [{ number: 5, state: 'closed' }] },
100
+ });
101
+ const r = run(t, args);
102
+ ok('unblock: a dependent without the label is skipped', !t.calls.some((c) => c.includes('issues/8/')));
103
+ ok('unblock: a closed dependent is skipped', !t.calls.some((c) => c.includes('issues/9/')));
104
+ ok('unblock: only the open labelled dependent is unblocked', JSON.stringify(r.json.unblocked) === '[10]');
105
+ }
106
+
107
+ // --- no dependents ------------------------------------------------------------
108
+ {
109
+ const r = run(makeTracker(), args);
110
+ ok('unblock: no dependents is ok and empty', r.code === 0 && r.json.status === 'ok' && r.json.unblocked.length === 0 && r.json.stillBlocked.length === 0);
111
+ }
112
+
113
+ // --- gh failures --------------------------------------------------------------
114
+ {
115
+ const t = makeTracker({ failOn: 'blocking' });
116
+ const r = run(t, args);
117
+ ok('unblock: a failed dependents read is an error', r.code === 1 && r.json.status === 'error' && /dependents|blocking/.test(r.json.reason));
118
+ ok('unblock: nothing edited after a failed read', t.edits.length === 0);
119
+ }
120
+ {
121
+ const t = makeTracker({
122
+ dependents: [{ number: 8, state: 'open', labels: [B] }, { number: 9, state: 'open', labels: [B] }],
123
+ blockers: { 8: [{ number: 5, state: 'closed' }], 9: [{ number: 5, state: 'closed' }] },
124
+ failOn: 'blocked_by',
125
+ });
126
+ const r = run(t, args);
127
+ ok('unblock: a failed blockers read is an error naming the step', r.code === 1 && r.json.status === 'error' && /blocked_by|blockers/.test(r.json.reason));
128
+ ok('unblock: nothing further edited after the failure', t.edits.length === 0 && t.calls.filter((c) => c.includes('issues/9/')).length === 0);
129
+ }
130
+ {
131
+ const t = makeTracker({
132
+ dependents: [{ number: 8, state: 'open', labels: [B] }, { number: 9, state: 'open', labels: [B] }],
133
+ blockers: { 8: [{ number: 5, state: 'closed' }], 9: [{ number: 5, state: 'closed' }] },
134
+ failOn: 'edit',
135
+ });
136
+ const r = run(t, args);
137
+ ok('unblock: a failed label removal is an error', r.code === 1 && r.json.status === 'error' && /remove/.test(r.json.reason));
138
+ ok('unblock: the next dependent is not touched after a failed edit', t.calls.filter((c) => c.startsWith('issue edit')).length === 1);
139
+ }
140
+ {
141
+ const r = run(makeTracker({ badJson: true }), args);
142
+ ok('unblock: unparseable gh output is an error', r.code === 1 && r.json.status === 'error' && /unparseable/.test(r.json.reason));
143
+ }
144
+
145
+ // --- multi-page reads ---------------------------------------------------------
146
+ // gh api --paginate with --jq prints one object per line across every page, so a
147
+ // second page is just more lines; the fake returns them concatenated, and the helper
148
+ // must read all of them (two pages of dependents, an open blocker only on page two).
149
+ {
150
+ const t = makeTracker({
151
+ dependents: [
152
+ ...Array.from({ length: 30 }, (_, i) => ({ number: 100 + i, state: 'closed', labels: [] })),
153
+ { number: 8, state: 'open', labels: [B] },
154
+ ],
155
+ blockers: {
156
+ 8: [...Array.from({ length: 30 }, (_, i) => ({ number: 200 + i, state: 'closed' })), { number: 99, state: 'open' }],
157
+ },
158
+ });
159
+ const r = run(t, args);
160
+ ok('unblock: a dependent on the second page is found', r.code === 0 && r.json.stillBlocked.length === 1 && r.json.stillBlocked[0].issue === 8);
161
+ ok('unblock: an open blocker only on the second page keeps the label', JSON.stringify(r.json.stillBlocked[0].openBlockers) === '[99]' && t.edits.length === 0);
162
+ }
163
+
164
+ // --- usage errors -------------------------------------------------------------
165
+ {
166
+ const t = makeTracker();
167
+ for (const [name, argv] of [
168
+ ['no arguments', []],
169
+ ['missing --repo', ['--issue', '5']],
170
+ ['missing --issue', ['--repo', 'o/r']],
171
+ ['bad --issue', ['--issue', 'x', '--repo', 'o/r']],
172
+ ['bad --repo', ['--issue', '5', '--repo', 'nope']],
173
+ ['unknown flag', ['--issue', '5', '--repo', 'o/r', '--force']],
174
+ ['flag without value', ['--issue']],
175
+ ]) {
176
+ const r = run(t, argv);
177
+ ok(`usage: ${name} exits 2 with stderr`, r.code === 2 && r.errOut.startsWith('usage:') && r.out === '');
178
+ }
179
+ ok('usage: no gh call on a usage error', t.calls.length === 0);
180
+ }
181
+
182
+ // --- end-to-end: the script with a fake gh on PATH -----------------------------
183
+ {
184
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'unblock-e2e-')));
185
+ const bin = path.join(dir, 'gh');
186
+ fs.writeFileSync(bin, [
187
+ '#!/bin/sh',
188
+ 'case "$*" in',
189
+ ' *dependencies/blocking*) echo \'{"number":8,"state":"open","labels":["trailhead:blocked"]}\' ;;',
190
+ ' *dependencies/blocked_by*) echo \'{"number":5,"state":"closed"}\' ;;',
191
+ ' *"issue edit"*) echo "$*" >> "' + path.join(dir, 'edits.log') + '" ;;',
192
+ ' *) exit 1 ;;',
193
+ 'esac',
194
+ '',
195
+ ].join('\n'));
196
+ fs.chmodSync(bin, 0o755);
197
+ const r = spawnSync('node', [scriptPath, '--issue', '5', '--repo', 'o/r'], { encoding: 'utf8', env: { ...process.env, PATH: `${dir}:${process.env.PATH}` } });
198
+ let j = null;
199
+ try { j = JSON.parse(r.stdout.trim()); } catch { /* not json */ }
200
+ ok('e2e: exits 0 with one JSON line', r.status === 0 && j && j.status === 'ok' && JSON.stringify(j.unblocked) === '[8]');
201
+ ok('e2e: the label edit reached gh', fs.readFileSync(path.join(dir, 'edits.log'), 'utf8').includes('--remove-label trailhead:blocked'));
202
+ const bad = spawnSync('node', [scriptPath], { encoding: 'utf8' });
203
+ ok('e2e: a usage error exits 2', bad.status === 2);
204
+ fs.rmSync(dir, { recursive: true, force: true });
205
+ }
206
+
207
+ console.log(`unblock.test.js: ${passed} checks passed`);
@@ -62,6 +62,11 @@ const WRAPPER_VALUE_FLAGS = {
62
62
  nohup: [],
63
63
  };
64
64
 
65
+ // Is `word` (as written, quotes allowed) a command wrapper from the list above? #316
66
+ function isWrapperWord(word) {
67
+ return Object.prototype.hasOwnProperty.call(WRAPPER_VALUE_FLAGS, dequote(word));
68
+ }
69
+
65
70
  // Index just past a wrapper's own flags (and assignments), from the wrapper at toks[k].
66
71
  function skipWrapper(toks, k) {
67
72
  const w = dequote(toks[k]);
@@ -342,4 +347,4 @@ function unmodelledReason(guard, what, hit, redact) {
342
347
  `interpret with certainty, so it cannot verify ${what}. Rewrite it in a simple form: ${hit.hint}.`;
343
348
  }
344
349
 
345
- module.exports = { UNMODELLED, findUnmodelled, firstRelevant, unmodelledReason };
350
+ module.exports = { UNMODELLED, findUnmodelled, firstRelevant, unmodelledReason, isWrapperWord };