@marcomigozzi/trailhead 0.13.0 → 0.15.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 (120) hide show
  1. package/README.it.md +142 -29
  2. package/README.md +141 -28
  3. package/bin/base-preflight-docs.test.js +65 -0
  4. package/bin/execute-split-docs.test.js +146 -0
  5. package/bin/isolation-answer-docs.test.js +41 -0
  6. package/bin/isolation-copy-docs.test.js +28 -0
  7. package/bin/lib/codex-projection.js +17 -10
  8. package/bin/lib/codex-projection.test.js +80 -11
  9. package/bin/lib/install-verify.js +10 -5
  10. package/bin/lib/install-verify.test.js +51 -0
  11. package/bin/report-status-docs.test.js +195 -0
  12. package/bin/resolve-helpers-docs.test.js +36 -0
  13. package/bin/scope-confirm-skip-docs.test.js +70 -0
  14. package/bin/trailhead.js +40 -28
  15. package/bin/trailhead.test.js +503 -6
  16. package/bin/uat-setup-docs.test.js +65 -0
  17. package/package.json +4 -1
  18. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  19. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  20. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  21. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  22. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  23. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  24. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  25. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  26. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  27. package/plugins/trailhead/commands/panel.md +10 -0
  28. package/plugins/trailhead/hooks/hooks.json +3 -18
  29. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  30. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  31. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  32. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  33. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  34. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  35. package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
  36. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  37. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  38. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  39. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  40. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  41. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  42. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  43. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  44. package/plugins/trailhead/hooks/lib/shell-scan.js +532 -65
  45. package/plugins/trailhead/hooks/lib/shell-scan.test.js +434 -0
  46. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  47. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  48. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  49. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  50. package/plugins/trailhead/hooks/lib/work-state.test.js +297 -0
  51. package/plugins/trailhead/hooks/mod/README.md +219 -0
  52. package/plugins/trailhead/hooks/mod/map-pane-view.ts +606 -0
  53. package/plugins/trailhead/hooks/mod/map-pane.tsx +832 -0
  54. package/plugins/trailhead/hooks/mod/register.tsx +293 -0
  55. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  56. package/plugins/trailhead/hooks/mod/work-state-view.ts +110 -0
  57. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  58. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  59. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  60. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  61. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  62. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  63. package/plugins/trailhead/hooks/trailhead-install-guard.js +187 -42
  64. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +182 -2
  65. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  66. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
  67. package/plugins/trailhead/hooks/trailhead-search-guard.js +59 -13
  68. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +68 -3
  69. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  70. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  71. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  72. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  73. package/plugins/trailhead/skills/_shared/choices.md +7 -3
  74. package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
  75. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  76. package/plugins/trailhead/skills/_shared/multi-map.md +3 -3
  77. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  78. package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
  79. package/plugins/trailhead/skills/_shared/substrate.md +6 -4
  80. package/plugins/trailhead/skills/_shared/teamwork.md +17 -6
  81. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
  82. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  83. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  84. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  85. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  86. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  87. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  88. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  89. package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
  90. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  91. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
  92. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  93. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  94. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  95. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  96. package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
  97. package/plugins/trailhead/skills/trailhead-work/SKILL.md +16 -13
  98. package/plugins/trailhead/skills/trailhead-work/references/auto.md +6 -4
  99. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  100. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +28 -10
  101. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  102. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  103. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  104. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  105. package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
  106. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  107. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  108. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  109. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  110. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  111. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  112. package/plugins/trailhead/templates/trailhead-work-state.js +217 -0
  113. package/plugins/trailhead/tests/map-pane-ui.test.ts +1921 -0
  114. package/plugins/trailhead/tests/map-pane-view.test.ts +853 -0
  115. package/plugins/trailhead/tests/mod.test.ts +14 -0
  116. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  117. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  118. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  119. package/plugins/trailhead/tests/work-state-view.test.ts +110 -0
  120. package/plugins/trailhead/types/index.d.ts +103 -0
@@ -7,8 +7,9 @@ const path = require('path');
7
7
  const fs = require('fs');
8
8
  const os = require('os');
9
9
 
10
- const HOOK = path.join(__dirname, 'trailhead-body-guard.js');
11
- const { ghBodyWrite, isEmptyBody } = require('./trailhead-body-guard.js');
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-body-guard.js');
12
+ const { ghBodyWrite, isEmptyBody, evaluate } = require('./trailhead-body-guard.js');
12
13
 
13
14
  let passed = 0;
14
15
  const ok = (name, cond) => { assert.ok(cond, name); passed++; };
@@ -87,6 +88,10 @@ ok("isEmptyBody('x') is false", isEmptyBody('x') === false);
87
88
  ok("isEmptyBody(' a ') is false", isEmptyBody(' a ') === false);
88
89
 
89
90
  // --- end-to-end: block ---
91
+ // #219: a quoted command word or subcommand is the same word for bash.
92
+ const bQuoted = runHook('gh "issue" edit 5 --body ""');
93
+ ok('blocks an empty --body behind a quoted subcommand (#219)', bQuoted.code === 2 && /EMPTY_ISSUE_BODY_WRITE/.test(bQuoted.out));
94
+
90
95
  const b1 = runHook('gh issue edit 5 --body ""');
91
96
  ok('blocks an empty inline --body (exit 2)', b1.code === 2 && /"decision":"block"/.test(b1.out) && /EMPTY_ISSUE_BODY_WRITE/.test(b1.out));
92
97
 
@@ -206,9 +211,144 @@ const a2 = runHook('gh issue view 5 --json body'); // read, not a write
206
211
  ok('allows a read (exit 0)', a2.code === 0 && a2.out.trim() === '');
207
212
  const a3 = runHook('gh issue comment 5 --body ""'); // excluded verb, even though empty
208
213
  ok('allows an empty gh issue comment (excluded verb, exit 0)', a3.code === 0 && a3.out.trim() === '');
209
- const a4 = runHook('gh issue edit 5 --body "$(cat f)"'); // unknowable, can't prove empty
214
+ // The merged runner also runs the secret-guard, which resolves `$(cat <path>)`: point it at a real clean file.
215
+ const a4File = path.join(os.tmpdir(), `bg-subst-${process.pid}.md`);
216
+ fs.writeFileSync(a4File, 'not empty\n');
217
+ const a4 = runHook(`gh issue edit 5 --body "$(cat ${a4File})"`); // unknowable, can't prove empty
218
+ fs.unlinkSync(a4File);
210
219
  ok('allows an unknowable (shell-expansion) body (exit 0)', a4.code === 0 && a4.out.trim() === '');
211
220
 
221
+ // --- regression (#211): an empty-body write chained after another command ---
222
+ const isBlock = (r) => r.code === 2 && /"decision":"block"/.test(r.out) && /EMPTY_ISSUE_BODY_WRITE/.test(r.out);
223
+ const isAllow = (r) => r.code === 0 && r.out.trim() === '';
224
+ ok('blocks an empty --body chained after cd (exit 2)', isBlock(runHook('cd /tmp && gh issue edit 5 --body ""')));
225
+ ok('blocks an empty --body after a variable assignment (exit 2)', isBlock(runHook('D=x && gh issue edit 5 --body ""')));
226
+ ok('blocks an empty --body inside a subshell (exit 2)', isBlock(runHook('( gh issue edit 5 --body "" )')));
227
+ ok('blocks an empty --body inside if/then (exit 2)', isBlock(runHook('if true; then gh issue edit 5 --body ""; fi')));
228
+ ok('blocks an empty write that is a LATER statement after a non-empty one (exit 2)',
229
+ isBlock(runHook('gh issue edit 5 --body "x"; gh issue edit 6 --body ""')));
230
+ ok('blocks a chained gh api -f body="" after cd (exit 2)',
231
+ isBlock(runHook('cd /tmp && gh api --method PATCH repos/o/r/issues/5 -f body=""')));
232
+ ok('allows a chained non-empty body (exit 0)', isAllow(runHook('cd /tmp && gh issue edit 5 --body "x"')));
233
+ ok('allows a chained empty gh issue comment (out of scope, exit 0)',
234
+ isAllow(runHook('cd /tmp && gh issue comment 5 --body ""')));
235
+ ok('allows a chained empty gh issue create (out of scope, exit 0)',
236
+ isAllow(runHook('cd /tmp && gh issue create --title t --body ""')));
237
+ {
238
+ // After a cd the working directory is unknown: a relative path cannot be proven empty.
239
+ const relName = `bg-rel-${process.pid}.md`;
240
+ fs.writeFileSync(path.join(process.cwd(), relName), '');
241
+ // evaluate() directly: the merged runner's secret-guard has its own (separate) block for this shape.
242
+ ok('allows a RELATIVE --body-file after cd (unknowable)',
243
+ evaluate(`cd /tmp && gh issue edit 5 --body-file ${relName}`).kind === 'allow');
244
+ ok('allows a RELATIVE api body=@path after cd (unknowable)',
245
+ evaluate(`cd /tmp && gh api --method PATCH repos/o/r/issues/5 -F body=@${relName}`).kind === 'allow');
246
+ ok('blocks the same RELATIVE --body-file when no cd precedes it',
247
+ evaluate(`gh issue edit 5 --body-file ${relName}`).kind === 'block');
248
+ fs.unlinkSync(path.join(process.cwd(), relName));
249
+ const absEmpty = path.join(os.tmpdir(), `bg-abs-empty-${process.pid}.md`);
250
+ fs.writeFileSync(absEmpty, '');
251
+ ok('blocks an ABSOLUTE empty --body-file after cd (exit 2)',
252
+ isBlock(runHook(`cd /tmp && gh issue edit 5 --body-file ${absEmpty}`)));
253
+ fs.unlinkSync(absEmpty);
254
+ }
255
+
256
+ {
257
+ // Partial install (no secret-guard next to the hook): the leading write still blocks.
258
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bg-partial-'));
259
+ fs.mkdirSync(path.join(dir, 'lib'));
260
+ fs.copyFileSync(path.join(__dirname, 'trailhead-body-guard.js'), path.join(dir, 'trailhead-body-guard.js'));
261
+ fs.copyFileSync(path.join(__dirname, 'lib', 'gh-subcommand.js'), path.join(dir, 'lib', 'gh-subcommand.js'));
262
+ fs.copyFileSync(path.join(__dirname, 'lib', 'body-sources.js'), path.join(dir, 'lib', 'body-sources.js'));
263
+ const partial = require(path.join(dir, 'trailhead-body-guard.js'));
264
+ ok('without secret-guard, a leading empty --body still blocks',
265
+ partial.evaluate('gh issue edit 5 --body ""').kind === 'block');
266
+ fs.rmSync(dir, { recursive: true, force: true });
267
+ }
268
+
269
+ // --- regression (#213): repeated / mixed body flags resolve like gh (pflag keeps the LAST) ---
270
+ {
271
+ const blk = (c) => evaluate(c).kind === 'block';
272
+ const alw = (c) => evaluate(c).kind === 'allow';
273
+ for (const sub of ['issue', 'pr']) {
274
+ const g = `gh ${sub} edit 5`;
275
+ ok(`#213 ${sub}: blocks --body "x" --body ""`, blk(`${g} --body "x" --body ""`));
276
+ ok(`#213 ${sub}: blocks --body=x --body=`, blk(`${g} --body=x --body=`));
277
+ ok(`#213 ${sub}: blocks -b "x" --body ""`, blk(`${g} -b "x" --body ""`));
278
+ ok(`#213 ${sub}: blocks -b "x" -b ""`, blk(`${g} -b "x" -b ""`));
279
+ ok(`#213 ${sub}: allows --body "" --body "x"`, alw(`${g} --body "" --body "x"`));
280
+ ok(`#213 ${sub}: allows --body= --body=x`, alw(`${g} --body= --body=x`));
281
+ ok(`#213 ${sub}: allows --body "" -b "x"`, alw(`${g} --body "" -b "x"`));
282
+ ok(`#213 ${sub}: allows --body '' --body 'x'`, alw(`${g} --body '' --body 'x'`));
283
+ }
284
+ const g = 'gh issue edit 5';
285
+ ok('#213 blocks --body "x" --body " "', blk(`${g} --body "x" --body " "`));
286
+ ok('#213 blocks an attached -b""', blk(`${g} -b""`));
287
+ ok('#213 allows --body "" --body "$(cat f)" (unknowable last)', alw(`${g} --body "" --body "$(cat f)"`));
288
+ ok('#213 allows --body "" --body $B (unknowable last)', alw(`${g} --body "" --body $B`));
289
+ ok('#213 allows --body "" --body-file /dev/null (gh mutual-exclusion error)', alw(`${g} --body "" --body-file /dev/null`));
290
+ ok('#213 allows --body "x" --body-file /dev/null (gh mutual-exclusion error)', alw(`${g} --body "x" --body-file /dev/null`));
291
+ ok('#213 allows --body "x" --body-file "" (empty file flag = absent)', alw(`${g} --body "x" --body-file ""`));
292
+ ok('#213 blocks --body "" --body-file "" (empty file flag = absent)', blk(`${g} --body "" --body-file ""`));
293
+ const full = path.join(os.tmpdir(), `bg-213-full-${process.pid}.md`);
294
+ fs.writeFileSync(full, 'real content\n');
295
+ ok('#213 blocks --body-file <full> --body-file /dev/null (last wins)', blk(`${g} --body-file ${full} --body-file /dev/null`));
296
+ ok('#213 allows --body-file /dev/null --body-file <full> (last wins)', alw(`${g} --body-file /dev/null --body-file ${full}`));
297
+ fs.unlinkSync(full);
298
+ ok('#213 per statement: blocks a later repeated-flag empty write',
299
+ blk('gh issue edit 5 --body "x" --body ""; gh issue edit 6 --body "x"'));
300
+ ok('#213 per statement: allows when the last --body of each statement is non-empty',
301
+ alw('gh issue edit 5 --body "" --body "x"; gh issue edit 6 --body "ok"'));
302
+ }
303
+
304
+ // --- regression (#213 review): backslash-newline continuations, stdin-like body files ---
305
+ {
306
+ const blk = (c) => evaluate(c).kind === 'block';
307
+ const alw = (c) => evaluate(c).kind === 'allow';
308
+ const { words } = require('./lib/body-sources.js');
309
+ ok('words: unindented continuation joins (a\\<nl>b -> ab)', JSON.stringify(words('a\\\nb')) === '["ab"]');
310
+ ok('words: spaced continuation keeps two words', JSON.stringify(words('a \\\nb')) === '["a","b"]');
311
+ ok('words: continuation inside double quotes is removed', JSON.stringify(words('"a\\\nb"')) === JSON.stringify(['"ab"']));
312
+ ok('words: continuation inside single quotes is literal', JSON.stringify(words("'a\\\nb'")) === JSON.stringify(["'a\\\nb'"]));
313
+ ok('#213 blocks --title t \\<nl>--body ""', blk('gh issue edit 5 --title t \\\n--body ""'));
314
+ ok('#213 blocks edit 5 \\<nl>--body ""', blk('gh issue edit 5 \\\n--body ""'));
315
+ ok('#213 blocks --body "x" \\<nl>--body ""', blk('gh issue edit 5 --body "x" \\\n--body ""'));
316
+ ok('#213 allows --body "" \\<nl>--body "x"', alw('gh issue edit 5 --body "" \\\n--body "x"'));
317
+ for (const f of ['/dev/stdin', '/dev/fd/0', '/proc/self/fd/0', '-']) {
318
+ const r = ghBodyWrite(`gh issue edit 5 --body-file ${f}`);
319
+ ok(`#213 --body-file ${f} is unknowable`, r && r.body === null);
320
+ }
321
+ // Run only this guard as a real hook process behind a shell pipe (stdin is a consumed pipe, like in production).
322
+ const bodyGuardOnly = (c) => {
323
+ try {
324
+ execFileSync('sh', ['-c', 'printf %s "$TH_IN" | "$TH_NODE" "$TH_HOOK"'], {
325
+ env: { ...process.env, TH_IN: JSON.stringify({ tool_input: { command: c } }), TH_NODE: process.execPath, TH_HOOK: path.join(__dirname, 'trailhead-body-guard.js') },
326
+ });
327
+ return true;
328
+ } catch { return false; }
329
+ };
330
+ ok('#213 negative control: the bodyGuardOnly harness sees a block', !bodyGuardOnly('gh issue edit 5 --body ""'));
331
+ {
332
+ // `..` after a symlink resolves in the kernel, not lexically: lnk/s -> real/sub, so lnk/s/../empty.md is real/empty.md.
333
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'bg-213-lnk-'));
334
+ fs.mkdirSync(path.join(d, 'real', 'sub'), { recursive: true });
335
+ fs.mkdirSync(path.join(d, 'lnk'));
336
+ fs.writeFileSync(path.join(d, 'real', 'empty.md'), '');
337
+ fs.writeFileSync(path.join(d, 'lnk', 'empty.md'), 'not empty\n');
338
+ fs.symlinkSync(path.join(d, 'real', 'sub'), path.join(d, 'lnk', 's'));
339
+ ok('#213 blocks a --body-file whose .. follows a symlink (read the raw path)',
340
+ blk(`gh issue edit 5 --body-file ${d}/lnk/s/../empty.md`));
341
+ fs.rmSync(d, { recursive: true, force: true });
342
+ }
343
+ for (const f of ['/dev//stdin', '/dev/fd//0', '/dev/./stdin']) {
344
+ ok(`#213 allows --body-file ${f} (non-canonical stdin spelling)`, bodyGuardOnly(`gh issue edit 5 --body-file ${f}`)); // the secret-guard judges these spellings on its own
345
+ }
346
+ ok('#213 pins: an escaped backslash before the newline is not a continuation (allows)',
347
+ alw('gh issue edit 5 --title t \\\\\n--body ""'));
348
+ ok('#213 pins: backslash CR LF is not a continuation (allows)',
349
+ alw('gh issue edit 5 --title t \\\r\n--body ""'));
350
+ }
351
+
212
352
  // --- crash-safety ---
213
353
  try {
214
354
  execFileSync('node', [HOOK], { input: 'not json at all {{{' });
@@ -219,4 +359,85 @@ try {
219
359
  const c2 = runHook('gh issue edit 5 --add-label x'); // non-body gh command
220
360
  ok('a non-body gh command exits 0', c2.code === 0 && c2.out.trim() === '');
221
361
 
362
+ // #225: a flag between the subcommand and the verb must not hide the write.
363
+ const bFlagVerb = runHook('gh issue -R o/r edit 5 --body ""');
364
+ ok('blocks an empty --body with a flag before the verb (exit 2)', bFlagVerb.code === 2 && /EMPTY_ISSUE_BODY_WRITE/.test(bFlagVerb.out));
365
+
366
+ // #227: constructs the guard cannot interpret are blocked, not emulated.
367
+ {
368
+ const CODE = 'BODY_GUARD_UNMODELLED_CONSTRUCT';
369
+ const clean = path.join(os.tmpdir(), `bg-um-clean-${process.pid}.md`);
370
+ fs.writeFileSync(clean, 'A real body.\n');
371
+ const code = (c) => { const r = runHook(c); return r.code === 2 ? (JSON.parse(r.out).code || '?') : null; };
372
+
373
+ // Gaps that used to pass.
374
+ for (const c of [
375
+ `gh api -X PATCH repos/o/r/issues/1 -f 'body'=""`,
376
+ 'gh api -X PATCH repos/o/r/issues/1 -f bo""dy=""',
377
+ 'printf -v B %s x; gh issue edit 1 --body "$B"',
378
+ 'source ./x.sh; gh issue edit 1 --body "$B"',
379
+ 'source ./x.sh; gh issue edit 1 --body-file rel.md',
380
+ '. ./x.sh && gh pr edit 1 --body "$B"',
381
+ `eval 'gh issue edit 1 --body real'`,
382
+ 'echo ${ gh issue edit 1 --body ""; }',
383
+ 'alias g=gh; g issue edit 1 --body ""',
384
+ 'f() { gh issue edit 1 --body real; }; f',
385
+ `gh issue ed\\\nit 1 --body real`,
386
+ // Through the merged runner the secret-guard speaks first on some of these (its own gate); still a block.
387
+ ]) ok(`blocks an unmodelled construct: ${c.slice(0, 50)}`, code(c) === CODE || (!!process.env.TRAILHEAD_GUARD_HOOK && code(c) !== null));
388
+
389
+ // An unknowable body behind a quoted key blocks too (through the merged runner the secret-guard may speak first).
390
+ ok('a quoted api key with an unknowable body blocks', code('gh api -X PATCH repos/o/r/issues/1 -f b\\ody="$B"') !== null);
391
+ ok('a quoted api key with an unreadable file blocks', code("gh api -X PATCH repos/o/r/issues/1 -f 'body'=@/nonexistent/bg-um.md") !== null);
392
+ for (const c of [
393
+ `printf -v B %s ''; gh issue edit 1 --body "it's $B and 'x'"`,
394
+ `source x.sh; gh issue edit 1 --body "it's $B and 'x'"`,
395
+ `printf -v B %s ''; printf %s "$B" | gh issue edit 1 --body-file -`,
396
+ `printf -v B %s ''; gh issue edit 1 --body-file - <<EOF\n$B\nEOF`,
397
+ ]) ok(`W2: a state-dependent write after the construct still blocks: ${c.slice(0, 40).replace(/\n/g, ' ')}`, code(c) !== null);
398
+ for (const c of ["source x; gh issue edit 1 --body 'lit'", 'source x; gh issue edit 1 --body lit', `source x; gh issue edit 1 --body "it's fine"`]) {
399
+ ok(`W2: a literal write after source passes: ${c}`, runHook(c).code === 0);
400
+ }
401
+ ok('the existing empty-body verdict keeps priority', code('source ./x.sh; gh issue edit 1 --body ""') === 'EMPTY_ISSUE_BODY_WRITE' ||
402
+ (!!process.env.TRAILHEAD_GUARD_HOOK && code('source ./x.sh; gh issue edit 1 --body ""') !== null));
403
+ ok('an empty body before the construct keeps its verdict', code('gh issue edit 1 --body ""; alias g=gh') === 'EMPTY_ISSUE_BODY_WRITE');
404
+
405
+ // Plain controls pass.
406
+ for (const c of [
407
+ "alias ll='ls -l'",
408
+ 'alias g=gh',
409
+ 'source ~/.venv/bin/activate && pytest',
410
+ 'eval "$(ssh-agent)"',
411
+ 'eval "$(ssh-agent)"; gh issue edit 1 --body-file ' + clean,
412
+ 'printf -v x %s y; echo $x',
413
+ `gh issue edit 1 --body-file ${clean}; source ./x.sh`,
414
+ `. venv/bin/activate && gh issue edit 1 --body 'hello'`,
415
+ `source venv/bin/activate && gh issue edit 1 --body-file ${clean}`,
416
+ 'f() { gh issue edit 1 --body x; }',
417
+ `gh issue edit 1 --body 'use eval, source and alias here'`,
418
+ `gh issue edit 1 --body-file ${clean} # eval x`,
419
+ `gh issue edit 1 \\\n --body-file ${clean}`,
420
+ 'gh api -X PATCH repos/o/r/issues/1 -f body=x',
421
+ `gh api -X PATCH repos/o/r/issues/1 -f 'body'=real`, // a known, non-empty body is read like the plain form
422
+ `gh api -X PATCH repos/o/r/issues/1 -f 'body'=@${clean}`,
423
+ 'gh api -X PATCH repos/o/r/issues/1 -f "body=x"',
424
+ `gh api -X PATCH repos/o/r/issues/1 -F title=x`,
425
+ ]) ok(`passes a plain form: ${c.slice(0, 50)}`, runHook(c).code === 0);
426
+
427
+ // Not a body-guard concern (a comment adds text, it overwrites nothing); the merged runner's
428
+ // secret-guard may block these for its own reasons, so only the standalone guard is checked.
429
+ if (!process.env.TRAILHEAD_GUARD_HOOK) {
430
+ for (const c of [
431
+ 'alias g=gh; g issue comment 1 --body x',
432
+ 'printf -v B %s x; gh issue comment 1 --body "$B"',
433
+ `source ./x.sh; gh issue comment 1 --body-file ${clean}`,
434
+ ]) ok(`passes a non-overwriting write: ${c.slice(0, 50)}`, runHook(c).code === 0);
435
+ }
436
+
437
+ const r = runHook(`gh api -X PATCH repos/o/r/issues/1 -f 'body'=""`);
438
+ const reason = JSON.parse(r.out).reason;
439
+ ok('the message names the guard and asks for the simple form', /^trailhead body-guard: /.test(reason) && /Rewrite it in a simple form/.test(reason));
440
+ fs.rmSync(clean);
441
+ }
442
+
222
443
  console.log(`✓ body-guard: ${passed} assertions passed`);
@@ -5,15 +5,17 @@
5
5
  // 2. Conventional Commits subject: <type>(<scope>)?: <subject>, <=72 chars.
6
6
  // Blocks with exit 2 + {"decision":"block","reason":...}; allows everything else.
7
7
  // Crash-safe: any error → exit 0 (never wedge the user's workflow).
8
- // Self-contained, no dependencies.
8
+ // Self-contained, no dependencies. Exports a pure evaluate(cmd) so the merged
9
+ // trailhead-bash-guards.js can run it in-process; run directly it is unchanged.
9
10
 
10
11
  const { checkCommitMessage } = require('./lib/commit-message-check.js');
11
12
 
12
13
  const CO_AUTHORED = /co-authored-by\s*:/i;
13
14
 
14
- function block(code, reason) {
15
- process.stdout.write(JSON.stringify({ decision: 'block', code, reason }));
16
- process.exit(2);
15
+ const ALLOW = { kind: 'allow' };
16
+
17
+ function blockVerdict(code, reason) {
18
+ return { kind: 'block', payload: { decision: 'block', code, reason } };
17
19
  }
18
20
 
19
21
  // Token-walk: is this shell string a `git commit …`? Handles env-var prefixes
@@ -50,18 +52,15 @@ function extractMessage(cmd) {
50
52
  return null;
51
53
  }
52
54
 
53
- let data = '';
54
- const timer = setTimeout(() => process.exit(0), 5000);
55
- process.stdin.on('data', (c) => (data += c));
56
- process.stdin.on('end', () => {
57
- clearTimeout(timer);
55
+ // Pure verdict for one command: {kind:'allow'} or {kind:'block', payload}. Never
56
+ // exits or writes; any internal error falls through to allow (crash-safe).
57
+ function evaluate(cmd) {
58
58
  try {
59
- const cmd = (JSON.parse(data).tool_input || {}).command || '';
60
- if (!isGitCommit(cmd)) process.exit(0);
59
+ if (!isGitCommit(cmd)) return ALLOW;
61
60
 
62
61
  // Co-Authored-By anywhere in the command (covers -m, -F, heredocs).
63
62
  if (CO_AUTHORED.test(cmd)) {
64
- block('CO_AUTHORED_BY_FORBIDDEN',
63
+ return blockVerdict('CO_AUTHORED_BY_FORBIDDEN',
65
64
  'trailhead: do not add a Co-Authored-By trailer to commits. Remove it and commit again.');
66
65
  }
67
66
 
@@ -71,10 +70,38 @@ process.stdin.on('end', () => {
71
70
  // `#` lines as message text (cleanup=whitespace), so a `#…` first line is
72
71
  // a real subject to validate, not a comment to skip.
73
72
  const verdict = checkCommitMessage(msg, { stripComments: false });
74
- if (!verdict.ok) block(verdict.code, verdict.reason);
73
+ if (!verdict.ok) return blockVerdict(verdict.code, verdict.reason);
75
74
  }
76
75
  } catch {
77
76
  // fall through to allow
78
77
  }
78
+ return ALLOW;
79
+ }
80
+
81
+ // Standalone CLI: write the payload and exit exactly as the guard always did.
82
+ function run(data) {
83
+ let cmd = '';
84
+ try {
85
+ cmd = (JSON.parse(data).tool_input || {}).command || '';
86
+ } catch {
87
+ process.exit(0);
88
+ }
89
+ const v = evaluate(cmd);
90
+ if (v.kind === 'block') {
91
+ process.stdout.write(JSON.stringify(v.payload));
92
+ process.exit(2);
93
+ }
79
94
  process.exit(0);
80
- });
95
+ }
96
+
97
+ if (require.main === module) {
98
+ let data = '';
99
+ const timer = setTimeout(() => process.exit(0), 5000);
100
+ process.stdin.on('data', (c) => (data += c));
101
+ process.stdin.on('end', () => {
102
+ clearTimeout(timer);
103
+ run(data);
104
+ });
105
+ }
106
+
107
+ module.exports = { evaluate };
@@ -5,7 +5,8 @@ const assert = require('assert');
5
5
  const { execFileSync } = require('child_process');
6
6
  const path = require('path');
7
7
 
8
- const HOOK = path.join(__dirname, 'trailhead-commit-guard.js');
8
+ // TRAILHEAD_GUARD_HOOK re-runs this suite through the merged trailhead-bash-guards.js.
9
+ const HOOK = process.env.TRAILHEAD_GUARD_HOOK || path.join(__dirname, 'trailhead-commit-guard.js');
9
10
 
10
11
  let passed = 0;
11
12
  const ok = (name, cond) => { assert.ok(cond, name); passed++; };