@marcomigozzi/trailhead 0.17.0 → 0.18.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 (116) hide show
  1. package/README.it.md +28 -18
  2. package/README.md +28 -18
  3. package/bin/base-preflight-docs.test.js +4 -1
  4. package/bin/bulk-cross-reference-docs.test.js +36 -0
  5. package/bin/engine-route-docs.test.js +7 -0
  6. package/bin/facet-stamp-docs.test.js +79 -0
  7. package/bin/forced-mutation-docs.test.js +38 -0
  8. package/bin/guard-axis-docs.test.js +69 -0
  9. package/bin/inbox-needs-info-docs.test.js +68 -0
  10. package/bin/lib/codex-projection.js +8 -8
  11. package/bin/lib/codex-projection.test.js +19 -3
  12. package/bin/needs-you-docs.test.js +9 -2
  13. package/bin/options-in-chat-docs.test.js +65 -0
  14. package/bin/pick-stress-test-docs.test.js +1 -1
  15. package/bin/pipeline-review-docs.test.js +118 -0
  16. package/bin/plan-review-budget-docs.test.js +63 -0
  17. package/bin/repo-target-docs.test.js +41 -0
  18. package/bin/seam-coverage-docs.test.js +46 -0
  19. package/bin/standards-brief-docs.test.js +112 -0
  20. package/bin/test-once-docs.test.js +101 -0
  21. package/bin/test-run-docs.test.js +47 -0
  22. package/bin/trailhead.js +9 -4
  23. package/bin/trailhead.test.js +18 -6
  24. package/bin/yes-accepts-recommended-docs.test.js +54 -0
  25. package/package.json +1 -1
  26. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  27. package/plugins/trailhead/agents/trailhead-code-review.md +2 -1
  28. package/plugins/trailhead/agents/trailhead-grill.md +2 -2
  29. package/plugins/trailhead/agents/trailhead-plan.md +1 -1
  30. package/plugins/trailhead/agents/trailhead-verify.md +1 -0
  31. package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
  32. package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
  33. package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
  34. package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
  35. package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
  36. package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
  37. package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
  38. package/plugins/trailhead/hooks/lib/issue-body.test.js +81 -0
  39. package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
  40. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
  41. package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
  42. package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
  43. package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
  44. package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
  45. package/plugins/trailhead/hooks/mod/README.md +30 -14
  46. package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
  47. package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
  48. package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
  49. package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
  50. package/plugins/trailhead/hooks/mod/map-pane.tsx +144 -45
  51. package/plugins/trailhead/hooks/mod/repo-pin.ts +105 -4
  52. package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
  53. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  54. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
  55. package/plugins/trailhead/hooks/trailhead-check-update.js +3 -1
  56. package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
  57. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
  58. package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
  59. package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
  60. package/plugins/trailhead/skills/_shared/capture-core.md +5 -0
  61. package/plugins/trailhead/skills/_shared/configuration-reference.md +12 -0
  62. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  63. package/plugins/trailhead/skills/_shared/substrate-commands.md +11 -2
  64. package/plugins/trailhead/skills/_shared/substrate.md +3 -2
  65. package/plugins/trailhead/skills/_shared/teamwork.md +5 -3
  66. package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
  67. package/plugins/trailhead/skills/_shared/techniques/code-review.md +18 -5
  68. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +2 -2
  69. package/plugins/trailhead/skills/_shared/techniques/debug.md +1 -1
  70. package/plugins/trailhead/skills/_shared/techniques/grilling.md +3 -3
  71. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
  72. package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
  73. package/plugins/trailhead/skills/_shared/techniques/verify.md +2 -0
  74. package/plugins/trailhead/skills/_shared/techniques.md +3 -3
  75. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
  76. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  77. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +8 -3
  78. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +17 -2
  79. package/plugins/trailhead/skills/trailhead-work/SKILL.md +1 -0
  80. package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
  81. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
  82. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -7
  83. package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
  84. package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
  85. package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
  86. package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
  87. package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
  88. package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
  89. package/plugins/trailhead/templates/trailhead-capture.js +2 -1
  90. package/plugins/trailhead/templates/trailhead-checkout-find.js +3 -2
  91. package/plugins/trailhead/templates/trailhead-checkout-in.js +2 -1
  92. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
  93. package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
  94. package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
  95. package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
  96. package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
  97. package/plugins/trailhead/templates/trailhead-issue-body.js +92 -6
  98. package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
  99. package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
  100. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
  101. package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
  102. package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
  103. package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
  104. package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
  105. package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
  106. package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
  107. package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
  108. package/plugins/trailhead/tests/git-env.test.ts +20 -0
  109. package/plugins/trailhead/tests/map-pane-ui.test.ts +268 -17
  110. package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
  111. package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
  112. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
  113. package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
  114. package/plugins/trailhead/tests/unscrub.ts +8 -0
  115. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
  116. package/plugins/trailhead/tests/work-state-worktree.test.ts +2 -1
@@ -798,5 +798,67 @@ function cli(args) {
798
798
  ok('attribute merge replacing a tracked line: merge is not a producer', !shas(c).includes(m));
799
799
  }
800
800
 
801
+ // --- facets: freshness of the codebase issue's facets -------------------------
802
+ {
803
+ const facetsRun = (dir, body) => {
804
+ const file = path.join(dir, '.git', 'facets-body.md');
805
+ fs.writeFileSync(file, body);
806
+ let text = '';
807
+ const code = walk.main(['facets', '--repo', dir, '--body-file', file], { stdout: { write: (t) => { text += t; } } });
808
+ return { code, out: JSON.parse(text) };
809
+ };
810
+ const facet = (name, sha, ...cited) => `## ${name}\n\n${sha ? `_as of ${sha}_\n\n` : ''}${cited.map((c) => `- \`${c}\` is here`).join('\n')}\n\n`;
811
+ const byName = (r, name) => r.out.facets.find((f) => f.name === name);
812
+
813
+ const dir = mkrepo();
814
+ const c1 = commit(dir, {
815
+ 'src/a.js': 'a\n', 'src/b.js': 'b\n', 'lib/old.js': 'o\n', 'lib/mv.js': 'm\n',
816
+ 'docs/x.md': 'x\n', 'star/[ab].txt': 's\n', 'star/a.txt': 'plain\n',
817
+ }, 'feat: base');
818
+ const s1 = c1.slice(0, 7);
819
+ const body = '# Codebase\n\n' + [
820
+ facet('Fresh', s1, 'docs/x.md'),
821
+ facet('Edited', s1, 'src/a.js:3-9', 'docs/x.md'),
822
+ facet('Gone', s1, 'lib/old.js'),
823
+ facet('Moved', s1, 'lib/mv.js'),
824
+ facet('Unstamped', null, 'src/b.js'),
825
+ facet('Rewritten', 'deadbeef', 'src/b.js'),
826
+ facet('Nothing', s1, 'no/such.js', '/etc/passwd', '../x', '-rf', ':x', 'a b', 'plain words'),
827
+ facet('Glob', s1, 'star/[ab].txt'),
828
+ facet('Dir', s1, 'src/'),
829
+ ].join('') + '## Fenced\n\n```\n## Not a facet\n```\n';
830
+ commit(dir, { 'src/a.js': 'a2\n', 'lib/old.js': null, 'star/[ab].txt': 's2\n' }, 'fix: edits');
831
+ git(dir, ['mv', 'lib/mv.js', 'lib/moved.js']);
832
+ const c2 = finishCommit(dir, ['commit', '-q'], 'refactor: rename', {});
833
+ const r = facetsRun(dir, body);
834
+ eq('facets: exit 0 and head', [r.code, r.out.head], [0, c2]);
835
+ eq('facets: fresh facet is current', [byName(r, 'Fresh').status, byName(r, 'Fresh').changed], ['current', []]);
836
+ eq('facets: edited cited file (with :line suffix) is stale', [byName(r, 'Edited').status, byName(r, 'Edited').changed, byName(r, 'Edited').paths], ['stale', ['src/a.js'], ['src/a.js', 'docs/x.md']]);
837
+ eq('facets: deleted cited file is stale', [byName(r, 'Gone').status, byName(r, 'Gone').changed], ['stale', ['lib/old.js']]);
838
+ eq('facets: renamed cited file is stale', byName(r, 'Moved').status, 'stale');
839
+ eq('facets: no stamp is unstamped', [byName(r, 'Unstamped').status, byName(r, 'Unstamped').sha], ['unstamped', null]);
840
+ eq('facets: unknown stamp is unresolved', byName(r, 'Rewritten').status, 'unresolved');
841
+ eq('facets: unsafe and missing tokens are ignored, nothing resolved is no-paths', [byName(r, 'Nothing').status, byName(r, 'Nothing').paths], ['no-paths', []]);
842
+ eq('facets: glob characters match literally and a real change reads stale', [byName(r, 'Glob').status, byName(r, 'Glob').changed], ['stale', ['star/[ab].txt']]);
843
+ eq('facets: trailing slash is stripped', [byName(r, 'Dir').status, byName(r, 'Dir').paths], ['stale', ['src']]);
844
+ ok('facets: a fenced ## is not a facet', !r.out.facets.some((f) => f.name === 'Not a facet'));
845
+ eq('facets: fenced section is listed as unstamped', byName(r, 'Fenced').status, 'unstamped');
846
+
847
+ // A stamp that is a real commit but not an ancestor of HEAD is unresolved.
848
+ git(dir, ['checkout', '-q', '-b', 'side', c1]);
849
+ const side = commit(dir, { 'side.txt': 's\n' }, 'feat: side');
850
+ git(dir, ['checkout', '-q', 'main']);
851
+ const o = facetsRun(dir, facet('Side', side.slice(0, 7), 'src/b.js'));
852
+ eq('facets: a stamp off HEAD history is unresolved', byName(o, 'Side').status, 'unresolved');
853
+
854
+ // Usage errors exit 2 with {"error":...}.
855
+ let text = '';
856
+ const bad = walk.main(['facets', '--repo', dir], { stdout: { write: (t) => { text += t; } } });
857
+ ok('facets: --body-file is required', bad === 2 && JSON.parse(text).error);
858
+ text = '';
859
+ const missing = walk.main(['facets', '--repo', dir, '--body-file', '/nonexistent/body.md'], { stdout: { write: (t) => { text += t; } } });
860
+ ok('facets: unreadable body file is a fatal error', missing === 2 && JSON.parse(text).error);
861
+ }
862
+
801
863
  cleanup();
802
864
  console.log(`✓ audit-walk: ${passed} assertions passed`);
@@ -0,0 +1,55 @@
1
+ // Mod side of #349: git spawns go through `env -u <repo-location vars> git ...`
2
+ // (the mod's process.run cannot delete env), and the name list matches the template's.
3
+ // The mod file is TypeScript: loaded through Node's type stripping (Node >= 22.18);
4
+ // skipped on an older Node, where the source checks below still run.
5
+ const assert = require('assert');
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const { GIT_LOCATION_VARS } = require('../../templates/trailhead-git-env.js');
9
+
10
+ const modDir = path.join(__dirname, '..', 'mod');
11
+ let passed = 0;
12
+ const t = (name, fn) => { fn(); passed++; console.log('ok -', name); };
13
+
14
+ let mod = null;
15
+ try { mod = require(path.join(modDir, 'git-env.ts')); } catch { /* no type stripping */ }
16
+
17
+ if (mod) {
18
+ t('the mod name list equals the template list', () => {
19
+ assert.deepStrictEqual([...mod.GIT_LOCATION_VARS], [...GIT_LOCATION_VARS]);
20
+ });
21
+ t('gitArgv starts with the env -u wrapper and ends with git args', () => {
22
+ const argv = mod.gitArgv('-C', '/r', 'status');
23
+ const gitAt = argv.indexOf('git');
24
+ assert.strictEqual(argv[0], 'env');
25
+ assert.deepStrictEqual(argv.slice(gitAt), ['git', '-C', '/r', 'status']);
26
+ const pairs = argv.slice(1, gitAt);
27
+ assert.deepStrictEqual(pairs.filter((_, i) => i % 2 === 0), GIT_LOCATION_VARS.map(() => '-u'));
28
+ assert.deepStrictEqual(pairs.filter((_, i) => i % 2 === 1), [...GIT_LOCATION_VARS]);
29
+ });
30
+ t('scrubGitEnv wraps a gh argv the same way', () => {
31
+ const argv = mod.scrubGitEnv(['gh', 'api', 'graphql']);
32
+ assert.strictEqual(argv[0], 'env');
33
+ assert.deepStrictEqual(argv.slice(-3), ['gh', 'api', 'graphql']);
34
+ });
35
+ t('the wrapper really drops GIT_DIR (env -u, run for real)', () => {
36
+ const { spawnSync } = require('child_process');
37
+ const argv = mod.scrubGitEnv([process.execPath, '-p', "'GIT_DIR' in process.env"]);
38
+ const r = spawnSync(argv[0], argv.slice(1), { encoding: 'utf8', env: { ...process.env, GIT_DIR: '/x' } });
39
+ assert.strictEqual(r.stdout.trim(), 'false');
40
+ });
41
+ }
42
+
43
+ t('no mod file spawns git without the wrapper', () => {
44
+ for (const f of fs.readdirSync(modDir).filter((n) => /\.tsx?$/.test(n) && n !== 'git-env.ts')) {
45
+ const src = fs.readFileSync(path.join(modDir, f), 'utf8');
46
+ assert.ok(!/\[\s*'git'/.test(src), `${f} builds a raw git argv; use gitArgv()`);
47
+ }
48
+ });
49
+
50
+ t('map-pane gh calls are wrapped', () => {
51
+ const src = fs.readFileSync(path.join(modDir, 'map-pane.tsx'), 'utf8');
52
+ assert.ok(src.includes("scrubGitEnv(['gh', ...args])"));
53
+ });
54
+
55
+ console.log(`${passed} passed${mod ? '' : ' (type stripping unavailable: wrapper checks skipped)'}`);
@@ -0,0 +1,73 @@
1
+ // Regression (#349): an inherited GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE must not
2
+ // redirect the plugin's git spawns away from the checkout they are pointed at.
3
+ const assert = require('assert');
4
+ const { spawnSync } = require('child_process');
5
+ const path = require('path');
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+
9
+ const T = path.resolve(__dirname, '..', '..', 'templates');
10
+ let passed = 0;
11
+ const eq = (name, a, b) => { assert.deepStrictEqual(a, b, name); passed++; console.log('ok -', name); };
12
+
13
+ const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'git-env-spawn-')));
14
+ const cleanEnv = () => { const e = { ...process.env }; for (const k of Object.keys(e)) if (/^GIT_(DIR|WORK_TREE|INDEX_FILE|COMMON_DIR)$/.test(k)) delete e[k]; return e; };
15
+ const git = (cwd, ...args) => {
16
+ const r = spawnSync('git', ['-C', cwd, '-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { encoding: 'utf8', env: cleanEnv() });
17
+ assert.strictEqual(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
18
+ return r.stdout.trim();
19
+ };
20
+ function makeRepo(name) {
21
+ const dir = path.join(tmp, name);
22
+ fs.mkdirSync(dir);
23
+ git(dir, 'init', '-q', '-b', 'main');
24
+ git(dir, 'commit', '-q', '--allow-empty', '-m', 'init');
25
+ return dir;
26
+ }
27
+ const a = makeRepo('a');
28
+ const b = makeRepo('b');
29
+ git(b, 'checkout', '-q', '-b', 'trailhead/t5');
30
+ git(b, 'commit', '-q', '--allow-empty', '-m', 'feat: work\n\nRefs: #5');
31
+ git(b, 'checkout', '-q', 'main');
32
+ const aHead = git(a, 'rev-parse', 'HEAD');
33
+ const bMain = git(b, 'rev-parse', 'main');
34
+ const bBranch = git(b, 'rev-parse', 'trailhead/t5');
35
+
36
+ // The hostile parent env: points every git call at a.
37
+ const hostile = { ...process.env, GIT_DIR: path.join(a, '.git'), GIT_WORK_TREE: a, GIT_INDEX_FILE: path.join(a, '.git', 'index') };
38
+ const node = (script, args, env) => spawnSync(process.execPath, [path.join(T, script), ...args], { encoding: 'utf8', env });
39
+
40
+ // checkout-in: a plain dir is no repo; with GIT_DIR set it must still be invalid.
41
+ const plain = path.join(tmp, 'plain');
42
+ fs.mkdirSync(plain);
43
+ const ci = node('trailhead-checkout-in.js', ['--in', plain, '--cwd', plain], hostile);
44
+ eq('checkout-in does not resolve a plain dir through GIT_DIR', JSON.parse(ci.stdout.trim()).status, 'invalid');
45
+
46
+ // auto-merge: merges into b, leaves a alone.
47
+ const am = node('trailhead-auto-merge.js', ['merge', '--repo', b, '--ticket', '5'], hostile);
48
+ const out = JSON.parse(am.stdout.trim());
49
+ eq('auto-merge merges into the target repo', [out.status, git(b, 'rev-parse', 'main')], ['merged', bBranch]);
50
+ eq('auto-merge leaves the GIT_DIR repo alone', git(a, 'rev-parse', 'HEAD'), aHead);
51
+ assert.notStrictEqual(bMain, bBranch);
52
+
53
+ // issue-body: the gh spawn (which asks git for the repo) must not see GIT_DIR.
54
+ const bin = path.join(tmp, 'bin');
55
+ fs.mkdirSync(bin);
56
+ fs.writeFileSync(path.join(bin, 'gh'), '#!/bin/sh\nprintf "dir=%s wt=%s idx=%s" "${GIT_DIR-unset}" "${GIT_WORK_TREE-unset}" "${GIT_INDEX_FILE-unset}"\n', { mode: 0o755 });
57
+ const probe = `process.stdout.write(require(${JSON.stringify(path.join(T, 'trailhead-issue-body.js'))}).defaultGh(['x']).stdout)`;
58
+ const ib = spawnSync(process.execPath, ['-e', probe], { encoding: 'utf8', env: { ...hostile, PATH: `${bin}${path.delimiter}${process.env.PATH}` } });
59
+ eq('issue-body gh spawn gets no repo-location vars', ib.stdout, 'dir=unset wt=unset idx=unset');
60
+
61
+ // The other gh-spawning templates share the same one-line scrub.
62
+ for (const f of ['unblock', 'uat-backfill', 'merge-ticket', 'needs-you', 'uat-followup', 'auto-queue', 'capture']) {
63
+ const src = fs.readFileSync(path.join(T, `trailhead-${f}.js`), 'utf8');
64
+ eq(`${f} gh spawn is scrubbed`, /spawnSync\('gh', args, \{[^}]*env: gitEnv\(\)/.test(src), true);
65
+ }
66
+
67
+ // The statusline keeps its own copy of the names: it must equal the template list.
68
+ const sl = fs.readFileSync(path.join(T, 'statusline-trailhead.sh'), 'utf8');
69
+ const unsetLine = sl.match(/^unset ((?:GIT_\w+[ \\\n]+)+)/m)[1].split(/[\s\\]+/).filter(Boolean);
70
+ eq('statusline unset list equals the template list', [...unsetLine].sort(), [...require(path.join(T, 'trailhead-git-env.js')).GIT_LOCATION_VARS].sort());
71
+
72
+ fs.rmSync(tmp, { recursive: true, force: true });
73
+ console.log(`${passed} passed`);
@@ -0,0 +1,51 @@
1
+ // Tests of templates/trailhead-git-env.js (and its hooks/lib copy).
2
+ const assert = require('assert');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+ const { GIT_LOCATION_VARS, gitEnv } = require('../../templates/trailhead-git-env.js');
7
+
8
+ let n = 0;
9
+ const t = (name, fn) => { fn(); n++; console.log('ok -', name); };
10
+
11
+ t('removes every repo-location name', () => {
12
+ const base = { PATH: '/bin' };
13
+ for (const k of GIT_LOCATION_VARS) base[k] = 'x';
14
+ assert.deepStrictEqual(gitEnv(base), { PATH: '/bin' });
15
+ });
16
+
17
+ t('keeps GIT_CONFIG* and unrelated names', () => {
18
+ const base = { GIT_DIR: 'x', GIT_CONFIG: 'a', GIT_CONFIG_PARAMETERS: 'b', GIT_CONFIG_COUNT: '1', HOME: '/h' };
19
+ assert.deepStrictEqual(gitEnv(base), { GIT_CONFIG: 'a', GIT_CONFIG_PARAMETERS: 'b', GIT_CONFIG_COUNT: '1', HOME: '/h' });
20
+ });
21
+
22
+ t('extra is applied on top and may re-add a name', () => {
23
+ assert.deepStrictEqual(gitEnv({ GIT_DIR: 'x', A: '1' }, { A: '2', GIT_EDITOR: 'true' }), { A: '2', GIT_EDITOR: 'true' });
24
+ });
25
+
26
+ t('does not mutate its input or process.env', () => {
27
+ const base = Object.freeze({ GIT_DIR: 'x' });
28
+ gitEnv(base);
29
+ assert.strictEqual(base.GIT_DIR, 'x');
30
+ process.env.GIT_DIR = '/nonexistent-for-test';
31
+ try {
32
+ assert.ok(!('GIT_DIR' in gitEnv()));
33
+ assert.strictEqual(process.env.GIT_DIR, '/nonexistent-for-test');
34
+ } finally { delete process.env.GIT_DIR; }
35
+ });
36
+
37
+ t('the name list is git\'s local env vars minus GIT_CONFIG*', () => {
38
+ const r = spawnSync('git', ['rev-parse', '--local-env-vars'], { encoding: 'utf8' });
39
+ if (r.status !== 0) return;
40
+ const expected = r.stdout.split('\n').filter((s) => s && !s.startsWith('GIT_CONFIG')).sort();
41
+ const missing = expected.filter((k) => !GIT_LOCATION_VARS.includes(k));
42
+ assert.deepStrictEqual(missing, [], 'git knows local env vars the list lacks');
43
+ });
44
+
45
+ t('hooks/lib copy is byte-identical to the template', () => {
46
+ const a = fs.readFileSync(path.join(__dirname, '..', '..', 'templates', 'trailhead-git-env.js'));
47
+ const b = fs.readFileSync(path.join(__dirname, 'trailhead-git-env.js'));
48
+ assert.ok(a.equals(b));
49
+ });
50
+
51
+ console.log(`${n} passed`);
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ // La config globale su Codex sta in <codexHome>, non in ~/.claude: ogni lettore, copiato dove l'installer
3
+ // Codex lo mette, deve trovarla lì. Run: node global-config-dir.test.js
4
+ const assert = require('assert');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ const pluginDir = path.resolve(__dirname, '..', '..');
10
+ const READERS = [
11
+ ['templates', 'trailhead-base-preflight.js'],
12
+ ['templates', 'trailhead-inbox-needs-info.js'],
13
+ ['templates', 'trailhead-isolation-copy.js'],
14
+ ['templates', 'trailhead-plan-review-rounds.js'],
15
+ ['templates', 'trailhead-test-run.js'],
16
+ ['templates', 'trailhead-uat-setup.js'],
17
+ ['hooks', 'trailhead-test-guard.js'],
18
+ ];
19
+
20
+ const tmp = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'trailhead-global-config-'));
21
+ const codexHome = path.join(tmp, 'codex');
22
+ const skillDir = path.join(codexHome, 'skills', 'trailhead');
23
+ const ENV = { HOME: path.join(tmp, 'home'), CLAUDE_CONFIG_DIR: path.join(tmp, 'claude') };
24
+
25
+ // Il percorso del file di config che il lettore userebbe, con l'env dato.
26
+ const configFileOf = (mod, env) =>
27
+ (mod.configDir ? path.join(mod.configDir(env), 'trailhead', 'config.json') : mod.globalConfigPath(env));
28
+
29
+ let passed = 0;
30
+ try {
31
+ for (const dir of ['templates', 'hooks']) {
32
+ fs.cpSync(path.join(pluginDir, dir), path.join(skillDir, dir), { recursive: true });
33
+ }
34
+ const expected = path.join(codexHome, 'trailhead', 'config.json');
35
+ for (const [dir, file] of READERS) {
36
+ const onCodex = require(path.join(skillDir, dir, file));
37
+ assert.strictEqual(configFileOf(onCodex, ENV), expected, `${file} on Codex`);
38
+ passed += 1;
39
+
40
+ const onClaude = require(path.join(pluginDir, dir, file));
41
+ assert.strictEqual(configFileOf(onClaude, ENV), path.join(ENV.CLAUDE_CONFIG_DIR, 'trailhead', 'config.json'), `${file} with CLAUDE_CONFIG_DIR`);
42
+ assert.strictEqual(configFileOf(onClaude, { HOME: ENV.HOME }), path.join(ENV.HOME, '.claude', 'trailhead', 'config.json'), `${file} default`);
43
+ passed += 2;
44
+ }
45
+ } finally {
46
+ fs.rmSync(tmp, { recursive: true, force: true });
47
+ }
48
+ console.log(`global-config-dir.test.js: ${passed} checks passed`);
@@ -0,0 +1,85 @@
1
+ // Mod side of #369: the mod strips heredoc bodies before it tests a Bash call for a tracker write,
2
+ // with its own import-free leaf (a mod file imports nothing from hooks/lib). This keeps the leaf's
3
+ // output equal to shell-scan's stripHeredocBodies over the commands that matter to the write check.
4
+ // The mod file is TypeScript: loaded through Node's type stripping (Node >= 22.18);
5
+ // skipped (with an explicit SKIP line) on a Node with no type stripping; any other load error fails.
6
+ const assert = require('assert');
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { stripHeredocBodies } = require('./shell-scan.js');
10
+
11
+ let passed = 0;
12
+ const t = (name, fn) => { fn(); passed++; console.log('ok -', name); };
13
+
14
+ const leaf = path.join(__dirname, '..', 'mod', 'heredoc.ts');
15
+ assert.ok(fs.existsSync(leaf), 'mod/heredoc.ts is missing');
16
+ let mod = null;
17
+ try {
18
+ mod = require(leaf);
19
+ } catch (err) {
20
+ // Only a Node with no type stripping may skip; a syntax error in the leaf must fail the test.
21
+ const noStripping = err.code === 'ERR_UNKNOWN_FILE_EXTENSION' || !process.features.typescript;
22
+ if (!noStripping) throw err;
23
+ console.log(`SKIP - mod/heredoc.ts not loadable without type stripping (${err.code || err.name})`);
24
+ }
25
+
26
+ const COMMANDS = [
27
+ 'cat >f <<EOF\ngh issue close 5\nEOF',
28
+ "cat >f <<'EOF'\ngh issue close 5\nEOF",
29
+ 'cat >f <<"EOF"\ngh issue close 5\nEOF',
30
+ 'cat >f <<-EOF\n\tgh issue close 5\n\tEOF',
31
+ 'cat <<A >a; cat <<B >b\ngh issue close 1\nA\ngh issue edit 2\nB',
32
+ 'cat <<A >a; cat <<B >b\nx\nA\ny\nB\ngh issue close 3',
33
+ 'gh issue create --body-file - <<EOF\nbody\nEOF',
34
+ 'gh issue comment 5 -F - <<EOF\ngh issue close 9\nEOF',
35
+ "cat <<'EOF' >x\nb\nEOF\ngh issue close 5",
36
+ "cat <<'EOF' >x\nEOF2\ngh issue close 5\n EOF\nEOF",
37
+ "cat <<'EOF' >x\ngh issue close 5",
38
+ 'cat <<-EOF >x\nb\n EOF\ngh issue close 5\nEOF',
39
+ "cat <<< 'x'\ngh issue close 5",
40
+ 'echo $((1<<2))\ngh issue close 5',
41
+ 'echo $((1<<2)); gh issue view 5',
42
+ "cat <<'EOF' >x\nnode trailhead-issue-body.js append\nEOF",
43
+ 'echo "a <<EOF"\ngh issue close 5',
44
+ 'gh issue close 5',
45
+ // W1: a `<<` in a comment opens nothing
46
+ '# build body with <<EOF\ngh issue close 5',
47
+ 'git commit -m "x" # see <<EOF\ngh issue close 5',
48
+ 'echo a;# c <<EOF\ngh issue close 5',
49
+ 'echo a&&(# c <<EOF\ngh issue close 5',
50
+ // a `#` inside a word is no comment
51
+ 'cat a#b <<EOF\ngh issue close 5\nEOF',
52
+ 'cat a#b <<EOF\ngh issue close 5',
53
+ // W2: an opener inside a multi-line quoted string opens nothing
54
+ 'echo "a\ncat <<EOF\nb"\ngh issue close 5\nEOF',
55
+ "echo 'a\ncat <<EOF\nb'\ngh issue close 5",
56
+ 'echo "a\n# x <<EOF\nb" <<EOF\ngh issue close 5\nEOF\ngh issue edit 2',
57
+ 'echo "unbalanced\ncat <<EOF\ngh issue close 5\nEOF',
58
+ // a `<<` inside a parameter expansion opens nothing
59
+ 'echo ${x:-<<EOF}\ngh issue close 5\nEOF',
60
+ 'echo ${x:-<<EOF\ngh issue close 5',
61
+ // a body is not scanned for quotes: its apostrophe leaks nowhere
62
+ "cat <<EOF\nit's\nEOF\ncat <<B\ngh issue close 5\nB",
63
+ "cat <<EOF\nit's\nEOF\ngh issue close 5",
64
+ ];
65
+
66
+ // Known difference (I1): shell-scan sees an opener inside `$(..)` that the leaf does not, so the
67
+ // leaf keeps those lines (an extra read at worst). Asserted, not emulated.
68
+ const KNOWN_DIFFS = [
69
+ 'echo "$(cat <<EOF\nhello\nEOF\n)"',
70
+ ];
71
+
72
+ if (mod) {
73
+ t('the mod leaf strips like shell-scan over the write-check commands', () => {
74
+ for (const cmd of COMMANDS) {
75
+ assert.strictEqual(mod.stripHeredocBodies(cmd), stripHeredocBodies(cmd), JSON.stringify(cmd));
76
+ }
77
+ });
78
+ t('the known $(..) difference keep the lines (safe direction)', () => {
79
+ for (const cmd of KNOWN_DIFFS) {
80
+ assert.strictEqual(mod.stripHeredocBodies(cmd), cmd, JSON.stringify(cmd));
81
+ }
82
+ });
83
+ }
84
+
85
+ console.log(`${passed} passed${mod ? '' : ' (type stripping unavailable: leaf checks skipped)'}`);
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/trailhead-inbox-needs-info.js. Run: node inbox-needs-info.test.js
3
+ // No framework: plain asserts, same style as uat-backfill.test.js.
4
+ //
5
+ // Layers covered:
6
+ // 1. main() against a fake tracker behind an injected `gh` runner: the
7
+ // classification, the reporter match, the days config, failed reads.
8
+ // 2. End-to-end: the script itself with a fake `gh` on PATH (multi-page read,
9
+ // failing read).
10
+ const assert = require('assert');
11
+ const { spawnSync } = require('child_process');
12
+ const path = require('path');
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+
16
+ const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-inbox-needs-info.js');
17
+ const { main } = require(scriptPath);
18
+
19
+ let passed = 0;
20
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
21
+
22
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'inbox-needs-info-'));
23
+ const NOW = new Date('2026-06-30T12:00:00Z');
24
+ const LABELED = '2026-06-10T12:00:00Z'; // 20 days before NOW
25
+
26
+ // issues: [{number,title,author,labeledAt?,labelEvents?,comments?,failEvents?,failComments?}]
27
+ function makeTracker(init = {}) {
28
+ const t = { issues: init.issues || [], failList: !!init.failList, calls: [] };
29
+ const res = (rows, status = 0) => ({ status, stdout: rows.map((r) => JSON.stringify(r)).join('\n'), stderr: status ? 'forced failure' : '' });
30
+ t.gh = (args) => {
31
+ t.calls.push(args.join(' '));
32
+ if (args[0] !== 'api') return res([], 1);
33
+ const url = args[2];
34
+ if (url.includes('/issues?labels=trailhead:needs-info&state=open&per_page=100')) {
35
+ if (t.failList) return res([], 1);
36
+ return res(t.issues.map((i) => ({ number: i.number, title: i.title || `T${i.number}`, author: i.author === undefined ? 'reporter' : i.author })));
37
+ }
38
+ const m = /\/issues\/(\d+)\/(events|comments)/.exec(url);
39
+ if (!m) return res([], 1);
40
+ const i = t.issues.find((x) => x.number === Number(m[1]));
41
+ if (m[2] === 'events') {
42
+ if (i.failEvents) return res([], 1);
43
+ return res(i.labelEvents || [{ at: i.labeledAt || LABELED }]);
44
+ }
45
+ if (i.failComments) return res([], 1);
46
+ return res(i.comments || []);
47
+ };
48
+ return t;
49
+ }
50
+
51
+ function run(t, { root, env, argv } = {}) {
52
+ let out = '';
53
+ const code = main(argv || ['--repo', 'o/r', '--config-root', root || tmp], {
54
+ gh: t.gh, now: () => NOW, env: env || { CLAUDE_CONFIG_DIR: path.join(tmp, 'noglobal') }, stdout: { write: (s) => { out += s; } }, stderr: { write: () => {} },
55
+ });
56
+ let json = null;
57
+ try { json = JSON.parse(out.trim()); } catch { /* not json */ }
58
+ return { code, out, json };
59
+ }
60
+
61
+ const nums = (a) => a.map((x) => x.number);
62
+
63
+ // --- classification ----------------------------------------------------------
64
+ {
65
+ const t = makeTracker({ issues: [
66
+ { number: 1, comments: [{ author: 'reporter', at: '2026-06-05T00:00:00Z' }] }, // before the label
67
+ { number: 2, comments: [{ author: 'someone', at: '2026-06-11T00:00:00Z' }] }, // other user after
68
+ { number: 3, comments: [{ author: 'reporter', at: '2026-06-11T00:00:00Z' }] }, // reporter after
69
+ { number: 4, author: 'Reporter', comments: [{ author: 'reporter', at: '2026-06-12T00:00:00Z' }] }, // case-different login
70
+ { number: 5, comments: [{ author: 'reporter', at: LABELED }] }, // same-second tie
71
+ { number: 6, author: null, comments: [] }, // deleted reporter
72
+ { number: 7, author: 'ghost', comments: [{ author: 'ghost', at: '2026-06-12T00:00:00Z' }] },
73
+ { number: 8, labeledAt: '2026-06-25T12:00:00Z' }, // 5 days: still waiting
74
+ { number: 9, labeledAt: '2026-06-16T12:00:00Z' }, // exactly 14 days: timed out
75
+ { number: 10, labeledAt: '2026-06-16T12:00:01Z' }, // 13 whole days: waiting
76
+ { number: 11, labelEvents: [] }, // no label event
77
+ { number: 12, failEvents: true },
78
+ { number: 13, failComments: true },
79
+ { number: 14, labelEvents: [{ at: '2026-05-01T00:00:00Z' }, { at: '2026-06-25T12:00:00Z' }], comments: [{ author: 'reporter', at: '2026-06-01T00:00:00Z' }] }, // latest label wins
80
+ ] });
81
+ const r = run(t);
82
+ ok('exit 0, one JSON line', r.code === 0 && r.json.status === 'ok' && r.out.trim().split('\n').length === 1);
83
+ ok('answered: reporter after the label, case-insensitive', JSON.stringify(nums(r.json.answered)) === '[3,4]');
84
+ ok('answered carries answeredAt and author', r.json.answered[0].answeredAt === '2026-06-11T00:00:00Z' && r.json.answered[0].author === 'reporter');
85
+ ok('timedOut: before-label comment, other user, tie, 14 whole days', JSON.stringify(nums(r.json.timedOut)) === '[1,2,5,9]');
86
+ ok('timedOut carries ageDays', r.json.timedOut.find((x) => x.number === 9).ageDays === 14);
87
+ ok('waiting stays out of answered and timedOut', JSON.stringify(nums(r.json.waiting)) === '[8,10,14]');
88
+ ok('unknown: deleted reporter, ghost, no event, failed reads', JSON.stringify(nums(r.json.unknown)) === '[6,7,11,12,13]' && r.json.unknown.every((u) => u.reason));
89
+ ok('the default is 14 days', r.json.days === 14 && r.json.warnings.length === 0);
90
+ ok('read-only: only api reads, pull requests filtered by jq', t.calls.every((c) => c.startsWith('api ')) && t.calls[0].includes('select(.pull_request == null)'));
91
+ ok('comments are read since the label', t.calls.some((c) => c.includes('/issues/3/comments?since=2026-06-10T12:00:00Z&per_page=100')));
92
+ }
93
+
94
+ // --- days config -------------------------------------------------------------
95
+ function configRoot(cfg) {
96
+ const root = fs.mkdtempSync(path.join(tmp, 'proj-'));
97
+ fs.mkdirSync(path.join(root, '.trailhead'));
98
+ if (cfg !== undefined) fs.writeFileSync(path.join(root, '.trailhead', 'config.json'), typeof cfg === 'string' ? cfg : JSON.stringify(cfg));
99
+ return root;
100
+ }
101
+ function globalEnv(cfg) {
102
+ const dir = fs.mkdtempSync(path.join(tmp, 'glob-'));
103
+ fs.mkdirSync(path.join(dir, 'trailhead'));
104
+ fs.writeFileSync(path.join(dir, 'trailhead', 'config.json'), typeof cfg === 'string' ? cfg : JSON.stringify(cfg));
105
+ return { CLAUDE_CONFIG_DIR: dir };
106
+ }
107
+ const one = () => makeTracker({ issues: [{ number: 1, labeledAt: '2026-06-25T12:00:00Z' }] }); // 5 days old
108
+ {
109
+ const r = run(one(), { root: configRoot({ 'inbox.needs_info_days': 3 }) });
110
+ ok('project override: flat key', r.json.days === 3 && r.json.timedOut.length === 1);
111
+ const n = run(one(), { root: configRoot({ inbox: { needs_info_days: 7 } }) });
112
+ ok('project override: nested key', n.json.days === 7 && n.json.waiting.length === 1);
113
+ const b = run(one(), { root: configRoot({ 'inbox.needs_info_days': 3, inbox: { needs_info_days: 9 } }) });
114
+ ok('flat key wins over nested in one file', b.json.days === 3);
115
+ const g = run(one(), { root: configRoot({}), env: globalEnv({ 'inbox.needs_info_days': 4 }) });
116
+ ok('global config applies when the project sets nothing', g.json.days === 4);
117
+ const p = run(one(), { root: configRoot({ 'inbox.needs_info_days': 2 }), env: globalEnv({ 'inbox.needs_info_days': 4 }) });
118
+ ok('project wins over global', p.json.days === 2);
119
+ }
120
+ for (const bad of [0, -1, 1.5, '7', null]) {
121
+ const r = run(one(), { root: configRoot({ 'inbox.needs_info_days': bad }), env: globalEnv({ 'inbox.needs_info_days': 6 }) });
122
+ ok(`invalid value ${JSON.stringify(bad)} warns and falls to the global layer`, r.json.days === 6 && r.json.warnings.length === 1);
123
+ }
124
+ {
125
+ const r = run(one(), { root: configRoot('{nope') });
126
+ ok('invalid JSON warns and falls to the default', r.json.days === 14 && r.json.warnings.length === 1);
127
+ const d = run(one(), { root: path.join(tmp, 'missing-root') });
128
+ ok('a missing project config is silent', d.json.days === 14 && d.json.warnings.length === 0);
129
+ }
130
+
131
+ // --- failures and usage ------------------------------------------------------
132
+ {
133
+ const r = run(makeTracker({ issues: [{ number: 1 }], failList: true }));
134
+ ok('a failed list read is status error, exit 1', r.code === 1 && r.json.status === 'error');
135
+ }
136
+ for (const argv of [['--bogus'], ['--repo'], ['--repo', 'nope', '--config-root', 'x'], ['--repo', 'o/r'], ['--config-root', 'x']]) {
137
+ const t = makeTracker();
138
+ const r = run(t, { argv });
139
+ ok(`usage error exit 2: ${argv.join(' ')}`, r.code === 2 && t.calls.length === 0);
140
+ }
141
+
142
+ // --- end-to-end with a fake gh on PATH ---------------------------------------
143
+ function e2e(script) {
144
+ const binDir = fs.mkdtempSync(path.join(tmp, 'bin-'));
145
+ fs.writeFileSync(path.join(binDir, 'gh'), script, { mode: 0o755 });
146
+ return spawnSync('node', [scriptPath, '--repo', 'o/r', '--config-root', tmp], { encoding: 'utf8', env: { ...process.env, CLAUDE_CONFIG_DIR: path.join(tmp, 'noglobal'), PATH: binDir + path.delimiter + process.env.PATH } });
147
+ }
148
+ {
149
+ // Two pages for each read: one JSON document per line, as `--jq ... | tojson` prints.
150
+ const good = e2e(`#!/bin/sh
151
+ case "$*" in
152
+ *"issues?labels="*) echo '{"number":5,"title":"A","author":"rep"}'; echo '{"number":6,"title":"B","author":"rep"}' ;;
153
+ *"/issues/5/events"*) echo '{"at":"2026-01-01T00:00:00Z"}'; echo '{"at":"2026-01-02T00:00:00Z"}' ;;
154
+ *"/issues/6/events"*) echo '{"at":"2026-01-01T00:00:00Z"}' ;;
155
+ *"/issues/5/comments"*) echo '{"author":"rep","at":"2026-01-03T00:00:00Z"}' ;;
156
+ *"/issues/6/comments"*) echo '{"author":"x","at":"2026-01-03T00:00:00Z"}' ;;
157
+ *) exit 1 ;;
158
+ esac
159
+ `);
160
+ const j = JSON.parse(good.stdout.trim());
161
+ ok('e2e: multi-page reads classify answered and timed out', good.status === 0 && JSON.stringify(nums(j.answered)) === '[5]' && JSON.stringify(nums(j.timedOut)) === '[6]');
162
+ const partial = e2e(`#!/bin/sh
163
+ case "$*" in
164
+ *"issues?labels="*) echo '{"number":5,"title":"A","author":"rep"}' ;;
165
+ *"/issues/5/events"*) echo '{"at":"2026-01-01T00:00:00Z"}' ;;
166
+ *"/issues/5/comments"*) echo '{"author":"rep","at":"2026-01-03T00:00:00Z"}'; exit 1 ;;
167
+ *) exit 1 ;;
168
+ esac
169
+ `);
170
+ const p = JSON.parse(partial.stdout.trim());
171
+ ok('e2e: a read that fails after printing is unknown, never classified', partial.status === 0 && nums(p.unknown).join() === '5' && p.answered.length === 0 && p.timedOut.length === 0);
172
+ const down = e2e('#!/bin/sh\nexit 1\n');
173
+ ok('e2e: gh failing exits 1 with one JSON error line', down.status === 1 && JSON.parse(down.stdout.trim()).status === 'error');
174
+ const bad = spawnSync('node', [scriptPath, '--bogus'], { encoding: 'utf8' });
175
+ ok('e2e: usage error exits 2', bad.status === 2);
176
+ }
177
+
178
+ fs.rmSync(tmp, { recursive: true, force: true });
179
+ console.log(`inbox-needs-info.test.js: ${passed} checks passed`);