@marcomigozzi/trailhead 0.14.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 (156) hide show
  1. package/README.it.md +158 -34
  2. package/README.md +147 -33
  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/base-preflight-docs.test.js +65 -0
  12. package/bin/execute-split-docs.test.js +146 -0
  13. package/bin/grilling-holistic-review-docs.test.js +90 -0
  14. package/bin/isolation-answer-docs.test.js +41 -0
  15. package/bin/isolation-copy-docs.test.js +28 -0
  16. package/bin/lib/codex-projection.js +6 -6
  17. package/bin/lib/codex-projection.test.js +43 -2
  18. package/bin/needs-you-docs.test.js +41 -0
  19. package/bin/pick-stress-test-docs.test.js +105 -0
  20. package/bin/report-status-docs.test.js +195 -0
  21. package/bin/resolve-helpers-docs.test.js +36 -0
  22. package/bin/scope-confirm-skip-docs.test.js +70 -0
  23. package/bin/trailhead.js +6 -6
  24. package/bin/trailhead.test.js +355 -16
  25. package/bin/uat-setup-docs.test.js +65 -0
  26. package/package.json +1 -1
  27. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  28. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  29. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  30. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  31. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  32. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  33. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  34. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  35. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  36. package/plugins/trailhead/commands/auto.md +2 -2
  37. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  38. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  39. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  40. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  41. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  42. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  43. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  44. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  45. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  46. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  47. package/plugins/trailhead/hooks/lib/dashboard.test.js +472 -0
  48. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  49. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  50. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  51. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  52. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  53. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  54. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  55. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  56. package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
  57. package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
  58. package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
  59. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  60. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  61. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  62. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  63. package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
  64. package/plugins/trailhead/hooks/mod/README.md +209 -36
  65. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  66. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  67. package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
  68. package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
  69. package/plugins/trailhead/hooks/mod/register.tsx +312 -40
  70. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  71. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  72. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  73. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  74. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  75. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  76. package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
  77. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  78. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  79. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  80. package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
  81. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
  82. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  83. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  84. package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
  85. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
  86. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
  87. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
  88. package/plugins/trailhead/skills/_shared/choices.md +11 -4
  89. package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
  90. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  91. package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
  92. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  93. package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
  94. package/plugins/trailhead/skills/_shared/substrate.md +9 -4
  95. package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
  96. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
  97. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  98. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  99. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  100. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  101. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  102. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  103. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  104. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  105. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  106. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  107. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
  108. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
  109. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  110. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  111. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  112. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  113. package/plugins/trailhead/skills/trailhead-view/SKILL.md +9 -6
  114. package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
  115. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  116. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  117. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
  118. package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
  119. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  120. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  121. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  122. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  123. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  124. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  125. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  126. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  127. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  128. package/plugins/trailhead/templates/trailhead-dashboard.js +360 -0
  129. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  130. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  131. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  132. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  133. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  134. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  135. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  136. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  137. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  138. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  139. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  140. package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
  141. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  142. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  143. package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
  144. package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
  145. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  146. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  147. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  148. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  149. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  150. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  151. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  152. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  153. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  154. package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
  155. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  156. package/plugins/trailhead/types/index.d.ts +62 -3
@@ -4,10 +4,12 @@
4
4
  const assert = require('assert');
5
5
  const { execFileSync } = require('child_process');
6
6
  const path = require('path');
7
+ const fs = require('fs');
8
+ const os = require('os');
7
9
 
8
10
  // TRAILHEAD_GUARD_HOOK re-runs this suite through the merged trailhead-bash-guards.js.
9
11
  const HOOK = process.env.TRAILHEAD_GUARD_HOOK || path.join(__dirname, 'trailhead-install-guard.js');
10
- const { detectInstall, isVerified } = require('./trailhead-install-guard.js');
12
+ const { detectInstall, isVerified, evaluate } = require('./trailhead-install-guard.js');
11
13
 
12
14
  let passed = 0;
13
15
  const ok = (name, cond) => { assert.ok(cond, name); passed++; };
@@ -86,6 +88,176 @@ ok('detects install after ; chain', (() => { const d = detectInstall('echo hi; p
86
88
  ok('detects install in a piped tail', (() => { const d = detectInstall('true | cargo add serde'); return d && d.packages.includes('serde'); })());
87
89
  ok('compound with no install stays null', detectInstall('cd app && npm run build && ls') === null);
88
90
 
91
+ // --- unit: lockfile installs, redirections, quoted text (#239) ---
92
+ const heredocDoc = ['cat > /tmp/x.md <<\'EOF\'', '| `pnpm install a 2>&1` | x |', 'EOF'].join('\n');
93
+ const neg239 = [
94
+ 'pnpm install --frozen-lockfile --offline 2>&1',
95
+ 'cd app && pnpm install --frozen-lockfile --offline 2>&1 | tail -5',
96
+ 'pnpm install --frozen-lockfile > /tmp/log',
97
+ 'pnpm install --frozen-lockfile --filter web',
98
+ 'pnpm install --frozen-lockfile',
99
+ 'yarn install --immutable',
100
+ 'bun install --frozen-lockfile',
101
+ 'yarn install --frozen-lockfile',
102
+ 'npm install 2>&1',
103
+ 'echo "done; pnpm install lodash"',
104
+ 'echo "a && npm install foo"',
105
+ 'echo "next: pnpm install then build"',
106
+ 'git commit -m "fix: pnpm install guard | npm i x"',
107
+ heredocDoc,
108
+ ];
109
+ for (const cmd of neg239) {
110
+ ok(`ignores: ${cmd.split('\n')[0]}`, detectInstall(cmd) === null);
111
+ }
112
+
113
+ // Redirections and quoting must not hide a real named install, nor leak into the name.
114
+ const pos239 = [
115
+ ['npm install lodash 2>&1', 'npm', ['lodash']],
116
+ ['pnpm add vite > /tmp/log', 'npm', ['vite']],
117
+ ['npm install "lodash"', 'npm', ['lodash']],
118
+ ['echo ok && npm install lodash', 'npm', ['lodash']],
119
+ ['echo ok; pip install requests', 'pip', ['requests']],
120
+ ['pnpm add --filter web vite', 'npm', ['vite']],
121
+ ];
122
+ for (const [cmd, eco, names] of pos239) {
123
+ const d = detectInstall(cmd);
124
+ ok(`detects exactly: ${cmd}`, d && d.ecosystem === eco && JSON.stringify(d.packages) === JSON.stringify(names));
125
+ }
126
+
127
+ // A lockfile flag never excuses a typed name: the named package is still gated.
128
+ const posLock = [
129
+ ['npm i --frozen-lockfile evilpkg', 'evilpkg'],
130
+ ['pnpm install --frozen-lockfile evilpkg', 'evilpkg'],
131
+ ['yarn install --immutable evilpkg', 'evilpkg'],
132
+ ['npm install evilpkg --frozen', 'evilpkg'],
133
+ ];
134
+ for (const [cmd, name] of posLock) {
135
+ const d = detectInstall(cmd);
136
+ ok(`detects despite lockfile flag: ${cmd}`, d && d.packages.includes(name));
137
+ const r = runHook(cmd);
138
+ ok(`blocks despite lockfile flag: ${cmd}`, r.code === 2 && new RegExp(name).test(r.out));
139
+ }
140
+
141
+ // A bare `&` (background) is never listed as a package name.
142
+ ok('background & is not a package', JSON.stringify((detectInstall('npm install lodash &') || {}).packages) === JSON.stringify(['lodash']));
143
+
144
+ // --- unit: wrapped installs and file-like names (#255) ---
145
+ const posWrap = [
146
+ ['sudo npm install lodash', 'lodash'],
147
+ ['time pip install requests', 'requests'],
148
+ ['env FOO=1 npm i lodash', 'lodash'],
149
+ ['nohup npm install lodash', 'lodash'],
150
+ ['xargs npm install lodash', 'lodash'],
151
+ ['sudo -u bob npm install lodash', 'lodash'], // flag with a separate-token value
152
+ ['sudo -- npm i lodash', 'lodash'],
153
+ ['sudo -E npm i lodash', 'lodash'],
154
+ ['env -u HOME npm i lodash', 'lodash'],
155
+ ['env -C /tmp FOO=1 npm i lodash', 'lodash'],
156
+ ['time -p pip install requests', 'requests'],
157
+ ['time -o /tmp/t pip install requests', 'requests'],
158
+ ['xargs -n 1 npm install lodash', 'lodash'],
159
+ ['xargs -I {} pip install requests', 'requests'],
160
+ ['xargs --max-args=1 npm i lodash', 'lodash'],
161
+ ['sudo env FOO=1 nohup npm i lodash', 'lodash'], // chain
162
+ ['FOO=1 sudo -u root time -p pip install requests', 'requests'],
163
+ ['cd app && sudo npm install lodash', 'lodash'],
164
+ ['/usr/bin/sudo npm install lodash', 'lodash'],
165
+ ['sudo -nu root npm i lodash', 'lodash'], // bundled short flags, value flag last
166
+ ['sudo -Eu bob npm i lodash', 'lodash'],
167
+ ['sudo -iu root npm i lodash', 'lodash'],
168
+ ['sudo -uroot npm i lodash', 'lodash'], // attached value
169
+ ['sudo -nE npm i lodash', 'lodash'], // cluster with no value flag
170
+ ['env -S "npm i lodash"', 'lodash'], // split-string payload is the command
171
+ ['env -S npm i lodash', 'lodash'],
172
+ ["env -S 'npm i lodash'", 'lodash'],
173
+ ['env --split-string="npm i lodash"', 'lodash'],
174
+ ['env --split-string npm i lodash', 'lodash'],
175
+ ['env -a name npm i lodash', 'lodash'], // argv0 value, command follows
176
+ ['env -S "FOO=1 sudo npm i lodash"', 'lodash'],
177
+ ];
178
+ for (const [cmd, name] of posWrap) {
179
+ const d = detectInstall(cmd);
180
+ ok(`detects wrapped: ${cmd}`, d && d.packages.includes(name));
181
+ }
182
+ const negWrap = [
183
+ 'sudo npm install',
184
+ 'sudo npm ci',
185
+ 'time npm install --frozen-lockfile',
186
+ 'env FOO=1 npm install',
187
+ 'xargs echo npm install foo', // xargs runs echo
188
+ 'sudo -u bob ls',
189
+ 'sudo',
190
+ 'env',
191
+ 'sudo -u',
192
+ 'sudo -nu root ls',
193
+ 'env -S "ls -la"',
194
+ 'env -S "npm install"',
195
+ ];
196
+ for (const cmd of negWrap) {
197
+ ok(`ignores wrapped: ${cmd}`, detectInstall(cmd) === null);
198
+ }
199
+
200
+ // A file-like suffix is local only for a path or an existing file.
201
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'install-guard-'));
202
+ fs.writeFileSync(path.join(tmp, 'foo.tgz'), '');
203
+ const inTmp = { cwd: tmp };
204
+ const posFile = ['pip install foo.txt', 'npm install left-pad.toml', 'npm install nope.tgz', 'pip install pkg.whl'];
205
+ for (const cmd of posFile) {
206
+ const d = detectInstall(cmd, inTmp);
207
+ ok(`gates absent file-like name: ${cmd}`, d && d.packages.length === 1);
208
+ }
209
+ const negFile = [
210
+ 'pip install ./foo.txt',
211
+ 'pip install sub/req.txt',
212
+ 'pip install /abs/req.txt',
213
+ 'pip install ~/req.txt',
214
+ 'npm install foo.tgz', // exists in cwd
215
+ ];
216
+ for (const cmd of negFile) {
217
+ ok(`allows path or existing file: ${cmd}`, detectInstall(cmd, inTmp) === null);
218
+ }
219
+ ok('existing file resolved against cwd, not process cwd', detectInstall('npm install foo.tgz', { cwd: os.tmpdir() }) !== null);
220
+ ok('injectable exists check', detectInstall('npm install x.tgz', { exists: () => true }) === null);
221
+ ok('scoped name is never a path', (() => { const d = detectInstall('npm install @scope/pkg.zip', inTmp); return d && d.packages[0] === '@scope/pkg.zip'; })());
222
+ ok('evaluate tolerates null opts', evaluate('npm install lodash', null).kind === 'block');
223
+ const wf = evaluate('npm install foo.tgz', { cwd: tmp });
224
+ ok('evaluate threads cwd (existing file allowed)', wf.kind === 'allow');
225
+ ok('evaluate blocks absent file-like name', evaluate('pip install foo.txt', { cwd: tmp }).kind === 'block');
226
+ const wrapRun = runHook('sudo npm install lodash');
227
+ ok('blocks a sudo-wrapped install end-to-end (exit 2)', wrapRun.code === 2 && /lodash/.test(wrapRun.out));
228
+ const cwdRun = (() => {
229
+ const input = JSON.stringify({ cwd: tmp, tool_input: { command: 'npm install foo.tgz' } });
230
+ try { return { code: 0, out: execFileSync('node', [HOOK], { input }).toString() }; } catch (e) { return { code: e.status }; }
231
+ })();
232
+ ok('hook honours input.cwd for the on-disk check (exit 0)', cwdRun.code === 0);
233
+ fs.rmSync(tmp, { recursive: true, force: true });
234
+
235
+ // Wrappers peeled with their flags: timeout (+ DURATION), nice, stdbuf, setsid, ionice, doas, command, exec.
236
+ const wrapHits = [
237
+ 'timeout 10 npm i x', 'timeout -s KILL 10 npm i x', 'timeout --signal=KILL 5s npm i x',
238
+ 'timeout -k 1 10 npm i x', 'nice npm i x', 'nice -n 5 npm i x', 'nice -n5 npm i x', 'nice -5 npm i x',
239
+ 'command npm i x', 'command -p npm i x', 'exec npm i x', 'exec -a foo npm i x',
240
+ 'doas npm i x', 'doas -u root npm i x', 'setsid npm i x', 'setsid -w npm i x',
241
+ 'ionice -c 3 npm i x', 'stdbuf -oL npm i x', 'stdbuf -o L npm i x',
242
+ 'sudo timeout 10 nice npm i x', 'xargs timeout 10 npm i x', 'timeout -- 10 npm i x',
243
+ 'env -S "env -S \\"npm i lodash\\""',
244
+ 'env -S "env -S \\"env -S \\\\\\"npm i lodash\\\\\\"\\""',
245
+ "env -S 'npm i lodash'",
246
+ ];
247
+ for (const c of wrapHits) ok(`wrapper hit: ${c}`, detectInstall(c) && detectInstall(c).packages.length > 0);
248
+ const wrapMisses = [
249
+ 'timeout 10 ls', 'nice ls', 'command -v npm', 'command -v npm i x', 'command -V npm i x',
250
+ 'command -pv npm i x', 'exec 3>&1', 'exec -a npm ls', 'doas ls',
251
+ "env -S 'echo \"npm i lodash\"'", 'env -S "echo \\"npm i lodash\\""',
252
+ ];
253
+ for (const c of wrapMisses) ok(`wrapper miss: ${c}`, detectInstall(c) === null);
254
+
255
+ // Known unmodelled gaps (documented, NOT asserted): this guard reads one plain
256
+ // command per statement plus the wrappers it peels, so an install behind
257
+ // `bash -c "..."` / `sh -c "..."`, `$( )`, `eval`, other wrappers (`chrt`,
258
+ // `taskset`, `flock`, `su -c`), or a GNU long-option prefix abbreviation
259
+ // taking a separate value (`timeout --sig KILL 5 ...`) is not seen.
260
+
89
261
  // --- unit: isVerified ---
90
262
  ok('sentinel recognised', isVerified('TRAILHEAD_VERIFIED_INSTALL=1 npm install lodash'));
91
263
  ok('no sentinel', !isVerified('npm install lodash'));
@@ -107,6 +279,13 @@ ok('non-install allowed (exit 0)', a3.code === 0 && a3.out.trim() === '');
107
279
  const a4 = runHook('pip install -r requirements.txt');
108
280
  ok('requirements-file install allowed (exit 0)', a4.code === 0 && a4.out.trim() === '');
109
281
 
282
+ const a5 = runHook('cd app && pnpm install --frozen-lockfile --offline 2>&1 | tail -5');
283
+ ok('lockfile install with redirection allowed (exit 0)', a5.code === 0 && a5.out.trim() === '');
284
+ const a6 = runHook('echo "done; pnpm install lodash"');
285
+ ok('install text inside echo allowed (exit 0)', a6.code === 0 && a6.out.trim() === '');
286
+ const b3 = runHook('npm install lodash 2>&1');
287
+ ok('named install with redirection still blocks (exit 2)', b3.code === 2 && /lodash/.test(b3.out));
288
+
110
289
  // --- end-to-end: unparseable stdin allowed ---
111
290
  const u = (() => {
112
291
  try {
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ // trailhead-labels-guard.js: PreToolUse(Bash) hook.
3
+ // A trailhead ticket is identified by its label set: `trailhead:ticket`, exactly one
4
+ // type label and exactly one destination (a map or the whiteboard). When an agent
5
+ // opens a ticket with `gh issue create` and forgets part of that set (or adds the
6
+ // missing labels in a later call that never comes), the ticket is invisible to the
7
+ // board and the engine. This guard checks the label set at the create call itself.
8
+ // - A create that carries at least one `trailhead:*` label must carry the whole
9
+ // ticket shape: `trailhead:ticket`, exactly one of trailhead:decision | research |
10
+ // prototype | build | bug | task, and exactly one of `trailhead:map-<n>` |
11
+ // `trailhead:whiteboard` (never both, never two maps). Non-ticket trailhead issues
12
+ // (`trailhead:map`, `trailhead:dashboard`, `trailhead:codebase`,
13
+ // `trailhead:conventions`, and `trailhead:fog` / `trailhead:unverified`) are exempt.
14
+ // State labels (blocked, seed, paused, ...) are allowed extras and never count
15
+ // toward the shape. This rule applies in any repo.
16
+ // - A create with NO `trailhead:*` label is blocked too, but only inside a trailhead
17
+ // repo (the hook cwd sits in a git work tree whose top level has a `.trailhead/`
18
+ // directory) and not when aimed at another repo (-R / --repo / GH_REPO=). A
19
+ // deliberate non-trailhead issue opts out by prefixing the command with
20
+ // `TRAILHEAD_PLAIN_ISSUE=1`, like the install guard's TRAILHEAD_VERIFIED_INSTALL=1.
21
+ // After a `cd` the working directory is unknown, so this ground never blocks there.
22
+ // - A label value that cannot be resolved statically ($VAR, `$(...)`, backticks) makes
23
+ // the whole create unparseable: it is never blocked on what cannot be read.
24
+ // The command is not required to START with `gh`: the shared statement walk (ghWrites in
25
+ // secret-guard) finds each `gh issue create` through `;` `&&` `||` `|`, subshells and
26
+ // leading NAME=v. Opaque writes (eval, sh -c, ...) are ignored (fail-open).
27
+ // Blocks with exit 2 + {"decision":"block",...}; allows everything else.
28
+ // Crash-safe: any error -> allow. Self-contained except for the sibling lib/ modules and
29
+ // the secret-guard statement walk. Pure functions are exported for tests and the merged
30
+ // trailhead-bash-guards.js; runs as a hook when executed directly.
31
+
32
+ const fs = require('fs');
33
+ const path = require('path');
34
+ const { parseGhSubcommand, dequote } = require('./lib/gh-subcommand.js');
35
+ const { words, parseRedirects } = require('./lib/body-sources.js');
36
+
37
+ const PLAIN_SENTINEL = /(?:^|\s)TRAILHEAD_PLAIN_ISSUE=1(?:\s|$)/;
38
+ const TYPES = ['decision', 'research', 'prototype', 'build', 'bug', 'task'];
39
+ const EXEMPT = ['trailhead:map', 'trailhead:dashboard', 'trailhead:codebase', 'trailhead:conventions', 'trailhead:fog', 'trailhead:unverified'];
40
+ const MAP_RE = /^trailhead:map-\d+$/;
41
+ const WHITEBOARD = 'trailhead:whiteboard';
42
+ const TYPE_LIST = TYPES.map((t) => `trailhead:${t}`).join(' | ');
43
+
44
+ // gh issue create flags that consume a value (long and short forms).
45
+ const VALUE_FLAGS = new Set([
46
+ '--assignee', '--body', '--body-file', '--label', '--milestone', '--project', '--repo', '--title', '--template', '--recover',
47
+ '-a', '-b', '-F', '-l', '-m', '-p', '-R', '-t', '-T',
48
+ ]);
49
+ const ASSIGN_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
50
+
51
+ // Read the options of one `gh issue create` statement.
52
+ // { labels: [lowercased label names], unresolved: bool, repoFlag: bool, help: bool }
53
+ function parseCreate(text) {
54
+ const { args } = parseRedirects(words(text));
55
+ const toks = args.map(dequote);
56
+ const labels = [];
57
+ let unresolved = false;
58
+ let repoFlag = false;
59
+ let help = false;
60
+ let afterCreate = false;
61
+ let sawIssue = false;
62
+ const take = (v) => {
63
+ if (/[$`]/.test(v)) unresolved = true;
64
+ for (const part of v.split(',')) {
65
+ const l = part.trim().toLowerCase();
66
+ if (l) labels.push(l);
67
+ }
68
+ };
69
+ // leading NAME=v: GH_REPO aims the command at another repo
70
+ let i = 0;
71
+ while (i < toks.length && ASSIGN_RE.test(toks[i])) {
72
+ if (/^GH_REPO=/.test(toks[i])) repoFlag = true;
73
+ i++;
74
+ }
75
+ for (i += 1; i < toks.length; i++) { // past the gh word
76
+ if (args[i].startsWith('#')) break; // an unquoted shell comment: the rest is not an argument
77
+ const t = toks[i];
78
+ if (t === '-h' || t === '--help') { help = true; continue; }
79
+ if (!t.startsWith('-')) {
80
+ if (!sawIssue && t === 'issue') sawIssue = true;
81
+ else if (sawIssue && t === 'create') afterCreate = true;
82
+ continue;
83
+ }
84
+ let flag = t;
85
+ let value;
86
+ if (t.startsWith('--')) {
87
+ const eq = t.indexOf('=');
88
+ if (eq !== -1) { flag = t.slice(0, eq); value = t.slice(eq + 1); }
89
+ else if (VALUE_FLAGS.has(t)) value = toks[++i];
90
+ } else if (VALUE_FLAGS.has(t)) {
91
+ value = toks[++i];
92
+ } else if (t.length > 2 && VALUE_FLAGS.has(t.slice(0, 2))) {
93
+ flag = t.slice(0, 2); // glued shorthand: -lbug, -l=bug
94
+ value = t.slice(2).replace(/^=/, '');
95
+ }
96
+ if (value === undefined) continue;
97
+ if (flag === '-R' || flag === '--repo') repoFlag = true;
98
+ else if (afterCreate && (flag === '--label' || flag === '-l')) take(value);
99
+ }
100
+ return { labels, unresolved, repoFlag, help };
101
+ }
102
+
103
+ // Is `cwd` in a git work tree whose top level has a `.trailhead/` directory? Walks up to
104
+ // the first directory holding `.git` (dir or file). Any fs error -> false (fail open).
105
+ function inTrailheadRepo(cwd) {
106
+ try {
107
+ let dir = path.resolve(cwd || process.cwd());
108
+ for (;;) {
109
+ if (fs.existsSync(path.join(dir, '.git'))) {
110
+ return fs.statSync(path.join(dir, '.trailhead')).isDirectory();
111
+ }
112
+ const up = path.dirname(dir);
113
+ if (up === dir) return false;
114
+ dir = up;
115
+ }
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ // The ticket-shape problems of a label set that has at least one trailhead:* label
122
+ // and is not a non-ticket issue. Empty list = well formed.
123
+ function shapeProblems(th) {
124
+ const problems = [];
125
+ if (!th.includes('trailhead:ticket')) problems.push('missing `trailhead:ticket`');
126
+ const types = th.filter((l) => TYPES.some((t) => l === `trailhead:${t}`));
127
+ if (types.length === 0) problems.push(`missing a type label (exactly one of ${TYPE_LIST})`);
128
+ else if (new Set(types).size > 1) problems.push(`conflicting type labels (${[...new Set(types)].join(', ')}): keep exactly one`);
129
+ const maps = [...new Set(th.filter((l) => MAP_RE.test(l)))];
130
+ const board = th.includes(WHITEBOARD);
131
+ if (maps.length === 0 && !board) problems.push('missing a destination (exactly one of `trailhead:map-<n>` | `trailhead:whiteboard`)');
132
+ else if (maps.length > 1) problems.push(`two map destinations (${maps.join(', ')}): keep exactly one`);
133
+ else if (maps.length === 1 && board) problems.push(`two destinations (${maps[0]} and ${WHITEBOARD}): keep exactly one`);
134
+ return problems;
135
+ }
136
+
137
+ const RETRY =
138
+ 'Retry with the full label set in the SAME `gh issue create` call (`--label trailhead:ticket --label <type> --label <destination>`); ' +
139
+ 'never add labels afterwards with `--add-label`.';
140
+
141
+ function block(code, reason) {
142
+ return { kind: 'block', payload: { decision: 'block', code, reason } };
143
+ }
144
+
145
+ // Verdict for one parsed create, or null to allow.
146
+ function checkCreate(parsed, { cmd, cwd, cwdKnown }) {
147
+ if (parsed.help) return null; // `--help` creates nothing
148
+ if (parsed.unresolved) return null; // a label we cannot read: never block on it
149
+ const th = parsed.labels.filter((l) => l.startsWith('trailhead:'));
150
+ if (th.length > 0) {
151
+ if (th.some((l) => EXEMPT.includes(l))) return null; // a non-ticket trailhead issue
152
+ const problems = shapeProblems(th);
153
+ if (problems.length === 0) return null;
154
+ return block(
155
+ 'TICKET_LABELS_INCOMPLETE',
156
+ `trailhead: this gh issue create opens a trailhead ticket (labels: ${th.join(', ')}) with an incomplete label set: ${problems.join('; ')}. ${RETRY}`,
157
+ );
158
+ }
159
+ if (parsed.repoFlag || !cwdKnown || PLAIN_SENTINEL.test(cmd)) return null;
160
+ if (!inTrailheadRepo(cwd)) return null;
161
+ return block(
162
+ 'TRAILHEAD_ISSUE_WITHOUT_LABELS',
163
+ 'trailhead: this gh issue create carries no `trailhead:*` label, so the issue would be invisible to the board and the engine. ' +
164
+ `${RETRY} ` +
165
+ 'For a deliberate non-trailhead issue, re-run the exact command prefixed with `TRAILHEAD_PLAIN_ISSUE=1`.',
166
+ );
167
+ }
168
+
169
+ // Every statement of the command that is a `gh issue create`: { text, cwdKnown }.
170
+ function creates(cmd) {
171
+ let ghWrites;
172
+ try {
173
+ ({ ghWrites } = require('./trailhead-secret-guard.js'));
174
+ } catch {
175
+ return [{ text: cmd, cwdKnown: true }]; // partial install: check the lone statement
176
+ }
177
+ const out = [];
178
+ for (const w of ghWrites(cmd, {})) {
179
+ if (w.kind !== 'issue') continue;
180
+ const p = parseGhSubcommand(w.text);
181
+ if (p && p.sub === 'issue' && p.verb === 'create') out.push({ text: w.text, cwdKnown: w.cwdKnown });
182
+ }
183
+ return out;
184
+ }
185
+
186
+ // Pure verdict: null (allow), or { kind:'block', payload }. Never exits or writes; any
187
+ // internal error allows.
188
+ function evaluate(cmd, opts) {
189
+ try {
190
+ const cwd = opts && opts.cwd;
191
+ for (const c of creates(String(cmd))) {
192
+ const p = parseGhSubcommand(c.text);
193
+ if (!p || p.sub !== 'issue' || p.verb !== 'create') continue;
194
+ const verdict = checkCreate(parseCreate(c.text), { cmd: String(cmd), cwd, cwdKnown: c.cwdKnown });
195
+ if (verdict) return verdict;
196
+ }
197
+ } catch {
198
+ // a guard must never wedge the workflow
199
+ }
200
+ return null;
201
+ }
202
+
203
+ function run(data) {
204
+ let cmd = '';
205
+ let cwd;
206
+ try {
207
+ const input = JSON.parse(data);
208
+ cmd = (input.tool_input || {}).command || '';
209
+ cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : undefined;
210
+ } catch {
211
+ process.exit(0);
212
+ }
213
+ const v = evaluate(cmd, { cwd });
214
+ if (v && v.kind === 'block') {
215
+ process.stdout.write(JSON.stringify(v.payload));
216
+ process.exit(2);
217
+ }
218
+ process.exit(0);
219
+ }
220
+
221
+ if (require.main === module) {
222
+ let data = '';
223
+ const timer = setTimeout(() => process.exit(0), 5000); // stdin never ends -> give up
224
+ process.stdin.on('data', (c) => (data += c));
225
+ process.stdin.on('end', () => {
226
+ clearTimeout(timer);
227
+ run(data);
228
+ });
229
+ }
230
+
231
+ module.exports = { parseCreate, shapeProblems, inTrailheadRepo, evaluate };
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+ // Tests for trailhead-labels-guard.js. Run: node trailhead-labels-guard.test.js
3
+ // No framework: plain asserts + child_process for the end-to-end hook behaviour.
4
+ const assert = require('assert');
5
+ const { spawnSync } = require('child_process');
6
+ const path = require('path');
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+
10
+ // TRAILHEAD_GUARD_HOOK re-runs this suite through the merged trailhead-bash-guards.js.
11
+ const HOOK = process.env.TRAILHEAD_GUARD_HOOK || path.join(__dirname, 'trailhead-labels-guard.js');
12
+ const { evaluate, parseCreate, inTrailheadRepo } = require('./trailhead-labels-guard.js');
13
+
14
+ let passed = 0;
15
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
16
+
17
+ // Scratch dirs: a trailhead repo (.git + .trailhead/), a plain repo (.git only), a plain dir.
18
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'labels-guard-'));
19
+ const mk = (rel, ...sub) => {
20
+ const d = path.join(tmp, rel);
21
+ fs.mkdirSync(d, { recursive: true });
22
+ for (const s of sub) fs.mkdirSync(path.join(d, s), { recursive: true });
23
+ return d;
24
+ };
25
+ const TH = mk('th', '.git', '.trailhead', 'src/deep');
26
+ const THDEEP = path.join(TH, 'src', 'deep');
27
+ const PLAIN_REPO = mk('plain', '.git');
28
+ const NOREPO = mk('norepo');
29
+ // A worktree-style repo: `.git` is a file, not a dir.
30
+ const WT = mk('wt', '.trailhead');
31
+ fs.writeFileSync(path.join(WT, '.git'), 'gitdir: /elsewhere\n');
32
+
33
+ // Run the hook end-to-end: tool_input JSON (+ cwd) on stdin, capture {code, out}.
34
+ function runHook(command, cwd) {
35
+ const input = JSON.stringify({ tool_input: { command }, ...(cwd ? { cwd } : {}) });
36
+ const r = spawnSync('node', [HOOK], { input, cwd: NOREPO });
37
+ return { code: r.status, out: r.stdout.toString() };
38
+ }
39
+ const blocked = (cmd, cwd) => { const v = evaluate(cmd, { cwd, env: {} }); return !!v && v.kind === 'block'; };
40
+ const reasonOf = (cmd, cwd) => { const v = evaluate(cmd, { cwd, env: {} }); return v ? v.payload.reason : ''; };
41
+ const codeOf = (cmd, cwd) => { const v = evaluate(cmd, { cwd, env: {} }); return v ? v.payload.code : null; };
42
+
43
+ const create = (labels, extra = '') =>
44
+ `gh issue create --title x --body y ${labels.map((l) => `--label ${l}`).join(' ')} ${extra}`.trim();
45
+ const FULL_WB = ['trailhead:ticket', 'trailhead:bug', 'trailhead:whiteboard'];
46
+ const FULL_MAP = ['trailhead:ticket', 'trailhead:build', 'trailhead:map-12'];
47
+
48
+ // --- the repro commands (#308) ---
49
+ {
50
+ const r1 = 'gh issue create --label trailhead:bug --label trailhead:whiteboard --title x --body-file /dev/null';
51
+ const r2 = 'gh issue create --label "trailhead:bug,trailhead:whiteboard" --title x --body y';
52
+ const r3 = 'gh issue create --title x --body y';
53
+ for (const c of [r1, r2]) {
54
+ ok(`blocks partial set: ${c.slice(0, 50)}`, blocked(c, NOREPO));
55
+ ok('names the missing ticket label', reasonOf(c, NOREPO).includes('missing `trailhead:ticket`'));
56
+ ok('tells to retry in the same create call', /SAME `gh issue create`/.test(reasonOf(c, NOREPO)) && /--add-label/.test(reasonOf(c, NOREPO)));
57
+ const h = runHook(c, NOREPO);
58
+ ok('hook exits 2 with a block payload', h.code === 2 && JSON.parse(h.out).decision === 'block');
59
+ }
60
+ ok('no-label create is blocked in a trailhead repo', blocked(r3, TH));
61
+ ok('no-label block mentions the opt-out', reasonOf(r3, TH).includes('TRAILHEAD_PLAIN_ISSUE=1'));
62
+ const h = runHook(r3, TH);
63
+ ok('no-label hook exits 2 inside a trailhead repo', h.code === 2 && JSON.parse(h.out).code === 'TRAILHEAD_ISSUE_WITHOUT_LABELS');
64
+ ok('no-label hook exits 0 outside', runHook(r3, NOREPO).code === 0);
65
+ }
66
+
67
+ // --- a complete ticket is allowed ---
68
+ ok('whiteboard ticket passes', !blocked(create(FULL_WB), TH));
69
+ ok('map ticket passes', !blocked(create(FULL_MAP), TH));
70
+ ok('comma-separated full set passes', !blocked('gh issue create --title x --body y --label "trailhead:ticket,trailhead:task,trailhead:map-3"', TH));
71
+ ok('-l shorthand passes', !blocked('gh issue create -t x -b y -l trailhead:ticket -l trailhead:research -l trailhead:whiteboard', TH));
72
+ ok('--label=value passes', !blocked('gh issue create --title x --body y --label=trailhead:ticket --label=trailhead:decision --label=trailhead:whiteboard', TH));
73
+ ok('glued -lvalue passes', !blocked('gh issue create -t x -b y -ltrailhead:ticket -ltrailhead:task -ltrailhead:map-1', TH));
74
+ ok('quoted values pass', !blocked('gh issue create --title x --body y --label "trailhead:ticket" --label \'trailhead:prototype\' --label "trailhead:whiteboard"', TH));
75
+ ok('state labels are allowed extras', !blocked(create([...FULL_WB, 'trailhead:blocked', 'trailhead:needs-you', 'bug']), TH));
76
+ ok('non-trailhead labels beside a full set pass', !blocked(create([...FULL_MAP, 'enhancement']), NOREPO));
77
+ ok('every type passes', ['decision', 'research', 'prototype', 'build', 'bug', 'task']
78
+ .every((t) => !blocked(create(['trailhead:ticket', `trailhead:${t}`, 'trailhead:whiteboard']), TH)));
79
+ ok('seed shape passes', !blocked(create(['trailhead:ticket', 'trailhead:task', 'trailhead:map-4', 'trailhead:seed', 'trailhead:blocked']), TH));
80
+ ok('a seed without type is blocked', blocked(create(['trailhead:ticket', 'trailhead:map-4', 'trailhead:seed', 'trailhead:blocked']), TH));
81
+
82
+ // --- the shape rules, with the names in the reason ---
83
+ {
84
+ const noTicket = create(['trailhead:bug', 'trailhead:map-2']);
85
+ ok('missing ticket blocks', blocked(noTicket, NOREPO) && reasonOf(noTicket, NOREPO).includes('missing `trailhead:ticket`'));
86
+ const noType = create(['trailhead:ticket', 'trailhead:whiteboard']);
87
+ ok('missing type blocks', blocked(noType, NOREPO) && /missing a type label/.test(reasonOf(noType, NOREPO)));
88
+ ok('missing type names the choices', reasonOf(noType, NOREPO).includes('trailhead:decision'));
89
+ const noDest = create(['trailhead:ticket', 'trailhead:bug']);
90
+ ok('missing destination blocks', blocked(noDest, NOREPO) && /missing a destination/.test(reasonOf(noDest, NOREPO)));
91
+ const both = create(['trailhead:ticket', 'trailhead:bug', 'trailhead:map-2', 'trailhead:whiteboard']);
92
+ ok('both destinations block', blocked(both, NOREPO) && /two destinations \(trailhead:map-2 and trailhead:whiteboard\)/.test(reasonOf(both, NOREPO)));
93
+ const twoMaps = create(['trailhead:ticket', 'trailhead:bug', 'trailhead:map-2', 'trailhead:map-3']);
94
+ ok('two maps block', blocked(twoMaps, NOREPO) && /two map destinations \(trailhead:map-2, trailhead:map-3\)/.test(reasonOf(twoMaps, NOREPO)));
95
+ const twoTypes = create(['trailhead:ticket', 'trailhead:bug', 'trailhead:task', 'trailhead:whiteboard']);
96
+ ok('two types block', blocked(twoTypes, NOREPO) && /conflicting type labels \(trailhead:bug, trailhead:task\)/.test(reasonOf(twoTypes, NOREPO)));
97
+ const multi = create(['trailhead:whiteboard']);
98
+ ok('several gaps are all named', ['trailhead:ticket', 'type label'].every((n) => reasonOf(multi, NOREPO).includes(n)));
99
+ ok('a state label alone does not satisfy the shape', blocked(create(['trailhead:blocked']), NOREPO));
100
+ ok('trailhead:map alone is not a destination for a ticket',
101
+ !blocked(create(['trailhead:map']), NOREPO)); // it IS the map issue, exempt
102
+ ok('a bare map-x (not digits) is no destination', blocked(create(['trailhead:ticket', 'trailhead:bug', 'trailhead:map-x']), NOREPO));
103
+ ok('labels are matched case-insensitively', !blocked(create(['Trailhead:Ticket', 'TRAILHEAD:BUG', 'trailhead:WhiteBoard']), NOREPO));
104
+ }
105
+
106
+ // --- non-ticket trailhead issues are exempt ---
107
+ for (const l of ['trailhead:map', 'trailhead:dashboard', 'trailhead:codebase', 'trailhead:conventions', 'trailhead:fog', 'trailhead:unverified']) {
108
+ ok(`${l} create passes`, !blocked(create([l]), TH));
109
+ }
110
+ ok('map issue with extra labels passes', !blocked(create(['trailhead:map', 'trailhead:paused']), TH));
111
+
112
+ // --- no-label ground: only inside a trailhead repo ---
113
+ {
114
+ const bare = 'gh issue create --title x --body y';
115
+ ok('blocked at the trailhead repo root', blocked(bare, TH));
116
+ ok('blocked from a subdirectory (walks up to .git)', blocked(bare, THDEEP));
117
+ ok('blocked in a worktree whose .git is a file', blocked(bare, WT));
118
+ ok('allowed in a repo without .trailhead/', !blocked(bare, PLAIN_REPO));
119
+ ok('allowed outside any repo', !blocked(bare, NOREPO));
120
+ ok('allowed with an unlabelled non-trailhead label', !blocked(bare, NOREPO) && blocked(`${bare} --label bug`, TH));
121
+ ok('TRAILHEAD_PLAIN_ISSUE=1 opts out', !blocked(`TRAILHEAD_PLAIN_ISSUE=1 ${bare}`, TH));
122
+ ok('opt-out works on a compound command', !blocked(`cd x; TRAILHEAD_PLAIN_ISSUE=1 ${bare}`, TH));
123
+ ok('TRAILHEAD_PLAIN_ISSUE=0 does not opt out', blocked(`TRAILHEAD_PLAIN_ISSUE=0 ${bare}`, TH));
124
+ ok('opt-out never excuses a partial trailhead set', blocked(`TRAILHEAD_PLAIN_ISSUE=1 ${create(['trailhead:bug'])}`, TH));
125
+ ok('-R other/repo is not blocked on the no-label ground', !blocked(`${bare} -R other/repo`, TH));
126
+ ok('--repo=other/repo is not blocked on the no-label ground', !blocked(`${bare} --repo=other/repo`, TH));
127
+ ok('global -R before the subcommand is not blocked', !blocked('gh -R other/repo issue create --title x --body y', TH));
128
+ ok('GH_REPO=other/repo is not blocked on the no-label ground', !blocked(`GH_REPO=other/repo ${bare}`, TH));
129
+ ok('-R other/repo is still checked on the partial-label ground', blocked(`${create(['trailhead:bug'])} -R other/repo`, TH));
130
+ ok('--web with no labels is treated like any create', blocked('gh issue create --web', TH));
131
+ ok('an unreadable cwd fails open', !blocked(bare, path.join(tmp, 'does', 'not', 'exist')));
132
+ ok('inTrailheadRepo: .trailhead as a plain file is not a trailhead repo', (() => {
133
+ const d = mk('filedir', '.git');
134
+ fs.writeFileSync(path.join(d, '.trailhead'), 'x');
135
+ return inTrailheadRepo(d) === false;
136
+ })());
137
+ }
138
+
139
+ // --- unresolvable labels fail open ---
140
+ ok('$VAR label does not block', !blocked('gh issue create --title x --body y --label "$LABEL"', TH));
141
+ ok('$VAR beside a partial set does not block', !blocked('gh issue create --title x --body y --label trailhead:bug --label $L', TH));
142
+ ok('backtick label does not block', !blocked('gh issue create --title x --body y --label `cat l`', TH));
143
+ ok('$(...) label does not block', !blocked('gh issue create --title x --body y --label "$(echo trailhead:bug)"', TH));
144
+ ok('${VAR}-embedded label does not block', !blocked('gh issue create --title x --body y --label "trailhead:${KIND}"', TH));
145
+
146
+ // --- compound commands, wrappers, quoting ---
147
+ {
148
+ const bad = 'gh issue create --title x --body y --label trailhead:bug';
149
+ ok('after cd &&', blocked(`cd x && ${bad}`, NOREPO));
150
+ ok('after ;', blocked(`echo hi; ${bad}`, NOREPO));
151
+ ok('after &&', blocked(`true && ${bad}`, NOREPO));
152
+ ok('after ||', blocked(`false || ${bad}`, NOREPO));
153
+ ok('in a subshell', blocked(`(${bad})`, NOREPO));
154
+ ok('behind a leading assignment', blocked(`FOO=1 ${bad}`, NOREPO));
155
+ ok('behind a global flag', blocked('gh --hostname h issue create --title x --body y --label trailhead:bug', NOREPO));
156
+ ok('behind -R', blocked('gh -R o/r issue create --title x --body y --label trailhead:bug', NOREPO));
157
+ ok('quoted gh word', blocked('"gh" issue create --title x --body y --label trailhead:bug', NOREPO));
158
+ ok('the second of two creates is checked', blocked(`${create(FULL_WB)} && ${bad}`, NOREPO));
159
+ ok('two good creates pass', !blocked(`${create(FULL_WB)} && ${create(FULL_MAP)}`, TH));
160
+ ok('after a cd the cwd is unknown: no-label ground does not block', !blocked('cd /elsewhere && gh issue create --title x --body y', TH));
161
+ ok('a label-looking word in the title is not a label', blocked('gh issue create --title "--label trailhead:ticket" --body y --label trailhead:bug', NOREPO));
162
+ ok('a label-looking word in the body is not a label', !blocked('gh issue create --title x --body "-l trailhead:bug" --label trailhead:ticket --label trailhead:bug --label trailhead:whiteboard', NOREPO));
163
+ ok('a quoted gh text in an echo is not a create', !blocked('echo "gh issue create --label trailhead:bug"', TH));
164
+ ok('a heredoc body mentioning a create is not a create', !blocked('cat <<EOF\ngh issue create --label trailhead:bug\nEOF', TH));
165
+ ok('the create is blocked through a pipeline', blocked(`echo y | ${bad}`, NOREPO));
166
+ }
167
+
168
+ // --- everything that is not `gh issue create` is untouched ---
169
+ for (const c of [
170
+ 'gh issue edit 5 --add-label trailhead:bug',
171
+ 'gh issue list --label trailhead:bug',
172
+ 'gh issue view 5 --json labels',
173
+ 'gh issue comment 5 --body hi',
174
+ 'gh pr create --title x --body y',
175
+ 'gh pr create --title x --body y --label trailhead:bug',
176
+ 'git status',
177
+ 'gh label create trailhead:bug',
178
+ 'gh api repos/o/r/issues -f title=x',
179
+ '',
180
+ ]) ok(`untouched: ${c || '(empty)'}`, !blocked(c, TH));
181
+
182
+ // --- help and shell comments (review of #308) ---
183
+ ok('--help is not a create', !blocked('gh issue create --help', TH));
184
+ ok('-h is not a create', !blocked('gh issue create -h', TH));
185
+ ok('a trailing comment cannot supply the labels',
186
+ blocked('gh issue create -t x -b y # --label trailhead:ticket,trailhead:bug,trailhead:whiteboard', TH));
187
+ ok('a comment after a full set changes nothing', !blocked(`${create(FULL_WB)} # note`, TH));
188
+ ok('a quoted # is still a value', !blocked('gh issue create -t "#1 x" -b y --label "trailhead:ticket,trailhead:bug,trailhead:whiteboard"', TH));
189
+
190
+ // --- parseCreate ---
191
+ {
192
+ const p = parseCreate('gh issue create -l a,b --label=c -ld --title t');
193
+ ok('parseCreate reads every label form', JSON.stringify(p.labels) === JSON.stringify(['a', 'b', 'c', 'd']));
194
+ ok('parseCreate flags an unresolved value', parseCreate('gh issue create --label $X').unresolved === true);
195
+ ok('parseCreate sees -R', parseCreate('gh issue create -R o/r').repoFlag === true);
196
+ }
197
+
198
+ // --- crash-safety ---
199
+ ok('non-string input does not throw', evaluate(undefined, {}) === null && evaluate(null) === null);
200
+ {
201
+ const bad = spawnSync('node', [HOOK], { input: 'not json {{' });
202
+ ok('unparseable hook input exits 0', bad.status === 0);
203
+ const nul = spawnSync('node', [HOOK], { input: 'null' });
204
+ ok('null hook input exits 0', nul.status === 0);
205
+ const clean = runHook('git status', TH);
206
+ ok('a clean command exits 0 with empty stdout', clean.code === 0 && clean.out === '');
207
+ const good = runHook(create(FULL_WB), TH);
208
+ ok('a full whiteboard create exits 0 end to end', good.code === 0 && good.out === '');
209
+ }
210
+
211
+ fs.rmSync(tmp, { recursive: true, force: true });
212
+ console.log(`labels-guard: ${passed} assertions passed`);