@marcomigozzi/trailhead 0.14.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 (92) hide show
  1. package/README.it.md +118 -30
  2. package/README.md +107 -29
  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 +1 -1
  8. package/bin/lib/codex-projection.test.js +41 -0
  9. package/bin/report-status-docs.test.js +195 -0
  10. package/bin/resolve-helpers-docs.test.js +36 -0
  11. package/bin/scope-confirm-skip-docs.test.js +70 -0
  12. package/bin/trailhead.js +3 -3
  13. package/bin/trailhead.test.js +341 -1
  14. package/bin/uat-setup-docs.test.js +65 -0
  15. package/package.json +1 -1
  16. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  17. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  18. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  19. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  20. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  21. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  22. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  23. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  24. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  25. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  26. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  27. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  28. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  29. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  30. package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
  31. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  32. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  33. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  34. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  35. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  36. package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
  37. package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
  38. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  39. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  40. package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
  41. package/plugins/trailhead/hooks/mod/README.md +38 -6
  42. package/plugins/trailhead/hooks/mod/map-pane-view.ts +168 -15
  43. package/plugins/trailhead/hooks/mod/map-pane.tsx +275 -106
  44. package/plugins/trailhead/hooks/mod/register.tsx +15 -2
  45. package/plugins/trailhead/hooks/mod/work-state-view.ts +17 -0
  46. package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
  47. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
  48. package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
  49. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
  50. package/plugins/trailhead/skills/_shared/choices.md +4 -2
  51. package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
  52. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  53. package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
  54. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  55. package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
  56. package/plugins/trailhead/skills/_shared/substrate.md +6 -4
  57. package/plugins/trailhead/skills/_shared/teamwork.md +16 -7
  58. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
  59. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  60. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  61. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  62. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  63. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  64. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  65. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  66. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  67. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
  68. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  69. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  70. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  71. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  72. package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
  73. package/plugins/trailhead/skills/trailhead-work/SKILL.md +14 -12
  74. package/plugins/trailhead/skills/trailhead-work/references/auto.md +4 -4
  75. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  76. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +26 -10
  77. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  78. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  79. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  80. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  81. package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
  82. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  83. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  84. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  85. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  86. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  87. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  88. package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
  89. package/plugins/trailhead/tests/map-pane-ui.test.ts +566 -19
  90. package/plugins/trailhead/tests/map-pane-view.test.ts +304 -16
  91. package/plugins/trailhead/tests/work-state-view.test.ts +25 -1
  92. package/plugins/trailhead/types/index.d.ts +37 -3
@@ -0,0 +1,513 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/trailhead-base-preflight.js. Run: node base-preflight.test.js
3
+ // No framework: plain asserts, same style as isolation-copy.test.js.
4
+ //
5
+ // Every scenario uses real git repos: a local bare `origin`, a working clone, and a
6
+ // second clone ("other") that pushes new trunk commits to make the working clone stale.
7
+ const assert = require('assert');
8
+ const { execFileSync } = require('child_process');
9
+ const path = require('path');
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+
13
+ const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-base-preflight.js');
14
+ const { main, parseArgs } = require(scriptPath);
15
+
16
+ let passed = 0;
17
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
18
+ const eq = (name, actual, expected) => {
19
+ assert.deepStrictEqual(actual, expected, `${name}: got ${JSON.stringify(actual)}, want ${JSON.stringify(expected)}`);
20
+ passed++;
21
+ };
22
+
23
+ const mktmp = () => fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-base-preflight-test-')));
24
+ const git = (dir, ...args) => execFileSync('git', ['-C', dir, ...args], { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
25
+ const gitOk = (dir, ...args) => { try { git(dir, ...args); return true; } catch (err) { return false; } };
26
+
27
+ function identify(dir) {
28
+ git(dir, 'config', 'user.email', 't@example.com');
29
+ git(dir, 'config', 'user.name', 't');
30
+ }
31
+
32
+ function commitFile(dir, rel, content, msg) {
33
+ fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
34
+ fs.writeFileSync(path.join(dir, rel), content);
35
+ git(dir, 'add', rel);
36
+ git(dir, 'commit', '-q', '-m', msg || `edit ${rel}`);
37
+ }
38
+
39
+ // A bare origin plus a working clone on `trunk`, one commit pushed.
40
+ function mkWorld(trunk = 'main') {
41
+ const origin = mktmp();
42
+ git(origin, 'init', '--bare', '-q', '-b', trunk);
43
+ const work = mktmp();
44
+ git(work, 'init', '-q', '-b', trunk);
45
+ identify(work);
46
+ git(work, 'remote', 'add', 'origin', origin);
47
+ commitFile(work, 'README.md', 'x\n', 'init');
48
+ git(work, 'push', '-q', '-u', 'origin', trunk);
49
+ return { origin, work, trunk };
50
+ }
51
+
52
+ // Pushes `n` commits to the origin trunk from a throwaway clone.
53
+ function advanceOrigin(world, n, file = 'upstream.txt') {
54
+ const other = mktmp();
55
+ execFileSync('git', ['clone', '-q', world.origin, other], { stdio: 'ignore' });
56
+ identify(other);
57
+ git(other, 'checkout', '-q', world.trunk);
58
+ for (let i = 0; i < n; i++) commitFile(other, n === 1 && file !== 'upstream.txt' ? file : `${path.basename(file, '.txt')}-${i}.txt`, `u${i}\n`, `upstream ${i}`);
59
+ git(other, 'push', '-q', 'origin', world.trunk);
60
+ return git(other, 'rev-parse', 'HEAD');
61
+ }
62
+
63
+ function run(argv, { env = {}, cwd = '/' } = {}) {
64
+ const chunks = [];
65
+ const errChunks = [];
66
+ const realErr = process.stderr.write.bind(process.stderr);
67
+ process.stderr.write = (s) => { errChunks.push(String(s)); return true; };
68
+ let code;
69
+ try {
70
+ code = main(argv, { cwd, env: { HOME: '/nonexistent-home', ...env }, stdout: { write: (s) => chunks.push(s) } });
71
+ } finally {
72
+ process.stderr.write = realErr;
73
+ }
74
+ const lines = chunks.join('').split('\n').filter(Boolean).map((l) => JSON.parse(l));
75
+ return { code, out: lines[0], lines, err: errChunks.join('') };
76
+ }
77
+
78
+ const emptyHome = mktmp();
79
+ const envNoGlobal = { CLAUDE_CONFIG_DIR: emptyHome };
80
+ const pre = (dir, extra = [], opts = {}) => run(['--repo', dir, ...extra], { env: envNoGlobal, ...opts });
81
+ const writeConfig = (dir, cfg) => {
82
+ fs.mkdirSync(path.join(dir, '.trailhead'), { recursive: true });
83
+ fs.writeFileSync(path.join(dir, '.trailhead', 'config.json'), typeof cfg === 'string' ? cfg : JSON.stringify(cfg));
84
+ };
85
+ const inProgress = (dir) => {
86
+ const gd = git(dir, 'rev-parse', '--absolute-git-dir');
87
+ return ['rebase-merge', 'rebase-apply', 'MERGE_HEAD'].some((n) => fs.existsSync(path.join(gd, n)));
88
+ };
89
+ const cleanTree = (dir) => git(dir, 'status', '--porcelain', '--untracked-files=no') === '';
90
+
91
+ // --- parseArgs -----------------------------------------------------------------
92
+ ok('parseArgs: missing --repo', parseArgs([]).error !== undefined);
93
+ ok('parseArgs: unknown flag', parseArgs(['--repo', 'x', '--nope']).error !== undefined);
94
+ ok('parseArgs: value needed', parseArgs(['--repo']).error !== undefined);
95
+ {
96
+ const a = parseArgs(['--repo', 'r', '--config-root', 'c', '--trunk', 't', '--sync', '--no-fetch']);
97
+ eq('parseArgs: full', a, { repo: 'r', 'config-root': 'c', trunk: 't', sync: true, 'no-fetch': true });
98
+ }
99
+
100
+ // --- usage errors --------------------------------------------------------------
101
+ {
102
+ const r = run([]);
103
+ eq('usage: exit 2 without --repo', r.code, 2);
104
+ ok('usage: message on stderr', /usage/.test(r.err));
105
+ eq('usage: unknown flag exits 2', run(['--repo', '/', '--wat']).code, 2);
106
+ }
107
+
108
+ // --- current -------------------------------------------------------------------
109
+ {
110
+ const w = mkWorld();
111
+ const r = pre(w.work);
112
+ eq('current: exit 0', r.code, 0);
113
+ eq('current: status', r.out.status, 'current');
114
+ eq('current: trunk', r.out.trunk, 'main');
115
+ eq('current: base', r.out.base, 'origin/main');
116
+ eq('current: behind', r.out.behind, 0);
117
+ eq('current: testsRequired false', r.out.testsRequired, false);
118
+ eq('current: no checks configured passes', r.out.checksOk, true);
119
+ eq('current: one JSON line', r.lines.length, 1);
120
+ }
121
+
122
+ // --- behind on the trunk: fetch advances origin/main, ff sync -------------------
123
+ {
124
+ const w = mkWorld();
125
+ const tip = advanceOrigin(w, 3);
126
+ const r = pre(w.work);
127
+ eq('behind: status without --sync', r.out.status, 'behind');
128
+ eq('behind: counts commits', r.out.behind, 3);
129
+ eq('fetch: origin/main advanced to the remote tip', git(w.work, 'rev-parse', 'origin/main'), tip);
130
+ eq('behind: tree untouched without --sync', git(w.work, 'rev-parse', 'HEAD') === tip, false);
131
+ const s = pre(w.work, ['--sync']);
132
+ eq('ff: status', s.out.status, 'synced');
133
+ eq('ff: method', s.out.method, 'ff');
134
+ eq('ff: pulled', s.out.pulled, 3);
135
+ eq('ff: testsRequired', s.out.testsRequired, true);
136
+ eq('ff: HEAD at the remote tip', git(w.work, 'rev-parse', 'HEAD'), tip);
137
+ }
138
+
139
+ // --- trunk diverged from origin while on it: rebase ----------------------------
140
+ {
141
+ const w = mkWorld();
142
+ commitFile(w.work, 'local.txt', 'l\n', 'local unpushed');
143
+ advanceOrigin(w, 2);
144
+ const s = pre(w.work, ['--sync']);
145
+ eq('trunk rebase: status', s.out.status, 'synced');
146
+ eq('trunk rebase: method', s.out.method, 'rebase');
147
+ eq('trunk rebase: pulled', s.out.pulled, 2);
148
+ eq('trunk rebase: local commit kept on top', git(w.work, 'log', '-1', '--format=%s'), 'local unpushed');
149
+ eq('trunk rebase: ahead is the one local commit', git(w.work, 'rev-list', '--count', 'origin/main..HEAD'), '1');
150
+ }
151
+
152
+ // --- ticket branch, unpublished: rebase ----------------------------------------
153
+ {
154
+ const w = mkWorld();
155
+ git(w.work, 'checkout', '-q', '-b', 'trailhead/t1');
156
+ commitFile(w.work, 'ticket.txt', 't\n', 'ticket work');
157
+ advanceOrigin(w, 2);
158
+ const r = pre(w.work);
159
+ eq('branch: base is origin/main when local main is behind', r.out.base, 'origin/main');
160
+ eq('branch: behind', r.out.behind, 2);
161
+ eq('branch: ahead', r.out.ahead, 1);
162
+ const s = pre(w.work, ['--sync']);
163
+ eq('branch unpublished: method rebase', s.out.method, 'rebase');
164
+ eq('branch unpublished: linear history', git(w.work, 'rev-list', '--merges', '--count', 'HEAD'), '0');
165
+ eq('branch unpublished: ticket commit on top', git(w.work, 'log', '-1', '--format=%s'), 'ticket work');
166
+ }
167
+
168
+ // --- ticket branch, published: merge, never a rewrite --------------------------
169
+ {
170
+ const w = mkWorld();
171
+ git(w.work, 'checkout', '-q', '-b', 'trailhead/t2');
172
+ commitFile(w.work, 'ticket.txt', 't\n', 'ticket work');
173
+ git(w.work, 'push', '-q', '-u', 'origin', 'trailhead/t2');
174
+ const before = git(w.work, 'rev-parse', 'HEAD');
175
+ advanceOrigin(w, 1);
176
+ const s = pre(w.work, ['--sync']);
177
+ eq('branch published: status', s.out.status, 'synced');
178
+ eq('branch published: method merge', s.out.method, 'merge');
179
+ eq('branch published: one merge commit', git(w.work, 'rev-list', '--merges', '--count', 'HEAD'), '1');
180
+ ok('branch published: published commit still an ancestor', gitOk(w.work, 'merge-base', '--is-ancestor', before, 'HEAD'));
181
+ }
182
+
183
+ // --- ticket branch with the local trunk ahead of origin ------------------------
184
+ {
185
+ const w = mkWorld();
186
+ git(w.work, 'checkout', '-q', '-b', 'trailhead/t3');
187
+ git(w.work, 'checkout', '-q', 'main');
188
+ commitFile(w.work, 'integrated.txt', 'i\n', 'integrated locally, not pushed');
189
+ git(w.work, 'checkout', '-q', 'trailhead/t3');
190
+ const r = pre(w.work);
191
+ eq('local trunk ahead: base is the local trunk', r.out.base, 'main');
192
+ eq('local trunk ahead: branch is behind the local trunk', r.out.behind, 1);
193
+ eq('local trunk ahead: status', r.out.status, 'behind');
194
+ const s = pre(w.work, ['--sync']);
195
+ eq('local trunk ahead: synced onto the local trunk', s.out.status, 'synced');
196
+ eq('local trunk ahead: pulled the local commit', git(w.work, 'log', '-1', '--format=%s'), 'integrated locally, not pushed');
197
+ }
198
+
199
+ // --- ticket branch with a diverged trunk: stop, nothing synced -----------------
200
+ {
201
+ const w = mkWorld();
202
+ git(w.work, 'checkout', '-q', '-b', 'trailhead/t4');
203
+ git(w.work, 'checkout', '-q', 'main');
204
+ commitFile(w.work, 'integrated.txt', 'i\n', 'integrated locally, not pushed');
205
+ git(w.work, 'checkout', '-q', 'trailhead/t4');
206
+ advanceOrigin(w, 1);
207
+ const head = git(w.work, 'rev-parse', 'HEAD');
208
+ const s = pre(w.work, ['--sync']);
209
+ eq('trunk-diverged: status', s.out.status, 'trunk-diverged');
210
+ eq('trunk-diverged: HEAD unchanged', git(w.work, 'rev-parse', 'HEAD'), head);
211
+ eq('trunk-diverged: no testsRequired', s.out.testsRequired, false);
212
+ eq('trunk-diverged: checks not ok', s.out.checksOk, false);
213
+ }
214
+
215
+ // --- conflicts: aborted, tree unchanged, paths listed --------------------------
216
+ {
217
+ // rebase conflict on the trunk
218
+ const w = mkWorld();
219
+ commitFile(w.work, 'clash.txt', 'local\n', 'local clash');
220
+ const other = mktmp();
221
+ execFileSync('git', ['clone', '-q', w.origin, other], { stdio: 'ignore' });
222
+ identify(other);
223
+ commitFile(other, 'clash.txt', 'remote\n', 'remote clash');
224
+ git(other, 'push', '-q', 'origin', 'main');
225
+ const head = git(w.work, 'rev-parse', 'HEAD');
226
+ const s = pre(w.work, ['--sync']);
227
+ eq('rebase conflict: status', s.out.status, 'conflict');
228
+ eq('rebase conflict: paths', s.out.conflicts, ['clash.txt']);
229
+ eq('rebase conflict: HEAD restored', git(w.work, 'rev-parse', 'HEAD'), head);
230
+ ok('rebase conflict: no rebase left behind', !inProgress(w.work));
231
+ ok('rebase conflict: tree clean', cleanTree(w.work));
232
+ eq('rebase conflict: file content untouched', fs.readFileSync(path.join(w.work, 'clash.txt'), 'utf8'), 'local\n');
233
+ }
234
+ {
235
+ // merge conflict on a published ticket branch
236
+ const w = mkWorld();
237
+ git(w.work, 'checkout', '-q', '-b', 'trailhead/t5');
238
+ commitFile(w.work, 'clash.txt', 'branch\n', 'branch clash');
239
+ git(w.work, 'push', '-q', '-u', 'origin', 'trailhead/t5');
240
+ const other = mktmp();
241
+ execFileSync('git', ['clone', '-q', w.origin, other], { stdio: 'ignore' });
242
+ identify(other);
243
+ git(other, 'checkout', '-q', 'main');
244
+ commitFile(other, 'clash.txt', 'trunk\n', 'trunk clash');
245
+ git(other, 'push', '-q', 'origin', 'main');
246
+ const head = git(w.work, 'rev-parse', 'HEAD');
247
+ writeConfig(w.work, { preflight: { checks: ['echo should-not-run'] } });
248
+ const s = pre(w.work, ['--sync']);
249
+ eq('merge conflict: status', s.out.status, 'conflict');
250
+ eq('merge conflict: paths', s.out.conflicts, ['clash.txt']);
251
+ eq('merge conflict: HEAD restored', git(w.work, 'rev-parse', 'HEAD'), head);
252
+ ok('merge conflict: no merge left behind', !inProgress(w.work));
253
+ eq('merge conflict: checks skipped', s.out.checks, []);
254
+ eq('merge conflict: checksOk false', s.out.checksOk, false);
255
+ }
256
+
257
+ // --- a non-conflict failure mid-operation: state preserved ---------------------
258
+ {
259
+ // untracked file that the fast-forward would overwrite
260
+ const w = mkWorld();
261
+ advanceOrigin(w, 1, 'blocker.txt');
262
+ fs.writeFileSync(path.join(w.work, 'blocker.txt'), 'mine\n');
263
+ const head = git(w.work, 'rev-parse', 'HEAD');
264
+ const s = pre(w.work, ['--sync']);
265
+ eq('untracked blocks ff: status', s.out.status, 'sync-failed');
266
+ ok('untracked blocks ff: git message reported', typeof s.out.message === 'string' && s.out.message.length > 0);
267
+ eq('untracked blocks ff: HEAD unchanged', git(w.work, 'rev-parse', 'HEAD'), head);
268
+ eq('untracked blocks ff: file preserved', fs.readFileSync(path.join(w.work, 'blocker.txt'), 'utf8'), 'mine\n');
269
+ ok('untracked blocks ff: nothing in progress', !inProgress(w.work));
270
+ }
271
+ {
272
+ // same, mid-rebase on an unpublished ticket branch
273
+ const w = mkWorld();
274
+ git(w.work, 'checkout', '-q', '-b', 'trailhead/t6');
275
+ commitFile(w.work, 'ticket.txt', 't\n', 'ticket work');
276
+ advanceOrigin(w, 1, 'blocker.txt');
277
+ fs.writeFileSync(path.join(w.work, 'blocker.txt'), 'mine\n');
278
+ const head = git(w.work, 'rev-parse', 'HEAD');
279
+ const s = pre(w.work, ['--sync']);
280
+ eq('untracked blocks rebase: status', s.out.status, 'sync-failed');
281
+ eq('untracked blocks rebase: HEAD restored', git(w.work, 'rev-parse', 'HEAD'), head);
282
+ eq('untracked blocks rebase: still on the branch', git(w.work, 'symbolic-ref', '--short', 'HEAD'), 'trailhead/t6');
283
+ ok('untracked blocks rebase: nothing in progress', !inProgress(w.work));
284
+ eq('untracked blocks rebase: file preserved', fs.readFileSync(path.join(w.work, 'blocker.txt'), 'utf8'), 'mine\n');
285
+ }
286
+
287
+ // --- dirty tracked tree --------------------------------------------------------
288
+ {
289
+ const w = mkWorld();
290
+ advanceOrigin(w, 1);
291
+ fs.writeFileSync(path.join(w.work, 'README.md'), 'changed\n');
292
+ writeConfig(w.work, { preflight: { checks: ['echo should-not-run'] } });
293
+ const head = git(w.work, 'rev-parse', 'HEAD');
294
+ const s = pre(w.work, ['--sync']);
295
+ eq('dirty: status', s.out.status, 'dirty');
296
+ eq('dirty: HEAD unchanged', git(w.work, 'rev-parse', 'HEAD'), head);
297
+ eq('dirty: edit kept', fs.readFileSync(path.join(w.work, 'README.md'), 'utf8'), 'changed\n');
298
+ eq('dirty: checks skipped', s.out.checks, []);
299
+ eq('dirty: checksOk false', s.out.checksOk, false);
300
+ }
301
+
302
+ // --- failed fetch: unverified, no sync, no checks ------------------------------
303
+ {
304
+ const w = mkWorld();
305
+ advanceOrigin(w, 2);
306
+ eq('unverified setup: sees behind 2', pre(w.work).out.behind, 2);
307
+ const marker = path.join(w.work, 'check-ran');
308
+ writeConfig(w.work, { preflight: { checks: [`touch ${marker}`] } });
309
+ git(w.work, 'remote', 'set-url', 'origin', path.join(w.origin, 'does-not-exist'));
310
+ const head = git(w.work, 'rev-parse', 'HEAD');
311
+ const s = pre(w.work, ['--sync']);
312
+ eq('unverified: status', s.out.status, 'unverified');
313
+ eq('unverified: distance from local refs', s.out.behind, 2);
314
+ eq('unverified: HEAD unchanged', git(w.work, 'rev-parse', 'HEAD'), head);
315
+ ok('unverified: checks did not run', !fs.existsSync(marker));
316
+ eq('unverified: checksOk false', s.out.checksOk, false);
317
+ ok('unverified: a warning explains', s.out.warnings.length > 0);
318
+ }
319
+
320
+ // --- --no-fetch ----------------------------------------------------------------
321
+ {
322
+ const w = mkWorld();
323
+ advanceOrigin(w, 2);
324
+ const before = git(w.work, 'rev-parse', 'origin/main');
325
+ const r = pre(w.work, ['--no-fetch']);
326
+ eq('--no-fetch: status from local refs', r.out.status, 'current');
327
+ eq('--no-fetch: origin/main not advanced', git(w.work, 'rev-parse', 'origin/main'), before);
328
+ }
329
+
330
+ // --- --config-root --------------------------------------------------------------
331
+ {
332
+ const w = mkWorld();
333
+ const ws = path.join(mktmp(), 'ws');
334
+ git(w.work, 'worktree', 'add', '-q', '-b', 'trailhead/t7', ws);
335
+ writeConfig(w.work, { preflight: { checks: ['pwd'] } });
336
+ const r = pre(ws, ['--config-root', w.work]);
337
+ eq('config-root: check from the original checkout ran', r.out.checks.length, 1);
338
+ eq('config-root: check ran at the workspace root', r.out.checks[0].output.trim(), ws);
339
+ const none = pre(ws);
340
+ eq('config-root: workspace config is not consulted by default', none.out.checks, []);
341
+ }
342
+
343
+ // --- trunk detection ------------------------------------------------------------
344
+ {
345
+ const w = mkWorld('develop');
346
+ git(w.work, 'remote', 'set-head', 'origin', 'develop');
347
+ eq('trunk: remote default branch', pre(w.work).out.trunk, 'develop');
348
+ const solo = mktmp();
349
+ git(solo, 'init', '-q', '-b', 'master');
350
+ identify(solo);
351
+ commitFile(solo, 'a.txt', 'a\n', 'init');
352
+ const r = pre(solo);
353
+ eq('trunk: falls back to master', r.out.trunk, 'master');
354
+ eq('trunk: no remote uses the local trunk', r.out.base, 'master');
355
+ eq('trunk: no remote is current', r.out.status, 'current');
356
+ const none = mktmp();
357
+ git(none, 'init', '-q', '-b', 'zzz');
358
+ identify(none);
359
+ commitFile(none, 'a.txt', 'a\n', 'init');
360
+ eq('trunk: none found is skipped', pre(none).out.status, 'skipped');
361
+ eq('trunk: --trunk wins', pre(w.work, ['--trunk', 'develop']).out.trunk, 'develop');
362
+ }
363
+ {
364
+ const dir = mktmp();
365
+ const r = pre(dir);
366
+ eq('not a repo: exit 0', r.code, 0);
367
+ eq('not a repo: error status', r.out.status, 'error');
368
+ ok('not a repo: message', typeof r.out.message === 'string');
369
+ }
370
+
371
+ // --- project checks -------------------------------------------------------------
372
+ {
373
+ const w = mkWorld();
374
+ writeConfig(w.work, { preflight: { checks: ['echo hello', 'echo bad >&2; exit 3'] } });
375
+ const r = pre(w.work);
376
+ eq('checks: two results', r.out.checks.length, 2);
377
+ eq('checks: pass', [r.out.checks[0].ok, r.out.checks[0].code], [true, 0]);
378
+ eq('checks: pass output', r.out.checks[0].output.trim(), 'hello');
379
+ eq('checks: fail', [r.out.checks[1].ok, r.out.checks[1].code], [false, 3]);
380
+ ok('checks: stderr captured', r.out.checks[1].output.includes('bad'));
381
+ eq('checks: command echoed', r.out.checks[1].command, 'echo bad >&2; exit 3');
382
+ eq('checks: one failing makes checksOk false', r.out.checksOk, false);
383
+ eq('checks: status still current', r.out.status, 'current');
384
+
385
+ writeConfig(w.work, { 'preflight.checks': ['true'] });
386
+ const flat = pre(w.work);
387
+ eq('checks: flat form runs', flat.out.checks.length, 1);
388
+ eq('checks: flat form ok', flat.out.checksOk, true);
389
+
390
+ writeConfig(w.work, { preflight: { checks: ['true'] }, 'preflight.checks': ['false'] });
391
+ eq('checks: nested wins over flat', pre(w.work).out.checks[0].command, 'true');
392
+
393
+ writeConfig(w.work, { preflight: { checks: 'not-an-array' } });
394
+ const bad = pre(w.work);
395
+ eq('checks: invalid value runs nothing', bad.out.checks, []);
396
+ ok('checks: invalid value named', /preflight\.checks/.test(bad.out.checksError));
397
+ eq('checks: invalid value is not ok', bad.out.checksOk, false);
398
+ writeConfig(w.work, { preflight: { checks: ['ok', ''] } });
399
+ ok('checks: empty entry invalid', pre(w.work).out.checksError !== undefined);
400
+ writeConfig(w.work, '{not json');
401
+ ok('checks: bad JSON invalid', pre(w.work).out.checksError !== undefined);
402
+ }
403
+ {
404
+ // project over global, as a whole value
405
+ const w = mkWorld();
406
+ const home = mktmp();
407
+ fs.mkdirSync(path.join(home, 'trailhead'), { recursive: true });
408
+ fs.writeFileSync(path.join(home, 'trailhead', 'config.json'), JSON.stringify({ preflight: { checks: ['echo global'] } }));
409
+ const viaGlobal = run(['--repo', w.work], { env: { CLAUDE_CONFIG_DIR: home } });
410
+ eq('checks: global applies when the project has none', viaGlobal.out.checks[0].command, 'echo global');
411
+ writeConfig(w.work, { preflight: { checks: ['echo project'] } });
412
+ const viaProject = run(['--repo', w.work], { env: { CLAUDE_CONFIG_DIR: home } });
413
+ eq('checks: project wins as a whole value', viaProject.out.checks.map((c) => c.command), ['echo project']);
414
+ }
415
+ {
416
+ // checks run after the sync, on the synced tree
417
+ const w = mkWorld();
418
+ advanceOrigin(w, 1);
419
+ writeConfig(w.work, { preflight: { checks: ['test -f upstream-0.txt'] } });
420
+ const r = pre(w.work, ['--sync']);
421
+ eq('checks: run after the sync', [r.out.status, r.out.checks[0].ok, r.out.checksOk], ['synced', true, true]);
422
+ }
423
+
424
+ // --- git failures are errors, never a silent 0 ----------------------------------
425
+ {
426
+ // unborn HEAD with an origin/main that has commits
427
+ const w = mkWorld();
428
+ const unborn = mktmp();
429
+ git(unborn, 'init', '-q', '-b', 'main');
430
+ git(unborn, 'remote', 'add', 'origin', w.origin);
431
+ git(unborn, 'fetch', '-q', 'origin');
432
+ const r = pre(unborn);
433
+ eq('unborn HEAD: exit 0', r.code, 0);
434
+ eq('unborn HEAD: error, never current', r.out.status, 'error');
435
+ ok('unborn HEAD: git message reported', typeof r.out.message === 'string' && r.out.message.length > 0);
436
+ eq('unborn HEAD: testsRequired false', r.out.testsRequired, false);
437
+ eq('unborn HEAD: checksOk false', r.out.checksOk, false);
438
+ }
439
+ {
440
+ // a remote-tracking ref whose history is broken (a parent object is missing)
441
+ const w = mkWorld();
442
+ git(w.work, 'checkout', '-q', '-b', 'trailhead/t8');
443
+ const tree = git(w.work, 'rev-parse', 'HEAD^{tree}');
444
+ const broken = execFileSync('git', ['-C', w.work, 'hash-object', '-w', '-t', 'commit', '--stdin'], {
445
+ input: `tree ${tree}\nparent deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\nauthor a <a@b> 1 +0000\ncommitter a <a@b> 1 +0000\n\nbroken\n`,
446
+ }).toString().trim();
447
+ git(w.work, 'update-ref', 'refs/remotes/origin/main', broken);
448
+ const r = pre(w.work, ['--no-fetch']);
449
+ eq('bad ref: error, never current', r.out.status, 'error');
450
+ ok('bad ref: git message reported', typeof r.out.message === 'string' && r.out.message.length > 0);
451
+ eq('bad ref: nothing synced', pre(w.work, ['--no-fetch', '--sync']).out.status, 'error');
452
+ }
453
+
454
+ // --- rebase keeps merge commits ---------------------------------------------------
455
+ {
456
+ const w = mkWorld();
457
+ git(w.work, 'checkout', '-q', '-b', 'trailhead/t10');
458
+ git(w.work, 'checkout', '-q', '-b', 'side');
459
+ commitFile(w.work, 'side.txt', 's\n', 'side work');
460
+ git(w.work, 'checkout', '-q', 'trailhead/t10');
461
+ commitFile(w.work, 'ticket.txt', 't\n', 'ticket work');
462
+ git(w.work, 'merge', '-q', '--no-ff', '-m', 'merge side', 'side');
463
+ advanceOrigin(w, 2);
464
+ const s = pre(w.work, ['--sync']);
465
+ eq('rebase-merges: branch rebased', [s.out.status, s.out.method], ['synced', 'rebase']);
466
+ eq('rebase-merges: the --no-ff merge commit survives', git(w.work, 'rev-list', '--merges', '--count', 'origin/main..HEAD'), '1');
467
+ }
468
+ {
469
+ const w = mkWorld();
470
+ git(w.work, 'checkout', '-q', '-b', 'side');
471
+ commitFile(w.work, 'side.txt', 's\n', 'side work');
472
+ git(w.work, 'checkout', '-q', 'main');
473
+ commitFile(w.work, 'local.txt', 'l\n', 'local unpushed');
474
+ git(w.work, 'merge', '-q', '--no-ff', '-m', 'merge side', 'side');
475
+ advanceOrigin(w, 1);
476
+ const s = pre(w.work, ['--sync']);
477
+ eq('rebase-merges: trunk rebased', [s.out.status, s.out.method], ['synced', 'rebase']);
478
+ eq('rebase-merges: trunk keeps its merge commit', git(w.work, 'rev-list', '--merges', '--count', 'origin/main..HEAD'), '1');
479
+ }
480
+
481
+ // --- remotes without an origin ----------------------------------------------------
482
+ {
483
+ const w = mkWorld();
484
+ git(w.work, 'remote', 'rename', 'origin', 'upstream');
485
+ const r = pre(w.work);
486
+ eq('no origin: status unchanged', r.out.status, 'current');
487
+ eq('no origin: measured on the local trunk', r.out.base, 'main');
488
+ ok('no origin: a warning names the remotes', r.out.warnings.some((x) => /no remote named origin/.test(x) && x.includes('upstream')));
489
+ const solo = mktmp();
490
+ git(solo, 'init', '-q', '-b', 'main');
491
+ identify(solo);
492
+ commitFile(solo, 'a.txt', 'a\n', 'init');
493
+ eq('no remotes at all: no warning', pre(solo).out.warnings, []);
494
+ }
495
+
496
+ // --- end to end -----------------------------------------------------------------
497
+ {
498
+ const w = mkWorld();
499
+ advanceOrigin(w, 4);
500
+ writeConfig(w.work, { preflight: { checks: ['true'] } });
501
+ const out = execFileSync('node', [scriptPath, '--repo', w.work, '--sync'], {
502
+ env: { ...process.env, CLAUDE_CONFIG_DIR: emptyHome, HOME: '/nonexistent-home' },
503
+ }).toString();
504
+ const parsed = JSON.parse(out.trim());
505
+ eq('e2e: synced', [parsed.status, parsed.pulled, parsed.method], ['synced', 4, 'ff']);
506
+ eq('e2e: one JSON line', out.trim().split('\n').length, 1);
507
+ let code = 0;
508
+ try { execFileSync('node', [scriptPath], { stdio: 'ignore' }); } catch (err) { code = err.status; }
509
+ eq('e2e: usage error exits 2', code, 2);
510
+ ok('e2e: helper is executable', (fs.statSync(scriptPath).mode & 0o111) !== 0);
511
+ }
512
+
513
+ console.log(`base-preflight.test.js: ${passed} checks passed`);
@@ -2,7 +2,7 @@
2
2
  // commit-message-check.js: the single source of truth for trailhead's commit
3
3
  // message rules, shared by two guardrails so they can never drift:
4
4
  // - trailhead-commit-guard.js (Claude Code PreToolUse(Bash) hook)
5
- // - templates/trailhead-commit-msg (host-independent git commit-msg hook,
5
+ // - templates/trailhead-commit-msg.cjs (host-independent git commit-msg hook logic,
6
6
  // installed into .git/hooks at repo first-use so it runs on ANY host)
7
7
  // Two rules: NEVER a Co-Authored-By trailer; a Conventional Commits subject
8
8
  // (<type>(scope)?: subject) of at most 72 chars. Plus a conditional third rule:
@@ -4,7 +4,8 @@
4
4
  //
5
5
  // Drives the SAME fixtures through two paths:
6
6
  // 1. checkCommitMessage() directly (the source of truth).
7
- // 2. the template hook (templates/trailhead-commit-msg) end-to-end via
7
+ // 2. the template hook (templates/trailhead-commit-msg sh wrapper, which runs
8
+ // the sibling trailhead-commit-msg.cjs logic) end-to-end via
8
9
  // execFileSync, feeding it the message as a temp file, run from a fresh
9
10
  // mkdtemp cwd (so the template's own .trailhead/session-ticket lookup is
10
11
  // independent of this repo's real marker).
@@ -60,7 +61,7 @@ function runTemplateHook(message, marker) {
60
61
  let allowed = true;
61
62
  let stderr = '';
62
63
  try {
63
- execFileSync('node', [templatePath, tmpFile], { cwd: tmp, stdio: 'pipe' });
64
+ execFileSync('sh', [templatePath, tmpFile], { cwd: tmp, stdio: 'pipe' });
64
65
  } catch (e) {
65
66
  allowed = false;
66
67
  stderr = e.stderr ? e.stderr.toString() : '';
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ // Regression for #257: the git commit-msg hook must work in a repo whose root
3
+ // package.json says "type": "module". The hook is installed by the sync script
4
+ // into a fixture repo and exercised through real `git commit` calls.
5
+ // Run: node commit-msg-module-repo.test.js
6
+ const assert = require('assert');
7
+ const { execFileSync, spawnSync } = require('child_process');
8
+ const path = require('path');
9
+ const fs = require('fs');
10
+ const os = require('os');
11
+
12
+ const syncScript = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-commit-msg-sync.js');
13
+
14
+ // Prefisso della riga che il wrapper stampa quando non può applicare le regole (#262).
15
+ const INACTIVE = 'trailhead: commit-msg hook inactive (';
16
+
17
+ let passed = 0;
18
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
19
+
20
+ function gitEnv(extra) {
21
+ return Object.assign({}, process.env, {
22
+ GIT_CONFIG_GLOBAL: '/dev/null',
23
+ GIT_CONFIG_NOSYSTEM: '1',
24
+ GIT_AUTHOR_NAME: 'Trailhead Test',
25
+ GIT_AUTHOR_EMAIL: 'trailhead-test@example.com',
26
+ GIT_COMMITTER_NAME: 'Trailhead Test',
27
+ GIT_COMMITTER_EMAIL: 'trailhead-test@example.com',
28
+ }, extra || {});
29
+ }
30
+
31
+ // Repo fixture: package.json "type": "module" alla radice, hook installato dallo
32
+ // script di sync, marker session-ticket = #1.
33
+ function makeRepo() {
34
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-commit-msg-esm-'));
35
+ execFileSync('git', ['init', '-q'], { cwd: dir, env: gitEnv() });
36
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ type: 'module' }));
37
+ const out = execFileSync(process.execPath, [syncScript, '--repo', dir], { env: gitEnv(), encoding: 'utf8' });
38
+ assert.strictEqual(JSON.parse(out).status, 'installed');
39
+ fs.mkdirSync(path.join(dir, '.trailhead'));
40
+ fs.writeFileSync(path.join(dir, '.trailhead', 'session-ticket'), '#1 x\n');
41
+ return { dir, hooksDir: path.join(dir, '.git', 'hooks') };
42
+ }
43
+
44
+ function commit(dir, message) {
45
+ const r = spawnSync('git', ['commit', '--allow-empty', '-q', '-m', message], { cwd: dir, env: gitEnv(), encoding: 'utf8' });
46
+ return { status: r.status, stderr: r.stderr || '' };
47
+ }
48
+
49
+ // (a) commit convenzionale con Refs: #1 passa
50
+ {
51
+ const { dir } = makeRepo();
52
+ const r = commit(dir, 'fix: thing\n\nRefs: #1');
53
+ ok('module repo: conventional commit with Refs passes', r.status === 0);
54
+ ok('module repo: clean commit stays silent', !r.stderr.includes('trailhead:'));
55
+ fs.rmSync(dir, { recursive: true, force: true });
56
+ }
57
+
58
+ // (b) senza trailer: rifiutato col messaggio di trailhead
59
+ {
60
+ const { dir } = makeRepo();
61
+ const r = commit(dir, 'fix: thing');
62
+ ok('module repo: missing Refs is rejected with exit 1', r.status === 1);
63
+ ok('module repo: rejection carries trailhead message', r.stderr.includes('trailhead:') && r.stderr.includes('Refs: #1'));
64
+ ok('module repo: no ESM load error leaks', !r.stderr.includes('require is not defined'));
65
+ ok('module repo: a violation carries no inactive notice', !r.stderr.includes(INACTIVE));
66
+ fs.rmSync(dir, { recursive: true, force: true });
67
+ }
68
+
69
+ // (c) errori di avvio: mai bloccare un commit, ma segnalarlo su stderr (#262)
70
+ {
71
+ const { dir, hooksDir } = makeRepo();
72
+ fs.rmSync(path.join(hooksDir, 'trailhead-commit-msg.cjs'));
73
+ const r = commit(dir, 'not conventional at all');
74
+ ok('module repo: missing .cjs lets the commit through', r.status === 0);
75
+ ok('module repo: missing .cjs prints the inactive notice',
76
+ r.stderr.includes(INACTIVE + 'trailhead-commit-msg.cjs missing or unreadable)'));
77
+ fs.rmSync(dir, { recursive: true, force: true });
78
+ }
79
+ // .cjs presente ma illeggibile: da root chmod non toglie la lettura, quindi si salta.
80
+ if (typeof process.getuid !== 'function' || process.getuid() !== 0) {
81
+ const { dir, hooksDir } = makeRepo();
82
+ fs.chmodSync(path.join(hooksDir, 'trailhead-commit-msg.cjs'), 0o000);
83
+ const r = commit(dir, 'not conventional at all');
84
+ ok('module repo: unreadable .cjs lets the commit through', r.status === 0);
85
+ ok('module repo: unreadable .cjs prints the inactive notice',
86
+ r.stderr.includes(INACTIVE + 'trailhead-commit-msg.cjs missing or unreadable)'));
87
+ fs.rmSync(dir, { recursive: true, force: true });
88
+ }
89
+ {
90
+ const { dir, hooksDir } = makeRepo();
91
+ fs.writeFileSync(path.join(hooksDir, 'trailhead-commit-msg.cjs'), "throw new Error('boom at load');\n");
92
+ const r = commit(dir, 'not conventional at all');
93
+ ok('module repo: .cjs crashing at load lets the commit through', r.status === 0);
94
+ ok('module repo: .cjs crashing at load prints the inactive notice',
95
+ r.stderr.includes(INACTIVE + 'trailhead-commit-msg.cjs failed to run, exit '));
96
+ fs.rmSync(dir, { recursive: true, force: true });
97
+ }
98
+ {
99
+ // node assente dal PATH: il wrapper deve uscire 0 anche con un messaggio che
100
+ // altrimenti sarebbe una violazione (controllo negativo con PATH normale).
101
+ const { dir, hooksDir } = makeRepo();
102
+ const msgFile = path.join(dir, 'MSG');
103
+ fs.writeFileSync(msgFile, 'not conventional at all\n');
104
+ const emptyBin = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-empty-bin-'));
105
+ const hook = path.join(hooksDir, 'commit-msg');
106
+ const withNode = spawnSync('/bin/sh', [hook, msgFile], { cwd: dir, env: gitEnv(), encoding: 'utf8' });
107
+ ok('wrapper control: violation exits 1 with node on PATH', withNode.status === 1);
108
+ const noNode = spawnSync('/bin/sh', [hook, msgFile], { cwd: dir, env: { PATH: emptyBin }, encoding: 'utf8' });
109
+ ok('wrapper: node absent from PATH exits 0', noNode.status === 0);
110
+ ok('wrapper: node absent from PATH prints the inactive notice',
111
+ (noNode.stderr || '').includes(INACTIVE + 'node not found on PATH)'));
112
+ fs.rmSync(emptyBin, { recursive: true, force: true });
113
+ fs.rmSync(dir, { recursive: true, force: true });
114
+ }
115
+
116
+ console.log(`✓ commit-msg module repo (#257): ${passed} assertions passed`);