@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.
- package/README.it.md +142 -29
- package/README.md +141 -28
- package/bin/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -0
- package/bin/isolation-answer-docs.test.js +41 -0
- package/bin/isolation-copy-docs.test.js +28 -0
- package/bin/lib/codex-projection.js +17 -10
- package/bin/lib/codex-projection.test.js +80 -11
- package/bin/lib/install-verify.js +10 -5
- package/bin/lib/install-verify.test.js +51 -0
- package/bin/report-status-docs.test.js +195 -0
- package/bin/resolve-helpers-docs.test.js +36 -0
- package/bin/scope-confirm-skip-docs.test.js +70 -0
- package/bin/trailhead.js +40 -28
- package/bin/trailhead.test.js +503 -6
- package/bin/uat-setup-docs.test.js +65 -0
- package/package.json +4 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
- package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
- package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
- package/plugins/trailhead/agents/trailhead-debug.md +20 -1
- package/plugins/trailhead/agents/trailhead-executor.md +29 -2
- package/plugins/trailhead/agents/trailhead-fix.md +29 -2
- package/plugins/trailhead/agents/trailhead-plan.md +20 -1
- package/plugins/trailhead/agents/trailhead-research.md +22 -3
- package/plugins/trailhead/agents/trailhead-verify.md +15 -2
- package/plugins/trailhead/commands/panel.md +10 -0
- package/plugins/trailhead/hooks/hooks.json +3 -18
- package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
- package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
- package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
- package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
- package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
- package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
- package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
- package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
- package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
- package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
- package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +532 -65
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +434 -0
- package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
- package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
- package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +297 -0
- package/plugins/trailhead/hooks/mod/README.md +219 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +606 -0
- package/plugins/trailhead/hooks/mod/map-pane.tsx +832 -0
- package/plugins/trailhead/hooks/mod/register.tsx +293 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
- package/plugins/trailhead/hooks/mod/work-state-view.ts +110 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
- package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
- package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
- package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
- package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-install-guard.js +187 -42
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +182 -2
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
- package/plugins/trailhead/hooks/trailhead-search-guard.js +59 -13
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +68 -3
- package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
- package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
- package/plugins/trailhead/skills/_shared/choices.md +7 -3
- package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
- package/plugins/trailhead/skills/_shared/load-first.md +3 -3
- package/plugins/trailhead/skills/_shared/multi-map.md +3 -3
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
- package/plugins/trailhead/skills/_shared/substrate.md +6 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +17 -6
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +12 -2
- package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +16 -13
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +6 -4
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +28 -10
- package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
- package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
- package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
- package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +217 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1921 -0
- package/plugins/trailhead/tests/map-pane-view.test.ts +853 -0
- package/plugins/trailhead/tests/mod.test.ts +14 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
- package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
- package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
- package/plugins/trailhead/tests/work-state-view.test.ts +110 -0
- package/plugins/trailhead/types/index.d.ts +103 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for templates/trailhead-dashboard.js. Run: node dashboard.test.js
|
|
3
|
+
// No framework: plain asserts, same style as issue-body.test.js.
|
|
4
|
+
//
|
|
5
|
+
// Layers covered:
|
|
6
|
+
// 1. Pure renderDashboard() / mapFlags() on a fixed snapshot.
|
|
7
|
+
// 2. main() against a fake tracker behind an injected `gh` runner: what is
|
|
8
|
+
// gathered (and what is not), last-audit carry-through, self-heal
|
|
9
|
+
// (create + pin, re-pin), the guarded write, dry-run.
|
|
10
|
+
// 3. End-to-end: the script itself with a failing fake `gh` on PATH.
|
|
11
|
+
const assert = require('assert');
|
|
12
|
+
const { spawnSync } = require('child_process');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
|
|
17
|
+
const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-dashboard.js');
|
|
18
|
+
const { renderDashboard, mapFlags, classify, main } = require(scriptPath);
|
|
19
|
+
|
|
20
|
+
let passed = 0;
|
|
21
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
22
|
+
|
|
23
|
+
const AUDIT = '<!-- trailhead:last-audit 2026-10-01T10:00:00Z -->';
|
|
24
|
+
|
|
25
|
+
// --- pure render -------------------------------------------------------------
|
|
26
|
+
const SNAP = {
|
|
27
|
+
repo: 'o/r',
|
|
28
|
+
maps: [
|
|
29
|
+
{ number: 14, title: '🗺 multi-host', openScoped: 0, total: 20, fog: false, closedEdges: null },
|
|
30
|
+
{ number: 98, title: '🗺 website [v1]', openScoped: 5, total: 92, fog: null, closedEdges: true },
|
|
31
|
+
{ number: 5, title: '🗺 plain', openScoped: 3, total: 10, fog: null, closedEdges: null },
|
|
32
|
+
],
|
|
33
|
+
whiteboard: [
|
|
34
|
+
{ number: 30, title: 'Frontier one', assignees: [], labels: ['trailhead:ticket', 'trailhead:whiteboard', 'trailhead:build'] },
|
|
35
|
+
{ number: 31, title: 'Doing it', assignees: ['me'], labels: ['trailhead:ticket', 'trailhead:whiteboard'] },
|
|
36
|
+
{ number: 32, title: 'Stuck', assignees: [], labels: ['trailhead:ticket', 'trailhead:whiteboard', 'trailhead:blocked'] },
|
|
37
|
+
],
|
|
38
|
+
resolved: [{ number: 20, title: 'Done a' }, { number: 19, title: 'Done b' }],
|
|
39
|
+
inbox: 4,
|
|
40
|
+
anchors: { codebase: 2, conventions: 3 },
|
|
41
|
+
lastAudit: AUDIT,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
const body = renderDashboard(SNAP);
|
|
46
|
+
ok('render: deterministic', body === renderDashboard(JSON.parse(JSON.stringify(SNAP))));
|
|
47
|
+
ok('render: ends with a single newline', body.endsWith('\n') && !body.endsWith('\n\n'));
|
|
48
|
+
const heads = body.split('\n').filter((l) => l.startsWith('## '));
|
|
49
|
+
ok('render: the three sections in order', JSON.stringify(heads) === JSON.stringify(['## 🗺 Maps', '## 🧷 Whiteboard (map-less tickets)', '## 📊 Counts']));
|
|
50
|
+
ok('render: map link with open count', body.includes('- [🗺 multi-host](https://github.com/o/r/issues/14): 0 open'));
|
|
51
|
+
ok('render: bracket in a title is escaped', body.includes('[🗺 website \\[v1\\]](https://github.com/o/r/issues/98)'));
|
|
52
|
+
ok('render: exhausted flag', /issues\/14\): 0 open · exhausted · closeable/.test(body));
|
|
53
|
+
ok('render: near cap + prunable flag', /issues\/98\): 5 open · near cap · 92\/100 · prunable/.test(body));
|
|
54
|
+
ok('render: an unflagged map has no flag', /issues\/5\): 3 open\n/.test(body));
|
|
55
|
+
ok('render: whiteboard lists', /Frontier \(1\):\n- \[Frontier one #30\]\(https:\/\/github.com\/o\/r\/issues\/30\)/.test(body)
|
|
56
|
+
&& /In progress \(1\):\n- \[Doing it #31\]/.test(body) && /Blocked \(1\):\n- \[Stuck #32\]/.test(body));
|
|
57
|
+
ok('render: recently resolved titles', body.includes('_Recently resolved: [Done a #20](https://github.com/o/r/issues/20), [Done b #19](https://github.com/o/r/issues/19)_'));
|
|
58
|
+
ok('render: counts', /- Untriaged inbox: 4/.test(body) && /- Whiteboard frontier: 1/.test(body) && /- Open maps: 3/.test(body));
|
|
59
|
+
ok('render: anchors line', body.includes('[Codebase #2](https://github.com/o/r/issues/2)') && body.includes('[Conventions #3](https://github.com/o/r/issues/3)'));
|
|
60
|
+
ok('render: last-audit marker verbatim and last', body.trimEnd().endsWith(AUDIT));
|
|
61
|
+
ok('render: no em-dash', !body.includes('—'));
|
|
62
|
+
}
|
|
63
|
+
{
|
|
64
|
+
const body = renderDashboard({ ...SNAP, lastAudit: null, maps: [], whiteboard: [], resolved: [], anchors: { codebase: null, conventions: null } });
|
|
65
|
+
ok('render: empty tracker still has every section', /_No open maps\._/.test(body) && /_Nothing on the whiteboard\._/.test(body));
|
|
66
|
+
ok('render: no marker when none exists', !body.includes('last-audit'));
|
|
67
|
+
ok('render: no anchors line when none exist', !body.includes('Anchors'));
|
|
68
|
+
}
|
|
69
|
+
{
|
|
70
|
+
ok('mapFlags: exhausted needs zero scoped and no fog', JSON.stringify(mapFlags({ openScoped: 0, total: 3, fog: false })) === JSON.stringify(['exhausted · closeable']));
|
|
71
|
+
ok('mapFlags: fog keeps a zero-count map unflagged', mapFlags({ openScoped: 0, total: 3, fog: true }).length === 0);
|
|
72
|
+
ok('mapFlags: at cap', JSON.stringify(mapFlags({ openScoped: 4, total: 100, closedEdges: false })) === JSON.stringify(['at cap · 100/100']));
|
|
73
|
+
ok('mapFlags: at cap prunable', JSON.stringify(mapFlags({ openScoped: 4, total: 100, closedEdges: true })) === JSON.stringify(['at cap · 100/100 · prunable']));
|
|
74
|
+
ok('mapFlags: 89 is not near cap', mapFlags({ openScoped: 4, total: 89 }).length === 0);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// #263: classify() follows the panel's precedence (unverified > paused > blocked > in progress > frontier)
|
|
78
|
+
{
|
|
79
|
+
const wbT = (number, assignees, ...extra) => ({ number, title: `T${number}`, assignees, labels: ['trailhead:ticket', 'trailhead:whiteboard', ...extra] });
|
|
80
|
+
const nums = (list) => list.map((t) => t.number);
|
|
81
|
+
const c = classify([
|
|
82
|
+
wbT(1, []),
|
|
83
|
+
wbT(2, ['me']),
|
|
84
|
+
wbT(3, [], 'trailhead:blocked'),
|
|
85
|
+
wbT(4, [], 'trailhead:paused'),
|
|
86
|
+
wbT(5, ['me'], 'trailhead:paused'),
|
|
87
|
+
wbT(6, [], 'trailhead:paused', 'trailhead:blocked'),
|
|
88
|
+
wbT(7, [], 'trailhead:paused', 'trailhead:unverified'),
|
|
89
|
+
wbT(8, ['me'], 'trailhead:blocked'),
|
|
90
|
+
wbT(9, [], 'trailhead:unverified'),
|
|
91
|
+
]);
|
|
92
|
+
ok('classify: a paused ticket lands in paused, assigned or not, blocked or not', JSON.stringify(nums(c.paused)) === JSON.stringify([4, 5, 6]));
|
|
93
|
+
ok('classify: paused+unverified is quarantined as unverified', JSON.stringify(nums(c.unverified)) === JSON.stringify([7, 9]));
|
|
94
|
+
ok('classify: assigned+blocked is blocked', JSON.stringify(nums(c.blocked)) === JSON.stringify([3, 8]));
|
|
95
|
+
ok('classify: plain assigned is in progress, plain unassigned is frontier', JSON.stringify(nums(c.inProgress)) === JSON.stringify([2]) && JSON.stringify(nums(c.frontier)) === JSON.stringify([1]));
|
|
96
|
+
const body = renderDashboard({ ...SNAP, whiteboard: [wbT(30, []), wbT(31, ['me']), wbT(33, [], 'trailhead:paused'), wbT(34, ['me'], 'trailhead:paused'), wbT(32, [], 'trailhead:blocked')] });
|
|
97
|
+
ok('render: a Paused group sits between In progress and Blocked', /In progress \(1\):\n- \[T31 #31\][^\n]*\nPaused \(2\):\n- \[T33 #33\][^\n]*\n- \[T34 #34\][^\n]*\nBlocked \(1\):/.test(body));
|
|
98
|
+
ok('render: a paused ticket shows only under Paused', body.split('T33 #33').length === 2 && body.split('T34 #34').length === 2);
|
|
99
|
+
ok('render: Whiteboard frontier excludes paused tickets', /- Whiteboard frontier: 1\n/.test(body));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- fake tracker ------------------------------------------------------------
|
|
103
|
+
function makeTracker(init = {}) {
|
|
104
|
+
const t = {
|
|
105
|
+
maps: init.maps || [
|
|
106
|
+
{ number: 14, title: '🗺 multi-host', scoped: 0, total: 20, body: '## Destination\nx\n\n## Not yet specified\n_none yet_\n', edges: [] },
|
|
107
|
+
{ number: 98, title: '🗺 website', scoped: 5, total: 92, body: 'body of 98 long enough', edges: [{ number: 1, state: 'closed' }, { number: 2, state: 'open' }] },
|
|
108
|
+
{ number: 5, title: '🗺 plain', scoped: 3, total: 10, body: 'body of 5 long enough text', edges: [] },
|
|
109
|
+
],
|
|
110
|
+
whiteboardOpen: init.whiteboardOpen || [
|
|
111
|
+
{ number: 30, title: 'Frontier one', labels: [{ name: 'trailhead:ticket' }, { name: 'trailhead:whiteboard' }], assignees: [] },
|
|
112
|
+
{ number: 31, title: 'Doing it', labels: [{ name: 'trailhead:ticket' }, { name: 'trailhead:whiteboard' }], assignees: [{ login: 'me' }] },
|
|
113
|
+
],
|
|
114
|
+
closed: init.closed || [
|
|
115
|
+
{ number: 20, title: 'Done a', closedAt: '2026-10-10T00:00:00Z', labels: [{ name: 'trailhead:whiteboard' }] },
|
|
116
|
+
{ number: 19, title: 'Done old', closedAt: '2026-10-01T00:00:00Z', labels: [{ name: 'trailhead:whiteboard' }] },
|
|
117
|
+
{ number: 18, title: 'Out of scope one', closedAt: '2026-10-12T00:00:00Z', labels: [{ name: 'trailhead:whiteboard' }, { name: 'trailhead:out-of-scope' }] },
|
|
118
|
+
],
|
|
119
|
+
openIssues: init.openIssues || [
|
|
120
|
+
{ number: 50, labels: [] }, { number: 51, labels: [{ name: 'bug' }] }, { number: 52, labels: [{ name: 'trailhead:ticket' }] },
|
|
121
|
+
],
|
|
122
|
+
codebase: init.codebase === undefined ? 2 : init.codebase,
|
|
123
|
+
conventions: init.conventions === undefined ? 3 : init.conventions,
|
|
124
|
+
dash: init.dash === undefined ? { number: 33, body: 'Old hand written dashboard body.\n\n## Maps\nlots of prose here\n' + AUDIT + '\n', updatedAt: 'T1' } : init.dash,
|
|
125
|
+
pinned: init.pinned || [33],
|
|
126
|
+
edits: init.edits === undefined ? 1 : init.edits,
|
|
127
|
+
editStep: init.editStep === undefined ? 1 : init.editStep,
|
|
128
|
+
labels: [],
|
|
129
|
+
calls: [],
|
|
130
|
+
writes: [],
|
|
131
|
+
created: [],
|
|
132
|
+
pins: [],
|
|
133
|
+
failOn: init.failOn || null,
|
|
134
|
+
};
|
|
135
|
+
const ok_ = (stdout) => ({ status: 0, stdout, stderr: '' });
|
|
136
|
+
t.gh = (args) => {
|
|
137
|
+
const line = args.join(' ');
|
|
138
|
+
t.calls.push(line);
|
|
139
|
+
if (t.failOn && t.failOn.test(line)) return { status: 1, stdout: '', stderr: 'forced failure' };
|
|
140
|
+
if (args[0] === 'repo' && args[1] === 'view') return ok_('o/r\n');
|
|
141
|
+
if (args[0] === 'label' && args[1] === 'create') { t.labels.push(args[2]); return ok_(''); }
|
|
142
|
+
if (args[0] === 'issue' && args[1] === 'list') {
|
|
143
|
+
const li = args.indexOf('--label');
|
|
144
|
+
const label = li < 0 ? undefined : args[li + 1];
|
|
145
|
+
const state = args[args.indexOf('--state') + 1];
|
|
146
|
+
if (label === 'trailhead:map') return ok_(JSON.stringify(t.maps.map((m) => ({ number: m.number, title: m.title }))));
|
|
147
|
+
if (/^trailhead:map-\d+$/.test(label || '')) {
|
|
148
|
+
const m = t.maps.find((x) => 'trailhead:map-' + x.number === label);
|
|
149
|
+
return ok_(JSON.stringify(Array.from({ length: m.scoped }, (_, i) => ({ number: 1000 + i }))));
|
|
150
|
+
}
|
|
151
|
+
if (label === 'trailhead:whiteboard' && state === 'open') return ok_(JSON.stringify(t.whiteboardOpen));
|
|
152
|
+
if (label === 'trailhead:whiteboard' && state === 'closed') return ok_(JSON.stringify(t.closed));
|
|
153
|
+
if (label === 'trailhead:codebase') return ok_(JSON.stringify(t.codebase ? [{ number: t.codebase }] : []));
|
|
154
|
+
if (label === 'trailhead:conventions') return ok_(JSON.stringify(t.conventions ? [{ number: t.conventions }] : []));
|
|
155
|
+
if (label === 'trailhead:dashboard') return ok_(JSON.stringify(t.dash ? [{ number: t.dash.number }] : []));
|
|
156
|
+
if (label === undefined) return ok_(JSON.stringify(t.openIssues));
|
|
157
|
+
}
|
|
158
|
+
if (args[0] === 'issue' && args[1] === 'view') {
|
|
159
|
+
const n = Number(args[2]);
|
|
160
|
+
if (t.dash && n === t.dash.number) return ok_(JSON.stringify({ body: t.dash.body, updatedAt: t.dash.updatedAt }));
|
|
161
|
+
const m = t.maps.find((x) => x.number === n);
|
|
162
|
+
if (m) return ok_(JSON.stringify({ body: m.body, updatedAt: 'M' }));
|
|
163
|
+
}
|
|
164
|
+
if (args[0] === 'issue' && args[1] === 'edit') {
|
|
165
|
+
const text = fs.readFileSync(args[args.indexOf('--body-file') + 1], 'utf8');
|
|
166
|
+
t.writes.push(text);
|
|
167
|
+
t.dash.body = text;
|
|
168
|
+
t.edits += t.editStep;
|
|
169
|
+
t.dash.updatedAt = 'T2';
|
|
170
|
+
return ok_('');
|
|
171
|
+
}
|
|
172
|
+
if (args[0] === 'issue' && args[1] === 'create') {
|
|
173
|
+
const text = fs.readFileSync(args[args.indexOf('--body-file') + 1], 'utf8');
|
|
174
|
+
t.created.push({ text, args });
|
|
175
|
+
t.dash = { number: 77, body: text, updatedAt: 'T1' };
|
|
176
|
+
return ok_('https://github.com/o/r/issues/77\n');
|
|
177
|
+
}
|
|
178
|
+
if (args[0] === 'api' && args[1] === 'graphql') {
|
|
179
|
+
const q = args.find((a) => a.startsWith('query=')) || '';
|
|
180
|
+
if (/userContentEdits/.test(q)) return ok_(JSON.stringify({ data: { repository: { issue: { userContentEdits: { totalCount: t.edits } } } } }));
|
|
181
|
+
if (/pinnedIssues/.test(q)) return ok_(JSON.stringify({ data: { repository: { pinnedIssues: { nodes: t.pinned.map((number) => ({ issue: { number } })) } } } }));
|
|
182
|
+
if (/pinIssue/.test(q)) { t.pins.push(args.join(' ')); return ok_('{}'); }
|
|
183
|
+
}
|
|
184
|
+
if (args[0] === 'api') {
|
|
185
|
+
const ep = args[1];
|
|
186
|
+
let m = /^repos\/o\/r\/issues\/(\d+)\/sub_issues/.exec(ep);
|
|
187
|
+
if (m) return ok_(JSON.stringify(t.maps.find((x) => x.number === Number(m[1])).edges));
|
|
188
|
+
m = /^repos\/o\/r\/issues\/(\d+)$/.exec(ep);
|
|
189
|
+
if (m) {
|
|
190
|
+
const n = Number(m[1]);
|
|
191
|
+
if (args.includes('.node_id')) return ok_('NODE_' + n + '\n');
|
|
192
|
+
const mp = t.maps.find((x) => x.number === n);
|
|
193
|
+
if (mp) return ok_(String(mp.total) + '\n');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return { status: 1, stdout: '', stderr: 'unexpected: ' + line };
|
|
197
|
+
};
|
|
198
|
+
return t;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const fakeGuard = { scan: (text) => (/ghp_[A-Za-z0-9]{20,}/.test(text) ? 'github token' : null) };
|
|
202
|
+
|
|
203
|
+
function runMain(argv, t, extra = {}) {
|
|
204
|
+
const chunks = [];
|
|
205
|
+
const errChunks = [];
|
|
206
|
+
const realErr = process.stderr.write.bind(process.stderr);
|
|
207
|
+
process.stderr.write = (s) => { errChunks.push(String(s)); return true; };
|
|
208
|
+
let code;
|
|
209
|
+
try {
|
|
210
|
+
code = main(argv, { gh: t.gh, stdout: { write: (s) => chunks.push(s) }, guard: fakeGuard, ...extra });
|
|
211
|
+
} finally {
|
|
212
|
+
process.stderr.write = realErr;
|
|
213
|
+
}
|
|
214
|
+
return { code, out: chunks.join(''), err: errChunks.join('') };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// --- regenerate an existing dashboard ----------------------------------------
|
|
218
|
+
{
|
|
219
|
+
const t = makeTracker();
|
|
220
|
+
const oldSize = Buffer.byteLength(t.dash.body);
|
|
221
|
+
const r = runMain(['--repo', 'o/r'], t);
|
|
222
|
+
ok('regen: exit 0', r.code === 0);
|
|
223
|
+
ok('regen: exactly one output line', r.out.split('\n').filter(Boolean).length === 1);
|
|
224
|
+
ok('regen: summary line', /^dashboard #33: 3 maps, whiteboard 2, flags: map-14 exhausted, map-98 near cap 92\/100 prunable/.test(r.out));
|
|
225
|
+
ok('regen: reports old and new size', new RegExp(`body ${oldSize} -> ${Buffer.byteLength(t.writes[0])} bytes`).test(r.out));
|
|
226
|
+
ok('regen: one write', t.writes.length === 1);
|
|
227
|
+
ok('regen: the old prose is gone, the shape is generated', !t.writes[0].includes('lots of prose here') && t.writes[0].includes('## 🗺 Maps'));
|
|
228
|
+
ok('regen: last-audit kept verbatim', t.writes[0].trimEnd().endsWith(AUDIT));
|
|
229
|
+
ok('regen: closed out-of-scope tickets are not "resolved", newest first', /Recently resolved: \[Done a #20\].*\[Done old #19\]/.test(t.writes[0]) && !t.writes[0].includes('Out of scope one'));
|
|
230
|
+
ok('regen: inbox counts only issues without a trailhead label', /- Untriaged inbox: 2/.test(t.writes[0]));
|
|
231
|
+
ok('regen: label ensured first', t.labels.includes('trailhead:dashboard'));
|
|
232
|
+
ok('regen: already pinned, no pin call', t.pins.length === 0);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// --- what is (not) gathered --------------------------------------------------
|
|
236
|
+
{
|
|
237
|
+
const t = makeTracker();
|
|
238
|
+
runMain(['--repo', 'o/r'], t);
|
|
239
|
+
const views = t.calls.filter((c) => c.startsWith('issue view'));
|
|
240
|
+
ok('fog: body read only for the zero-count map', views.some((c) => c.startsWith('issue view 14 ')) && !views.some((c) => c.startsWith('issue view 98 ') || c.startsWith('issue view 5 ')));
|
|
241
|
+
const edges = t.calls.filter((c) => /\/sub_issues/.test(c));
|
|
242
|
+
ok('closed-edge check only for near/at-cap maps', edges.length === 1 && /issues\/98\/sub_issues/.test(edges[0]));
|
|
243
|
+
}
|
|
244
|
+
{
|
|
245
|
+
// a zero-count map with fog left is not exhausted
|
|
246
|
+
const t = makeTracker({ maps: [{ number: 14, title: '🗺 m', scoped: 0, total: 2, body: '## Not yet specified\n- still foggy\n', edges: [] }] });
|
|
247
|
+
const r = runMain(['--repo', 'o/r'], t);
|
|
248
|
+
ok('fog left: not flagged exhausted', /flags: none/.test(r.out) && !t.writes[0].includes('exhausted'));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- W2: a missing fog section is unconfirmed, not empty ----------------------
|
|
252
|
+
{
|
|
253
|
+
const t = makeTracker({ maps: [{ number: 14, title: '🗺 m', scoped: 0, total: 2, body: '## Destination\nSomewhere good, long enough.\n', edges: [] }] });
|
|
254
|
+
const r = runMain(['--repo', 'o/r'], t);
|
|
255
|
+
ok('W2: no "Not yet specified" section: not flagged exhausted', r.code === 0 && /flags: none/.test(r.out) && !t.writes[0].includes('exhausted'));
|
|
256
|
+
ok('W2: mapFlags keeps fog=null unflagged', mapFlags({ openScoped: 0, total: 2, fog: null }).length === 0);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// --- W3: newest closures are inside the window; a hit limit is said out loud --
|
|
260
|
+
{
|
|
261
|
+
const t = makeTracker();
|
|
262
|
+
runMain(['--repo', 'o/r'], t);
|
|
263
|
+
const q = t.calls.find((c) => c.startsWith('issue list --label trailhead:whiteboard --state closed'));
|
|
264
|
+
ok('W3: the resolved query is sorted by recent updates', /--search sort:updated-desc/.test(q));
|
|
265
|
+
ok('W3: a normal run reports no truncation', !/truncated/.test(t.writes[0]) && !/truncated/.test(runMain(['--repo', 'o/r', '--dry-run'], makeTracker()).out));
|
|
266
|
+
}
|
|
267
|
+
{
|
|
268
|
+
const many = (n, extra) => Array.from({ length: n }, (_, i) => ({ number: 5000 + i, title: 't' + i, labels: [], assignees: [], ...extra }));
|
|
269
|
+
const t = makeTracker({ openIssues: many(1000) });
|
|
270
|
+
const r = runMain(['--repo', 'o/r'], t);
|
|
271
|
+
ok('W3: inbox at its limit is said in the output line', r.code === 0 && /truncated: inbox/.test(r.out));
|
|
272
|
+
ok('W3: the inbox count is rendered as a lower bound', /- Untriaged inbox: 1000\+/.test(t.writes[0]));
|
|
273
|
+
const w = makeTracker({ whiteboardOpen: many(200, { labels: [{ name: 'trailhead:whiteboard' }] }) });
|
|
274
|
+
ok('W3: whiteboard at its limit is said', /truncated: whiteboard/.test(runMain(['--repo', 'o/r'], w).out));
|
|
275
|
+
const dropped = many(100, { closedAt: '2026-10-01T00:00:00Z', labels: [{ name: 'trailhead:whiteboard' }, { name: 'trailhead:out-of-scope' }] });
|
|
276
|
+
const c = makeTracker({ closed: dropped });
|
|
277
|
+
ok('W3: a full resolved window that filters down below 10 is said', /truncated: resolved/.test(runMain(['--repo', 'o/r'], c).out));
|
|
278
|
+
const fine = many(100, { closedAt: '2026-10-01T00:00:00Z', labels: [{ name: 'trailhead:whiteboard' }] });
|
|
279
|
+
ok('W3: a full resolved window with 10 to show is not truncation', !/truncated/.test(runMain(['--repo', 'o/r'], makeTracker({ closed: fine })).out));
|
|
280
|
+
const both = runMain(['--repo', 'o/r'], makeTracker({ openIssues: many(1000), whiteboardOpen: many(200, { labels: [{ name: 'trailhead:whiteboard' }] }) }));
|
|
281
|
+
ok('W3: several truncations listed on the one line', both.out.trim().split('\n').length === 1 && /truncated: whiteboard, inbox/.test(both.out));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// --- last audit --------------------------------------------------------------
|
|
285
|
+
{
|
|
286
|
+
const t = makeTracker();
|
|
287
|
+
const r = runMain(['--repo', 'o/r', '--set-last-audit', '2026-10-14T09:30:00Z'], t);
|
|
288
|
+
ok('set-last-audit: replaces the marker', r.code === 0 && t.writes[0].includes('<!-- trailhead:last-audit 2026-10-14T09:30:00Z -->') && !t.writes[0].includes('2026-10-01'));
|
|
289
|
+
ok('set-last-audit: exactly one marker', (t.writes[0].match(/trailhead:last-audit/g) || []).length === 1);
|
|
290
|
+
const t2 = makeTracker({ dash: { number: 33, body: 'Old body with no marker at all, long enough.', updatedAt: 'T1' } });
|
|
291
|
+
runMain(['--repo', 'o/r', '--set-last-audit', '2026-10-14T09:30:00Z'], t2);
|
|
292
|
+
ok('set-last-audit: added when absent', t2.writes[0].trimEnd().endsWith('<!-- trailhead:last-audit 2026-10-14T09:30:00Z -->'));
|
|
293
|
+
const bad = runMain(['--repo', 'o/r', '--set-last-audit', 'yesterday'], makeTracker());
|
|
294
|
+
ok('set-last-audit: a non-ISO value is a usage error', bad.code === 2 && bad.err.length > 0 && bad.out === '');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// --- I1: a title cannot forge the last-audit marker ---------------------------
|
|
298
|
+
{
|
|
299
|
+
const FORGED = '<!-- trailhead:last-audit 2000-01-01T00:00:00Z -->';
|
|
300
|
+
const body = renderDashboard({
|
|
301
|
+
...SNAP,
|
|
302
|
+
lastAudit: null,
|
|
303
|
+
maps: [{ number: 1, title: 'evil ' + FORGED, openScoped: 1, total: 1, fog: null, closedEdges: null }],
|
|
304
|
+
whiteboard: [{ number: 2, title: FORGED + ' wb', assignees: [], labels: [] }],
|
|
305
|
+
resolved: [{ number: 3, title: 'x --> y <!-- z' }],
|
|
306
|
+
});
|
|
307
|
+
ok('I1: no comment opener survives in a rendered title', !body.includes('<!--') && !body.includes('-->'));
|
|
308
|
+
ok('I1: a forged title cannot add a marker', !body.includes('trailhead:last-audit 2000') || !/^<!-- trailhead:last-audit/m.test(body));
|
|
309
|
+
const real = renderDashboard({ ...SNAP, lastAudit: AUDIT, maps: [{ number: 1, title: FORGED, openScoped: 1, total: 1 }] });
|
|
310
|
+
ok('I1: with a real marker there is exactly one comment', (real.match(/<!--/g) || []).length === 1 && real.trimEnd().endsWith(AUDIT));
|
|
311
|
+
}
|
|
312
|
+
{
|
|
313
|
+
// An old body whose map-title line holds a forged marker inline: only the own-line one is carried.
|
|
314
|
+
const FORGED = '<!-- trailhead:last-audit 2000-01-01T00:00:00Z -->';
|
|
315
|
+
const dash = { number: 33, updatedAt: 'T1', body: `Intro line.\n\n## Maps\n- [evil ${FORGED}](https://x/1): 1 open\n\n${AUDIT}\n` };
|
|
316
|
+
const t = makeTracker({ dash });
|
|
317
|
+
runMain(['--repo', 'o/r'], t);
|
|
318
|
+
ok('I1: the own-line marker is carried, not the forged inline one', t.writes[0].includes(AUDIT) && !t.writes[0].includes('2000-01-01'));
|
|
319
|
+
const t2 = makeTracker({ dash: { number: 33, updatedAt: 'T1', body: `Intro line.\n\n## Maps\n- [evil ${FORGED}](https://x/1): 1 open\n` } });
|
|
320
|
+
runMain(['--repo', 'o/r'], t2);
|
|
321
|
+
ok('I1: a forged inline marker alone is not carried', !t2.writes[0].includes('trailhead:last-audit'));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// --- self-heal ---------------------------------------------------------------
|
|
325
|
+
{
|
|
326
|
+
const t = makeTracker({ dash: null, pinned: [] });
|
|
327
|
+
const r = runMain(['--repo', 'o/r'], t);
|
|
328
|
+
ok('create: issue created once with the final body, never empty', r.code === 0 && t.created.length === 1 && t.created[0].text.includes('## 🗺 Maps') && t.created[0].text.trim() !== '');
|
|
329
|
+
ok('create: labelled trailhead:dashboard, no placeholder write after', t.created[0].args.includes('trailhead:dashboard') && t.writes.length === 0);
|
|
330
|
+
ok('create: pinned', t.pins.length === 1 && /NODE_77|id=NODE_77/.test(t.pins[0]));
|
|
331
|
+
ok('create: summary names the new issue', /^dashboard #77: 3 maps/.test(r.out) && /created/.test(r.out));
|
|
332
|
+
}
|
|
333
|
+
{
|
|
334
|
+
const t = makeTracker({ pinned: [2, 3] });
|
|
335
|
+
const r = runMain(['--repo', 'o/r'], t);
|
|
336
|
+
ok('re-pin: an unpinned dashboard is pinned again', r.code === 0 && t.pins.length === 1 && /NODE_33/.test(t.pins[0]));
|
|
337
|
+
}
|
|
338
|
+
{
|
|
339
|
+
const t = makeTracker({ dash: null, pinned: [] });
|
|
340
|
+
const r = runMain(['--repo', 'o/r', '--set-last-audit', '2026-10-14T09:30:00Z'], t);
|
|
341
|
+
ok('create with --set-last-audit: marker in the first body', t.created[0].text.includes('trailhead:last-audit 2026-10-14T09:30:00Z'));
|
|
342
|
+
const ghost = makeTracker({ dash: null, pinned: [] });
|
|
343
|
+
const guardHit = runMain(['--repo', 'o/r'], ghost, { guard: { scan: () => 'github token' } });
|
|
344
|
+
ok('create: a secret in the generated body aborts the create', guardHit.code === 1 && ghost.created.length === 0);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// --- I2: a pin-check failure after the write is not an abort ------------------
|
|
348
|
+
{
|
|
349
|
+
const t = makeTracker({ failOn: /pinnedIssues/ });
|
|
350
|
+
const r = runMain(['--repo', 'o/r'], t);
|
|
351
|
+
ok('I2: the body was written', t.writes.length === 1);
|
|
352
|
+
ok('I2: exit 0, one line saying written and the pin check failed', r.code === 0 && r.out.trim().split('\n').length === 1
|
|
353
|
+
&& /written, pin check failed: .*forced failure/.test(r.out) && !/^aborted/.test(r.out));
|
|
354
|
+
ok('I2: the line still carries the summary and sizes', /^dashboard #33: 3 maps/.test(r.out) && /body \d+ -> \d+ bytes/.test(r.out));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// --- guards ------------------------------------------------------------------
|
|
358
|
+
{
|
|
359
|
+
const t = makeTracker();
|
|
360
|
+
t.maps[0].title = '🗺 leak ghp_' + 'a'.repeat(36);
|
|
361
|
+
const r = runMain(['--repo', 'o/r'], t);
|
|
362
|
+
ok('secret in the whole generated body: abort, nothing written', r.code === 1 && /secret/.test(r.out) && t.writes.length === 0);
|
|
363
|
+
const t2 = makeTracker();
|
|
364
|
+
const r2 = runMain(['--repo', 'o/r'], t2, { guard: null });
|
|
365
|
+
ok('no guard module: fail closed', r2.code === 1 && /secret-scan unavailable/.test(r2.out) && t2.writes.length === 0);
|
|
366
|
+
const t3 = makeTracker({ dash: { number: 33, body: '', updatedAt: 'T1' } });
|
|
367
|
+
const r3 = runMain(['--repo', 'o/r'], t3);
|
|
368
|
+
ok('empty existing body: refused, nothing written', r3.code === 1 && /empty/.test(r3.out) && t3.writes.length === 0);
|
|
369
|
+
const t4 = makeTracker({ failOn: /issue list --label trailhead:whiteboard --state open/ });
|
|
370
|
+
const r4 = runMain(['--repo', 'o/r'], t4);
|
|
371
|
+
ok('a failed gather aborts, nothing written', r4.code === 1 && /^aborted: /.test(r4.out) && t4.writes.length === 0);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// --- dry run -----------------------------------------------------------------
|
|
375
|
+
{
|
|
376
|
+
const t = makeTracker();
|
|
377
|
+
const r = runMain(['--repo', 'o/r', '--dry-run'], t);
|
|
378
|
+
ok('dry-run: exit 0, one line, sizes', r.code === 0 && r.out.split('\n').filter(Boolean).length === 1 && /dry-run/.test(r.out) && /body \d+ -> \d+ bytes/.test(r.out));
|
|
379
|
+
ok('dry-run: writes nothing', t.writes.length === 0 && t.created.length === 0 && t.pins.length === 0 && !t.calls.some((c) => c.startsWith('label create')));
|
|
380
|
+
const m = makeTracker({ dash: null, pinned: [] });
|
|
381
|
+
const r2 = runMain(['--repo', 'o/r', '--dry-run'], m);
|
|
382
|
+
ok('dry-run: a missing dashboard is reported, not created', r2.code === 0 && /would create/.test(r2.out) && m.created.length === 0 && m.pins.length === 0);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// --- usage errors ------------------------------------------------------------
|
|
386
|
+
for (const argv of [['--bogus'], ['--repo'], ['--repo', 'nope'], ['--set-last-audit'], ['extra']]) {
|
|
387
|
+
const t = makeTracker();
|
|
388
|
+
const r = runMain(argv, t);
|
|
389
|
+
ok(`usage error exit 2: ${argv.join(' ')}`, r.code === 2 && r.err.length > 0 && r.out === '' && t.calls.length === 0);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// --- end-to-end with a failing fake gh on PATH --------------------------------
|
|
393
|
+
{
|
|
394
|
+
const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashboard-bin-'));
|
|
395
|
+
fs.writeFileSync(path.join(binDir, 'gh'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
|
|
396
|
+
const res = spawnSync('node', [scriptPath, '--repo', 'o/r'], { encoding: 'utf8', env: { ...process.env, PATH: binDir + path.delimiter + process.env.PATH } });
|
|
397
|
+
ok('e2e: gh failing aborts with exit 1 and one line', res.status === 1 && /^aborted: /.test(res.stdout) && res.stdout.trim().split('\n').length === 1);
|
|
398
|
+
const bad = spawnSync('node', [scriptPath, '--bogus'], { encoding: 'utf8' });
|
|
399
|
+
ok('e2e: usage error exits 2', bad.status === 2);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
{
|
|
403
|
+
// GitHub stops recording body edits at 100: the write is clean on an exact read-back.
|
|
404
|
+
const t = makeTracker({ edits: 100, editStep: 0 });
|
|
405
|
+
const r = runMain(['--repo', 'o/r'], t);
|
|
406
|
+
ok('full edit history: the dashboard write is not a conflict', r.code === 0 && !/conflict|aborted/.test(r.out) && t.writes.length === 1);
|
|
407
|
+
ok('full edit history: the output says the check was read-back only', /edit history full/.test(r.out));
|
|
408
|
+
}
|
|
409
|
+
console.log(`dashboard.test.js: ${passed} checks passed`);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead commit-msg hook (git). Host-independent guardrail: installed into
|
|
4
|
+
// .git/hooks/commit-msg at repo first-use so trailhead's commit discipline is
|
|
5
|
+
// enforced on EVERY `git commit`, whatever agent host drives it (Codex has no
|
|
6
|
+
// hook bus; this git hook is how the commit-guard survives there). Decision #16.
|
|
7
|
+
//
|
|
8
|
+
// Self-contained by design: detached from the trailhead install, it inlines the
|
|
9
|
+
// same rules as plugins/trailhead/hooks/lib/commit-message-check.js. That
|
|
10
|
+
// lib's test drives this hook end-to-end so the copies cannot drift.
|
|
11
|
+
//
|
|
12
|
+
// git passes the commit-message file path as argv[2]. On a violation: print the
|
|
13
|
+
// reason to stderr and exit 1 (git aborts the commit). Otherwise exit 0.
|
|
14
|
+
// Crash-safe: any unexpected error exits 0 (never wedge a commit).
|
|
15
|
+
//
|
|
16
|
+
// Note: a merge/revert/fixup message already fails the conventional-subject
|
|
17
|
+
// rule below, so it needs no special case; `git commit --amend` keeps whatever
|
|
18
|
+
// trailer the original message carried; `git commit --no-verify` bypasses this
|
|
19
|
+
// hook entirely, as before.
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
|
|
24
|
+
const TYPES = 'feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert';
|
|
25
|
+
const CONVENTIONAL = new RegExp(`^(${TYPES})(\\([^)]+\\))?!?:\\s.+`);
|
|
26
|
+
const CO_AUTHORED = /co-authored-by\s*:/i;
|
|
27
|
+
const SCISSORS = '# ------------------------ >8';
|
|
28
|
+
const REFS_LINE = /^\s*refs\s*:(.*)$/i;
|
|
29
|
+
|
|
30
|
+
function messageRegion(raw) {
|
|
31
|
+
const text = String(raw == null ? '' : raw);
|
|
32
|
+
const i = text.indexOf(SCISSORS);
|
|
33
|
+
return i === -1 ? text : text.slice(0, i);
|
|
34
|
+
}
|
|
35
|
+
function subjectOf(raw) {
|
|
36
|
+
for (const line of messageRegion(raw).split('\n')) {
|
|
37
|
+
if (line.trim() === '' || line.startsWith('#')) continue;
|
|
38
|
+
return line.trim();
|
|
39
|
+
}
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Mirrors parseSessionTicket() in commit-message-check.js: parses the first
|
|
44
|
+
// line of a .trailhead/session-ticket marker (`#<n> <title>`).
|
|
45
|
+
function parseSessionTicket(text) {
|
|
46
|
+
const raw = String(text == null ? '' : text).replace(/^/, '');
|
|
47
|
+
const firstLine = raw.split('\n')[0].replace(/\r$/, '');
|
|
48
|
+
const m = firstLine.match(/^#([1-9]\d*)(?:\s|$)/);
|
|
49
|
+
return m ? Number(m[1]) : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Mirrors hasRefsFor() in commit-message-check.js.
|
|
53
|
+
function hasRefsFor(message, n) {
|
|
54
|
+
const target = new RegExp('#' + n + '(?![\\w])');
|
|
55
|
+
for (const line of messageRegion(message).split('\n')) {
|
|
56
|
+
if (line.trim().startsWith('#')) continue;
|
|
57
|
+
const m = line.match(REFS_LINE);
|
|
58
|
+
if (m && target.test(m[1])) return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Reads the session-ticket marker at the worktree top-level. git runs
|
|
64
|
+
// commit-msg from the worktree top-level (linked worktrees included), so
|
|
65
|
+
// process.cwd() is the right root here. Its own try/catch: a read error
|
|
66
|
+
// (marker absent, unreadable, etc.) must never skip the other rules below,
|
|
67
|
+
// it just leaves the Refs rule off.
|
|
68
|
+
function readTicket() {
|
|
69
|
+
try {
|
|
70
|
+
const markerPath = path.join(process.cwd(), '.trailhead', 'session-ticket');
|
|
71
|
+
return parseSessionTicket(fs.readFileSync(markerPath, 'utf8'));
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const file = process.argv[2];
|
|
79
|
+
const raw = file ? fs.readFileSync(file, 'utf8') : '';
|
|
80
|
+
const region = messageRegion(raw);
|
|
81
|
+
|
|
82
|
+
if (CO_AUTHORED.test(region)) {
|
|
83
|
+
process.stderr.write('trailhead: do not add a Co-Authored-By trailer to commits. Remove it and commit again.\n');
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
const subject = subjectOf(raw);
|
|
87
|
+
if (subject && !CONVENTIONAL.test(subject)) {
|
|
88
|
+
process.stderr.write(`trailhead: commit subject must be Conventional Commits: <type>(<scope>)?: <subject>. Valid types: ${TYPES.replace(/\|/g, ', ')}.\n`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
if (subject.length > 72) {
|
|
92
|
+
process.stderr.write('trailhead: commit subject must be 72 characters or less.\n');
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ticket = readTicket();
|
|
97
|
+
if (Number.isInteger(ticket) && ticket > 0 && !hasRefsFor(raw, ticket)) {
|
|
98
|
+
process.stderr.write(`trailhead: this commit is on ticket #${ticket} (per .trailhead/session-ticket) but has no "Refs: #${ticket}" trailer. Add "Refs: #${ticket}" as the last line of the message and commit again. If this commit is not part of ticket #${ticket}, remove the stale .trailhead/session-ticket marker first.\n`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
process.exit(0); // never wedge a commit on an unexpected error
|
|
103
|
+
}
|
|
104
|
+
process.exit(0);
|
|
@@ -49,6 +49,21 @@
|
|
|
49
49
|
// stray keyword token in a genuinely non-write command from being
|
|
50
50
|
// misclassified as a write.
|
|
51
51
|
//
|
|
52
|
+
// Third follow-up fix (#219): the walk compared raw, still-quoted tokens, but bash
|
|
53
|
+
// removes quotes before `gh` runs, so `"gh" issue comment`, `gh 'issue' comment`,
|
|
54
|
+
// `gh issue com""ment`, `\gh ...` or `$'gh' ...` hid the write from both guards.
|
|
55
|
+
// Every token is now compared (and `sub`/`verb` returned) in its quote-removed
|
|
56
|
+
// form. This slightly over-detects (a quote inside a single-quoted word is
|
|
57
|
+
// dropped too), which is the safe direction for a guard.
|
|
58
|
+
//
|
|
59
|
+
// Fourth follow-up fix (#225): the verb was read as the token right after the
|
|
60
|
+
// subcommand, so a flag between them (`gh issue -R o/r comment 5`,
|
|
61
|
+
// `gh pr --repo=o/r edit 5`) made the verb the flag itself and hid the write
|
|
62
|
+
// from both guards. Flags after the subcommand are now skipped with the same
|
|
63
|
+
// arity model as the global walk (a glued `--flag=value` or a boolean global
|
|
64
|
+
// flag takes no value, any other flag consumes one following non-flag token)
|
|
65
|
+
// before the verb is read, in the primary walk and in the fail-safe fallback.
|
|
66
|
+
//
|
|
52
67
|
// Self-contained. No requires.
|
|
53
68
|
|
|
54
69
|
// gh's boolean global flags (take NO value). gh's only value-taking global
|
|
@@ -62,11 +77,83 @@ const BOOLEAN_GLOBAL_FLAGS = new Set(['-h', '--help', '--version']);
|
|
|
62
77
|
// below to recover from an unknown boolean flag swallowing the subcommand.
|
|
63
78
|
const WRITE_SUBCOMMANDS = new Set(['issue', 'pr', 'api']);
|
|
64
79
|
|
|
80
|
+
// Single-letter ANSI-C escapes (`$'\n'`).
|
|
81
|
+
const ANSI_C_SIMPLE = { n: '\n', t: '\t', r: '\r', a: '\x07', b: '\b', f: '\f', v: '\v', e: '\x1b' };
|
|
82
|
+
|
|
83
|
+
// Decode the escape at word[i] (the char after a backslash) inside a `$'...'` span:
|
|
84
|
+
// { out, next }. Unknown pairs keep the escaped char, so nothing is lost.
|
|
85
|
+
function ansiEscape(word, i) {
|
|
86
|
+
const n = word[i];
|
|
87
|
+
if (Object.prototype.hasOwnProperty.call(ANSI_C_SIMPLE, n)) return { out: ANSI_C_SIMPLE[n], next: i + 1 };
|
|
88
|
+
const rest = word.slice(i);
|
|
89
|
+
let m;
|
|
90
|
+
if ((m = /^x([0-9a-fA-F]{1,2})/.exec(rest)) || (m = /^u([0-9a-fA-F]{1,4})/.exec(rest)) || (m = /^U([0-9a-fA-F]{1,8})/.exec(rest))) {
|
|
91
|
+
const cp = parseInt(m[1], 16);
|
|
92
|
+
return { out: cp <= 0x10ffff ? String.fromCodePoint(cp) : m[0], next: i + m[0].length };
|
|
93
|
+
}
|
|
94
|
+
if ((m = /^[0-7]{1,3}/.exec(rest))) return { out: String.fromCharCode(parseInt(m[0], 8) & 0xff), next: i + m[0].length };
|
|
95
|
+
return { out: n, next: i + 1 };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// A word as bash passes it once quoting is removed: a `$'...'` span is decoded
|
|
99
|
+
// (\xHH, \NNN, \uHHHH, \n ...), `$` before a `"` is dropped, a backslash escape keeps
|
|
100
|
+
// the escaped char, quote characters are dropped (#219). Also returns, per output
|
|
101
|
+
// char, the id of the quoted span it came from (-1 for unquoted text), so a caller can
|
|
102
|
+
// tell `"-F x"` (one span, free text) from `-"F"/"p a"` (the flag, then a value).
|
|
103
|
+
function dequoteSpans(word) {
|
|
104
|
+
const w = String(word);
|
|
105
|
+
let text = '';
|
|
106
|
+
const spans = [];
|
|
107
|
+
const put = (str, id) => { text += str; for (let k = 0; k < str.length; k++) spans.push(id); };
|
|
108
|
+
let q = null; // the open quote: ' or "
|
|
109
|
+
let id = 0;
|
|
110
|
+
for (let i = 0; i < w.length; i++) {
|
|
111
|
+
const c = w[i];
|
|
112
|
+
if (q === "'") { if (c === "'") q = null; else put(c, id); continue; }
|
|
113
|
+
if (q === '"') {
|
|
114
|
+
if (c === '"') q = null;
|
|
115
|
+
else if (c === '\\' && i + 1 < w.length && '$`"\\\n'.includes(w[i + 1])) {
|
|
116
|
+
// bash: inside "..." a backslash only escapes $ ` " \ and newline (which vanishes)
|
|
117
|
+
i++;
|
|
118
|
+
if (w[i] !== '\n') put(w[i], id);
|
|
119
|
+
} else put(c, id);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (c === '$' && w[i + 1] === "'") {
|
|
123
|
+
id++;
|
|
124
|
+
i += 2;
|
|
125
|
+
while (i < w.length && w[i] !== "'") {
|
|
126
|
+
if (w[i] === '\\' && i + 1 < w.length) { const e = ansiEscape(w, i + 1); put(e.out, id); i = e.next; } else put(w[i++], id);
|
|
127
|
+
}
|
|
128
|
+
} else if (c === '$' && w[i + 1] === '"') {
|
|
129
|
+
continue; // locale quoting: the `$` vanishes, the double-quoted span follows
|
|
130
|
+
} else if (c === '\\' && i + 1 < w.length) put(w[++i], -1);
|
|
131
|
+
else if (c === "'" || c === '"') { q = c; id++; }
|
|
132
|
+
else put(c, -1);
|
|
133
|
+
}
|
|
134
|
+
return { text, spans };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const dequote = (word) => dequoteSpans(word).text;
|
|
138
|
+
|
|
139
|
+
// Skip the flags starting at toks[i] and return the index of the first
|
|
140
|
+
// non-flag token. A glued `--flag=value` or a BOOLEAN_GLOBAL_FLAGS member takes
|
|
141
|
+
// no value; any other flag consumes one following non-flag token (#153, #225).
|
|
142
|
+
function skipFlags(toks, i) {
|
|
143
|
+
while (i < toks.length && toks[i].startsWith('-')) {
|
|
144
|
+
const flag = toks[i];
|
|
145
|
+
i++; // the flag token itself
|
|
146
|
+
if (flag.includes('=') || BOOLEAN_GLOBAL_FLAGS.has(flag)) continue; // no separate value
|
|
147
|
+
if (i < toks.length && !toks[i].startsWith('-')) i++; // consume its value
|
|
148
|
+
}
|
|
149
|
+
return i;
|
|
150
|
+
}
|
|
151
|
+
|
|
65
152
|
// Parse a raw shell command string and locate the gh subcommand/verb.
|
|
66
153
|
// Returns { sub, verb } (verb may be undefined) or null when this is not a
|
|
67
154
|
// gh invocation at all.
|
|
68
155
|
function parseGhSubcommand(cmd) {
|
|
69
|
-
const toks = String(cmd).trim().split(/\s+/);
|
|
156
|
+
const toks = String(cmd).trim().split(/\s+/).map(dequote);
|
|
70
157
|
let i = 0;
|
|
71
158
|
while (i < toks.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i])) i++; // env prefix
|
|
72
159
|
if (i >= toks.length || !/(^|\/)gh$/.test(toks[i])) return null; // the gh binary
|
|
@@ -75,14 +162,9 @@ function parseGhSubcommand(cmd) {
|
|
|
75
162
|
// Primary positional walk. -R/--repo (and any non-boolean, non-glued flag)
|
|
76
163
|
// is treated as value-taking: consume one following non-flag token so an
|
|
77
164
|
// unknown two-token global flag's value cannot shift the subcommand (#153).
|
|
78
|
-
|
|
79
|
-
const flag = toks[i];
|
|
80
|
-
i++; // the flag token itself
|
|
81
|
-
if (flag.includes('=') || BOOLEAN_GLOBAL_FLAGS.has(flag)) continue; // no separate value
|
|
82
|
-
if (i < toks.length && !toks[i].startsWith('-')) i++; // consume its value
|
|
83
|
-
}
|
|
165
|
+
i = skipFlags(toks, i);
|
|
84
166
|
let sub = toks[i];
|
|
85
|
-
let verb = toks[i + 1];
|
|
167
|
+
let verb = toks[skipFlags(toks, i + 1)];
|
|
86
168
|
// Fail-safe fallback: if the walk did NOT land on a recognized write
|
|
87
169
|
// subcommand, an unknown *boolean* flag before the subcommand may have
|
|
88
170
|
// consumed the subcommand itself as if it were the flag's value (the mirror
|
|
@@ -93,10 +175,10 @@ function parseGhSubcommand(cmd) {
|
|
|
93
175
|
// non-write command does not become a spurious write.
|
|
94
176
|
if (!WRITE_SUBCOMMANDS.has(sub)) {
|
|
95
177
|
for (let j = start; j < toks.length; j++) {
|
|
96
|
-
if (WRITE_SUBCOMMANDS.has(toks[j])) { sub = toks[j]; verb = toks[j + 1]; break; }
|
|
178
|
+
if (WRITE_SUBCOMMANDS.has(toks[j])) { sub = toks[j]; verb = toks[skipFlags(toks, j + 1)]; break; }
|
|
97
179
|
}
|
|
98
180
|
}
|
|
99
181
|
return { sub, verb };
|
|
100
182
|
}
|
|
101
183
|
|
|
102
|
-
module.exports = { parseGhSubcommand, BOOLEAN_GLOBAL_FLAGS, WRITE_SUBCOMMANDS };
|
|
184
|
+
module.exports = { parseGhSubcommand, dequote, dequoteSpans, BOOLEAN_GLOBAL_FLAGS, WRITE_SUBCOMMANDS };
|