@marcomigozzi/trailhead 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.it.md +158 -34
- package/README.md +147 -33
- package/bin/auto-intro-docs.test.js +72 -0
- package/bin/auto-merge-docs.test.js +33 -0
- package/bin/auto-merge-ticket-docs.test.js +32 -0
- package/bin/auto-review-rounds-docs.test.js +45 -0
- package/bin/auto-run-docs.test.js +60 -0
- package/bin/auto-scope-docs.test.js +33 -0
- package/bin/auto-slots-docs.test.js +45 -0
- package/bin/auto-uat-docs.test.js +47 -0
- package/bin/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -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 +6 -6
- package/bin/lib/codex-projection.test.js +43 -2
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -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 +6 -6
- package/bin/trailhead.test.js +355 -16
- package/bin/uat-setup-docs.test.js +65 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- 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/auto.md +2 -2
- package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
- package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
- package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
- package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
- package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -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 +472 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -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/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
- package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
- package/plugins/trailhead/hooks/mod/README.md +209 -36
- package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
- package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
- package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
- package/plugins/trailhead/hooks/mod/register.tsx +312 -40
- package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
- package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
- package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
- package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
- package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
- package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
- package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
- package/plugins/trailhead/skills/_shared/choices.md +11 -4
- package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
- package/plugins/trailhead/skills/_shared/load-first.md +3 -3
- package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
- package/plugins/trailhead/skills/_shared/substrate.md +9 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
- 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/grilling.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -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 +1 -1
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- 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 +9 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
- package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
- package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
- package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
- package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
- package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
- package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -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 +360 -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-merge-ticket.js +366 -0
- package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -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-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
- package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
- package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
- package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
- package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
- package/plugins/trailhead/tests/session-registry.test.ts +50 -0
- package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
- package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
- package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
- package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
- package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- package/plugins/trailhead/types/index.d.ts +62 -3
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for templates/trailhead-report-status.js. Run: node report-status.test.js
|
|
3
|
+
// No framework: plain asserts, same style as work-state.test.js.
|
|
4
|
+
//
|
|
5
|
+
// Layers covered:
|
|
6
|
+
// 1. checkReport() as a table, for the review techniques (#250).
|
|
7
|
+
// 2. The same for the field families: executor, fix, plan, debug, research,
|
|
8
|
+
// codebase-map (#249): fields, body, the fields problem.
|
|
9
|
+
// 3. The vocabulary equals the tokens the technique files define, and the field
|
|
10
|
+
// contracts equal what scripts/agent-blocks.md states.
|
|
11
|
+
// 4. The CLI: --file, stdin, one JSON line, exit codes.
|
|
12
|
+
const assert = require('assert');
|
|
13
|
+
const { spawnSync } = require('child_process');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
|
|
18
|
+
const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-report-status.js');
|
|
19
|
+
const { checkReport, TOKENS, CONTRACTS, FAMILY_OF } = require(scriptPath);
|
|
20
|
+
const techDir = path.resolve(__dirname, '..', '..', 'skills', '_shared', 'techniques');
|
|
21
|
+
|
|
22
|
+
let passed = 0;
|
|
23
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
24
|
+
|
|
25
|
+
const K = 'critical: 0 · warning: 1 · info: 2'; // the counts line
|
|
26
|
+
const V = 'verify';
|
|
27
|
+
const C = 'code-review';
|
|
28
|
+
const KEYS = ['action', 'verdict', 'problem', 'report', 'fields', 'body'];
|
|
29
|
+
|
|
30
|
+
// [name, technique, input, expected]; expected = [action, verdict, problem] and,
|
|
31
|
+
// for a normalise row, the exact normalised report as a fourth element.
|
|
32
|
+
const rows = [
|
|
33
|
+
// --- accept ---
|
|
34
|
+
['verify met, exact', V, 'status: met\nbody\n', ['accept', 'met', null]],
|
|
35
|
+
['verify gaps_found, exact', V, 'status: gaps_found\n- claim 2 unmet\n', ['accept', 'gaps_found', null]],
|
|
36
|
+
['verify inconclusive, exact', V, 'status: inconclusive\nreason: unrunnable-check\n', ['accept', 'inconclusive', null]],
|
|
37
|
+
['verify status only, no newline', V, 'status: met', ['accept', 'met', null]],
|
|
38
|
+
['verify ignores a counts-looking line', V, `status: met\n${K}\nbody`, ['accept', 'met', null]],
|
|
39
|
+
['verify does not need a counts line', V, 'status: met\nbody', ['accept', 'met', null]],
|
|
40
|
+
['code-review clean with counts on line 2', C, `status: clean\n${K}\nledger\n`, ['accept', 'clean', null]],
|
|
41
|
+
['code-review issues_found with counts on line 2', C, `status: issues_found\n${K}\nC1 open\n`, ['accept', 'issues_found', null]],
|
|
42
|
+
|
|
43
|
+
// --- normalise: the same single token in another layout ---
|
|
44
|
+
['status on line 3 with bold decoration and upper case token', V, 'Intro\n\n**Status:** GAPS_FOUND\nbody',
|
|
45
|
+
['normalise', 'gaps_found', null, 'status: gaps_found\nIntro\n\nbody']],
|
|
46
|
+
['heading decoration', V, '## Status: met\nbody', ['normalise', 'met', null, 'status: met\nbody']],
|
|
47
|
+
['list bullet', V, '- status: met\nbody', ['normalise', 'met', null, 'status: met\nbody']],
|
|
48
|
+
['star bullet and bold', V, '* **status**: met\nbody', ['normalise', 'met', null, 'status: met\nbody']],
|
|
49
|
+
['backtick decoration', V, '`status: met`\nbody', ['normalise', 'met', null, 'status: met\nbody']],
|
|
50
|
+
['underscore decoration', V, '__status__: met\nbody', ['normalise', 'met', null, 'status: met\nbody']],
|
|
51
|
+
['capitalised label and token', V, 'Status: Met\nbody', ['normalise', 'met', null, 'status: met\nbody']],
|
|
52
|
+
['hyphen for underscore', V, 'status: gaps-found\nbody', ['normalise', 'gaps_found', null, 'status: gaps_found\nbody']],
|
|
53
|
+
['space for underscore', C, `status: issues found\n${K}\nbody`, ['normalise', 'issues_found', null, `status: issues_found\n${K}\nbody`]],
|
|
54
|
+
['space before the colon', V, 'status : met\nbody', ['normalise', 'met', null, 'status: met\nbody']],
|
|
55
|
+
['two-space indent', V, ' status: met\nbody', ['normalise', 'met', null, 'status: met\nbody']],
|
|
56
|
+
['trailing spaces', V, 'status: met \nbody', ['normalise', 'met', null, 'status: met\nbody']],
|
|
57
|
+
['leading blank lines', V, '\n\nstatus: met\nbody', ['normalise', 'met', null, 'status: met\nbody']],
|
|
58
|
+
['a blank line after the status line is kept', V, 'Status: Met\n\nbody', ['normalise', 'met', null, 'status: met\n\nbody']],
|
|
59
|
+
['CRLF line endings', V, 'status: met\r\nbody\r\n', ['normalise', 'met', null, 'status: met\nbody\n']],
|
|
60
|
+
['lone trailing CR is a line ending', V, 'status: met\r', ['normalise', 'met', null, 'status: met\n']],
|
|
61
|
+
['BOM before an exact status line', V, 'status: met\nbody', ['normalise', 'met', null, 'status: met\nbody']],
|
|
62
|
+
['BOM + exact status: clean + counts on line 2', C, `status: clean\n${K}\nbody`, ['normalise', 'clean', null, `status: clean\n${K}\nbody`]],
|
|
63
|
+
['status not first', V, 'intro\nstatus: met', ['normalise', 'met', null, 'status: met\nintro']],
|
|
64
|
+
['repeated same token keeps one at line 1', V, 'status: met\nbody\nstatus: met', ['normalise', 'met', null, 'status: met\nbody']],
|
|
65
|
+
['repeated same token in different case', V, 'status: met\nbody\nStatus: MET\ntail', ['normalise', 'met', null, 'status: met\nbody\ntail']],
|
|
66
|
+
['status inside a code fence is ignored', V, 'x\n```\nstatus: PASS\n```\nstatus: met',
|
|
67
|
+
['normalise', 'met', null, 'status: met\nx\n```\nstatus: PASS\n```']],
|
|
68
|
+
['status inside a tilde fence is ignored', V, '~~~\nstatus: gaps_found\n~~~\nstatus: met',
|
|
69
|
+
['normalise', 'met', null, 'status: met\n~~~\nstatus: gaps_found\n~~~']],
|
|
70
|
+
['blockquote status is ignored, real one wins', V, '> status: gaps_found\nstatus: met',
|
|
71
|
+
['normalise', 'met', null, 'status: met\n> status: gaps_found']],
|
|
72
|
+
['indented status is ignored, unindented valid one wins', V, ' status: PASS\nstatus: met',
|
|
73
|
+
['normalise', 'met', null, 'status: met\n status: PASS']],
|
|
74
|
+
['tab-indented status is ignored, unindented valid one wins', V, '\tstatus: PASS\nstatus: met',
|
|
75
|
+
['normalise', 'met', null, 'status: met\n\tstatus: PASS']],
|
|
76
|
+
['code-review heading between status and counts', C, `status: clean\n## Findings\n${K}\nbody`,
|
|
77
|
+
['normalise', 'clean', null, `status: clean\n${K}\n## Findings\nbody`]],
|
|
78
|
+
['code-review counts further down', C, `status: clean\nledger\n${K}`, ['normalise', 'clean', null, `status: clean\n${K}\nledger`]],
|
|
79
|
+
['code-review counts with decoration on line 2', C, `status: clean\n**${K}**\nbody`, ['normalise', 'clean', null, `status: clean\n${K}\nbody`]],
|
|
80
|
+
['code-review both lines out of place', C, `ledger\n${K}\n**Status:** ISSUES_FOUND\nbody`,
|
|
81
|
+
['normalise', 'issues_found', null, `status: issues_found\n${K}\nledger\nbody`]],
|
|
82
|
+
['verify leaves a counts-looking line where it is', V, `intro\n${K}\nStatus: met`, ['normalise', 'met', null, `status: met\nintro\n${K}`]],
|
|
83
|
+
|
|
84
|
+
// --- reask: missing ---
|
|
85
|
+
['empty', V, '', ['reask', null, 'missing']],
|
|
86
|
+
['whitespace only', V, ' \n\t\n\n', ['reask', null, 'missing']],
|
|
87
|
+
['no status line', V, 'The change looks fine.\n', ['reask', null, 'missing']],
|
|
88
|
+
['status only inside a blockquote', V, '> status: met\nbody', ['reask', null, 'missing']],
|
|
89
|
+
['status only inside a code fence', V, '```\nstatus: met\n```\n', ['reask', null, 'missing']],
|
|
90
|
+
['status only in an unterminated fence', V, 'text\n```\nstatus: met\n', ['reask', null, 'missing']],
|
|
91
|
+
['only candidate indented 4 spaces', V, ' status: met\n', ['reask', null, 'missing']],
|
|
92
|
+
['only candidate indented by a tab', V, '\tstatus: met\n', ['reask', null, 'missing']],
|
|
93
|
+
['status mid-sentence is not a status line', V, 'Final status: met\n', ['reask', null, 'missing']],
|
|
94
|
+
['code-review with no status', C, `${K}\nbody`, ['reask', null, 'missing']],
|
|
95
|
+
|
|
96
|
+
// --- reask: unknown-token ---
|
|
97
|
+
['PASS', V, 'status: PASS\n', ['reask', null, 'unknown-token']],
|
|
98
|
+
['OK', V, 'status: OK\n', ['reask', null, 'unknown-token']],
|
|
99
|
+
['LGTM', C, `status: LGTM\n${K}\n`, ['reask', null, 'unknown-token']],
|
|
100
|
+
['FAIL', V, 'status: FAIL\n', ['reask', null, 'unknown-token']],
|
|
101
|
+
['done', V, 'status: done\n', ['reask', null, 'unknown-token']],
|
|
102
|
+
['empty token', V, 'status:\nbody', ['reask', null, 'unknown-token']],
|
|
103
|
+
['the other technique token (verify gets clean)', V, 'status: clean\n', ['reask', null, 'unknown-token']],
|
|
104
|
+
['the other technique token (code-review gets met)', C, `status: met\n${K}\n`, ['reask', null, 'unknown-token']],
|
|
105
|
+
['token with a trailing remark', V, 'status: met (5 claims)\n', ['reask', null, 'unknown-token']],
|
|
106
|
+
['two different invalid tokens', V, 'status: PASS\nstatus: OK', ['reask', null, 'unknown-token']],
|
|
107
|
+
['bad token and no counts line', C, 'status: LGTM\nbody', ['reask', null, 'unknown-token']],
|
|
108
|
+
|
|
109
|
+
// --- reask: conflicting ---
|
|
110
|
+
['two different valid tokens', V, 'status: met\nstatus: gaps_found\n', ['reask', null, 'conflicting']],
|
|
111
|
+
['valid next to invalid', V, 'status: met\nstatus: PASS\n', ['reask', null, 'conflicting']],
|
|
112
|
+
['invalid first, then valid', V, 'status: PASS\nbody\nstatus: met\n', ['reask', null, 'conflicting']],
|
|
113
|
+
['code-review differing tokens', C, `status: clean\n${K}\nstatus: issues_found\n`, ['reask', null, 'conflicting']],
|
|
114
|
+
['conflicting outranks a missing counts line', C, 'status: clean\nstatus: issues_found\n', ['reask', null, 'conflicting']],
|
|
115
|
+
|
|
116
|
+
// --- reask: counts (code-review only) ---
|
|
117
|
+
['counts line missing', C, 'status: clean\nbody\n', ['reask', null, 'counts']],
|
|
118
|
+
['counts line malformed', C, 'status: clean\ncritical: x · warning: 0 · info: 0\n', ['reask', null, 'counts']],
|
|
119
|
+
['two counts lines', C, `status: clean\n${K}\nbody\n${K}\n`, ['reask', null, 'counts']],
|
|
120
|
+
['two different counts lines', C, `status: clean\n${K}\ncritical: 1 · warning: 1 · info: 2\n`, ['reask', null, 'counts']],
|
|
121
|
+
['counts only inside a code fence', C, `status: clean\n\`\`\`\n${K}\n\`\`\`\n`, ['reask', null, 'counts']],
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
for (const [name, technique, input, expected] of rows) {
|
|
125
|
+
const r = checkReport(input, technique);
|
|
126
|
+
const [action, verdict, problem, normalised] = expected;
|
|
127
|
+
ok(`${technique} / ${name}: exactly the six keys`, JSON.stringify(Object.keys(r)) === JSON.stringify(KEYS));
|
|
128
|
+
ok(`${technique} / ${name}: fields ${r.action === 'reask' ? 'null on reask' : '{} for a review'}`, r.action === 'reask' ? r.fields === null : JSON.stringify(r.fields) === '{}');
|
|
129
|
+
ok(`${technique} / ${name}: body is null`, r.body === null);
|
|
130
|
+
ok(`${technique} / ${name}: action ${action}`, r.action === action);
|
|
131
|
+
ok(`${technique} / ${name}: verdict ${verdict}`, r.verdict === verdict);
|
|
132
|
+
ok(`${technique} / ${name}: problem ${problem}`, r.problem === problem);
|
|
133
|
+
ok(`${technique} / ${name}: problem is null iff not a reask`, (r.problem === null) === (r.action !== 'reask'));
|
|
134
|
+
if (action === 'normalise') {
|
|
135
|
+
ok(`${technique} / ${name}: normalised report`, r.report === normalised);
|
|
136
|
+
const again = checkReport(r.report, technique);
|
|
137
|
+
ok(`${technique} / ${name}: the normalised report is accepted as is`, again.action === 'accept' && again.verdict === verdict && again.report === r.report);
|
|
138
|
+
} else {
|
|
139
|
+
ok(`${technique} / ${name}: report is the original text`, r.report === input);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// --- the field families (#249) ---------------------------------------------------
|
|
144
|
+
const EX = 'executor';
|
|
145
|
+
const fullImpl = (status, blockers, extra = '') =>
|
|
146
|
+
`status: ${status}\ncommits:\nabc1234 feat: x\ndef5678 test: y\ntests: npm test, green\ndeviations: none\nblockers: ${blockers}\n${extra}`;
|
|
147
|
+
// [name, technique, input, expected]; expected = [action, verdict, problem, fields-or-null, body, report-for-normalise]
|
|
148
|
+
const frows = [
|
|
149
|
+
// --- accept ---
|
|
150
|
+
['done, fields parsed', EX, fullImpl('done', 'none'), ['accept', 'done', null,
|
|
151
|
+
{ commits: 'abc1234 feat: x\ndef5678 test: y', tests: 'npm test, green', deviations: 'none', blockers: 'none' }, null]],
|
|
152
|
+
['fix uses the implementing contract', 'fix', fullImpl('done', 'none'), ['accept', 'done', null, null, null]],
|
|
153
|
+
['done with blockers: empty', EX, 'status: done\ncommits: a b\ntests: t\ndeviations:\nblockers:\n', ['accept', 'done', null,
|
|
154
|
+
{ commits: 'a b', tests: 't', deviations: '', blockers: '' }, null]],
|
|
155
|
+
['partial with a blocker', EX, fullImpl('partial', 'infeasible: the gradient header'), ['accept', 'partial', null, null, null]],
|
|
156
|
+
['blocked, multi-line blockers', EX, fullImpl('blocked', '\n- pkg-x: `npm i pkg-x`, first seen yesterday\n- foreign change in a.js'), ['accept', 'blocked', null, null, null]],
|
|
157
|
+
['optional fields are parsed', EX, fullImpl('done', 'none', 'fidelity:\n- header -> reproduced\nsurfaced scope: a follow-up\n'), ['accept', 'done', null, null, null]],
|
|
158
|
+
['investigating found', 'research', 'status: found\nanswer: use X\nevidence: https://a.example/doc\nopen questions: none\n', ['accept', 'found', null,
|
|
159
|
+
{ answer: 'use X', evidence: 'https://a.example/doc', 'open questions': 'none' }, null]],
|
|
160
|
+
['debug inconclusive keeps open questions', 'debug', 'status: inconclusive\nanswer: not yet\nevidence: a.js:3\nopen questions: which env?\n', ['accept', 'inconclusive', null, null, null]],
|
|
161
|
+
['codebase-map needs_input with questions', 'codebase-map', 'status: needs_input\nanswer: partial\nevidence: a.js:1\nopen questions: which root?\n', ['accept', 'needs_input', null, null, null]],
|
|
162
|
+
['plan ready, body is the rest', 'plan', 'status: ready\n\n## Steps\n1. a\n2. b\n\nVerification: x\n', ['accept', 'ready', null, {}, '## Steps\n1. a\n2. b\n\nVerification: x']],
|
|
163
|
+
['plan needs_input', 'plan', 'status: needs_input\nquestions:\n1. Which db?\n2. Keep v1?\n', ['accept', 'needs_input', null, { questions: '1. Which db?\n2. Keep v1?' }, null]],
|
|
164
|
+
// --- normalise: layout, fields intact ---
|
|
165
|
+
['bold label and token, bullets', EX, '**Status:** DONE\n- **commits:** abc1234 feat: x\n- tests: ok\n- deviations: none\n- blockers: none\n', ['normalise', 'done', null,
|
|
166
|
+
{ commits: 'abc1234 feat: x', tests: 'ok', deviations: 'none', blockers: 'none' }, null,
|
|
167
|
+
'status: done\n- **commits:** abc1234 feat: x\n- tests: ok\n- deviations: none\n- blockers: none\n']],
|
|
168
|
+
['labels in any case and with spaces', 'debug', 'Intro\nStatus: Found\nAnswer: a\nEVIDENCE: e\nOpen Questions: none', ['normalise', 'found', null,
|
|
169
|
+
{ answer: 'a', evidence: 'e', 'open questions': 'none' }, null, 'status: found\nIntro\nAnswer: a\nEVIDENCE: e\nOpen Questions: none']],
|
|
170
|
+
['plan ready, status not first: body without the status line', 'plan', 'Here is the plan.\n\nstatus: ready\nStep 1\n', ['normalise', 'ready', null, {}, 'Here is the plan.\n\nStep 1',
|
|
171
|
+
'status: ready\nHere is the plan.\n\nStep 1\n']],
|
|
172
|
+
['a label inside a code fence is not a field', EX, 'status: done\ncommits: a b\ntests: t\ndeviations: none\n```\nblockers: x\n```\nblockers: none\n', ['accept', 'done', null,
|
|
173
|
+
{ commits: 'a b', tests: 't', deviations: 'none\n```\nblockers: x\n```', blockers: 'none' }, null]],
|
|
174
|
+
['a label in a blockquote is not a field', EX, 'status: done\ncommits: a\ntests: t\ndeviations: none\n> blockers: x\nblockers: none\n', ['accept', 'done', null, null, null]],
|
|
175
|
+
['a label that is not in the closed list is part of the value', EX, 'status: done\ncommits: a\nnote: x\ntests: t\ndeviations: none\nblockers: none\n', ['accept', 'done', null,
|
|
176
|
+
{ commits: 'a\nnote: x', tests: 't', deviations: 'none', blockers: 'none' }, null]],
|
|
177
|
+
|
|
178
|
+
// --- reask: the status problems keep precedence over fields ---
|
|
179
|
+
['missing status', EX, 'commits: a\ntests: t\ndeviations: none\nblockers: none\n', ['reask', null, 'missing']],
|
|
180
|
+
['conflicting outranks fields', EX, 'status: done\nstatus: blocked\nblockers: none\n', ['reask', null, 'conflicting']],
|
|
181
|
+
['unknown token outranks fields', EX, 'status: met\ncommits: a\n', ['reask', null, 'unknown-token']],
|
|
182
|
+
['a review token is unknown for a field technique', 'plan', 'status: met\n', ['reask', null, 'unknown-token']],
|
|
183
|
+
['an implementing token is unknown for investigating', 'research', 'status: done\n', ['reask', null, 'unknown-token']],
|
|
184
|
+
// --- reask: fields ---
|
|
185
|
+
['missing required field (tests)', EX, 'status: done\ncommits: a\ndeviations: none\nblockers: none\n', ['reask', null, 'fields']],
|
|
186
|
+
['no fields at all', 'debug', 'status: found\nThe cause is X.\n', ['reask', null, 'fields']],
|
|
187
|
+
['done with blockers', EX, fullImpl('done', 'a flagged package'), ['reask', null, 'fields']],
|
|
188
|
+
['partial without blockers', EX, fullImpl('partial', 'none'), ['reask', null, 'fields']],
|
|
189
|
+
['blocked with an empty blockers', EX, fullImpl('blocked', ''), ['reask', null, 'fields']],
|
|
190
|
+
['needs_input without open questions', 'research', 'status: needs_input\nanswer: a\nevidence: e\nopen questions: none\n', ['reask', null, 'fields']],
|
|
191
|
+
['needs_input with an empty open questions', 'debug', 'status: needs_input\nanswer: a\nevidence: e\nopen questions:\n', ['reask', null, 'fields']],
|
|
192
|
+
['plan needs_input without questions', 'plan', 'status: needs_input\nsome prose\n', ['reask', null, 'fields']],
|
|
193
|
+
['plan needs_input with none questions', 'plan', 'status: needs_input\nquestions: none\n', ['reask', null, 'fields']],
|
|
194
|
+
['plan ready with an empty body', 'plan', 'status: ready\n\n \n', ['reask', null, 'fields']],
|
|
195
|
+
['plan ready with only a status line', 'plan', 'status: ready', ['reask', null, 'fields']],
|
|
196
|
+
['a fenced status line inside a plan body is part of the body', 'plan', 'status: ready\n\nA feature about statuses:\n```\nstatus: pending\n```\nDone.\n', ['accept', 'ready', null, {},
|
|
197
|
+
'A feature about statuses:\n```\nstatus: pending\n```\nDone.']],
|
|
198
|
+
['a blockquoted status line inside an answer is ignored', 'research', 'status: found\nanswer: see\n> status: pending\nevidence: e\nopen questions: none\n', ['accept', 'found', null, null, null]],
|
|
199
|
+
['prose after the last field becomes its value, so a done report reasks', EX, fullImpl('done', 'none', 'Note: hi\n'), ['reask', null, 'fields']],
|
|
200
|
+
['the same label twice', EX, fullImpl('done', 'none', 'tests: again\n'), ['reask', null, 'fields']],
|
|
201
|
+
];
|
|
202
|
+
for (const [name, technique, input, expected] of frows) {
|
|
203
|
+
const r = checkReport(input, technique);
|
|
204
|
+
const [action, verdict, problem, fields, body, normalised] = expected;
|
|
205
|
+
const t = `${technique} / ${name}`;
|
|
206
|
+
ok(`${t}: exactly the six keys`, JSON.stringify(Object.keys(r)) === JSON.stringify(KEYS));
|
|
207
|
+
ok(`${t}: action ${action}`, r.action === action);
|
|
208
|
+
ok(`${t}: verdict ${verdict}`, r.verdict === verdict);
|
|
209
|
+
ok(`${t}: problem ${problem}`, r.problem === problem);
|
|
210
|
+
if (action === 'reask') {
|
|
211
|
+
ok(`${t}: fields and body are null`, r.fields === null && r.body === null);
|
|
212
|
+
ok(`${t}: report is the original text`, r.report === input);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (fields) ok(`${t}: fields`, JSON.stringify(r.fields) === JSON.stringify(fields));
|
|
216
|
+
else ok(`${t}: fields is an object`, r.fields !== null && typeof r.fields === 'object');
|
|
217
|
+
if (body !== undefined && (technique === 'plan' || body !== null)) ok(`${t}: body`, r.body === body);
|
|
218
|
+
if (action === 'normalise') {
|
|
219
|
+
ok(`${t}: normalised report`, r.report === normalised);
|
|
220
|
+
const again = checkReport(r.report, technique);
|
|
221
|
+
ok(`${t}: the normalised report is accepted as is, with the same fields and body`,
|
|
222
|
+
again.action === 'accept' && again.report === r.report && JSON.stringify(again.fields) === JSON.stringify(r.fields) && again.body === r.body);
|
|
223
|
+
} else {
|
|
224
|
+
ok(`${t}: report is the original text`, r.report === input);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
{
|
|
228
|
+
const r = checkReport(fullImpl('done', 'none', 'fidelity:\n- header -> reproduced\nsurfaced scope: a follow-up\n'), EX);
|
|
229
|
+
ok('optional fidelity is multi-line', r.fields.fidelity === '- header -> reproduced');
|
|
230
|
+
ok('optional surfaced scope is read', r.fields['surfaced scope'] === 'a follow-up');
|
|
231
|
+
const b = checkReport(fullImpl('blocked', '\n- pkg-x: `npm i pkg-x`, first seen yesterday\n- foreign change in a.js'), EX);
|
|
232
|
+
ok('multi-line blockers keep every entry', b.fields.blockers === '- pkg-x: `npm i pkg-x`, first seen yesterday\n- foreign change in a.js');
|
|
233
|
+
ok('a report with an unterminated fence still parses its leading fields', checkReport('status: found\nanswer: a\nevidence: e\nopen questions: none\n```\nx', 'research').action === 'accept');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// --- misuse ---------------------------------------------------------------------
|
|
237
|
+
ok('checkReport: unknown technique throws', (() => { try { checkReport('status: met', 'bogus'); } catch (e) { return true; } return false; })());
|
|
238
|
+
ok('checkReport: missing technique throws', (() => { try { checkReport('status: met'); } catch (e) { return true; } return false; })());
|
|
239
|
+
ok('checkReport: a non-string report is treated as empty', checkReport(undefined, V).problem === 'missing');
|
|
240
|
+
|
|
241
|
+
// --- vocabulary equals the technique files --------------------------------------
|
|
242
|
+
const techniqueTokens = (file) => {
|
|
243
|
+
const text = fs.readFileSync(path.join(techDir, file), 'utf8');
|
|
244
|
+
return [...new Set([...text.matchAll(/`status: (\w+)`/g)].map((m) => m[1]))].sort();
|
|
245
|
+
};
|
|
246
|
+
ok('vocabulary: verify tokens equal the ones verify.md defines',
|
|
247
|
+
JSON.stringify([...TOKENS.verify].sort()) === JSON.stringify(techniqueTokens('verify.md')) && TOKENS.verify.length === 3);
|
|
248
|
+
ok('vocabulary: code-review tokens equal the ones code-review.md defines',
|
|
249
|
+
JSON.stringify([...TOKENS['code-review']].sort()) === JSON.stringify(techniqueTokens('code-review.md')) && TOKENS['code-review'].length === 2);
|
|
250
|
+
|
|
251
|
+
// --- the field contracts equal scripts/agent-blocks.md (#249) -----------------------
|
|
252
|
+
{
|
|
253
|
+
const { parseSource } = require(path.resolve(__dirname, '..', '..', '..', '..', 'scripts', 'sync-agent-blocks.js'));
|
|
254
|
+
const blocks = parseSource(fs.readFileSync(path.resolve(__dirname, '..', '..', '..', '..', 'scripts', 'agent-blocks.md'), 'utf8'));
|
|
255
|
+
const words = (block, prefix) => {
|
|
256
|
+
const line = block.split('\n').find((l) => l.startsWith(`- ${prefix}:`)) || '';
|
|
257
|
+
return [...line.matchAll(/`([^`]+)`/g)].map((m) => m[1]);
|
|
258
|
+
};
|
|
259
|
+
// `- invariant: `a`, `b` => `field` non-empty.` -> { when, field, rule }
|
|
260
|
+
const invariants = (block) => block.split('\n').filter((l) => l.startsWith('- invariant:')).map((l) => {
|
|
261
|
+
const [lhs, rhs] = l.split('=>');
|
|
262
|
+
return { when: [...lhs.matchAll(/`([^`]+)`/g)].map((m) => m[1]), field: /`([^`]+)`/.exec(rhs)[1], rule: /(non-empty|none)\.$/.exec(rhs)[1] };
|
|
263
|
+
});
|
|
264
|
+
for (const family of ['implementing', 'investigating', 'plan']) {
|
|
265
|
+
const block = blocks[family];
|
|
266
|
+
ok(`contracts: source defines the ${family} block`, typeof block === 'string');
|
|
267
|
+
ok(`contracts: ${family} tokens equal the source`, JSON.stringify(CONTRACTS[family].tokens) === JSON.stringify(words(block, 'statuses')));
|
|
268
|
+
ok(`contracts: ${family} required fields equal the source`, JSON.stringify(CONTRACTS[family].required) === JSON.stringify(words(block, 'required fields')));
|
|
269
|
+
ok(`contracts: ${family} optional fields equal the source`, JSON.stringify(CONTRACTS[family].optional) === JSON.stringify(words(block, 'optional fields')));
|
|
270
|
+
ok(`contracts: ${family} invariants equal the source`, JSON.stringify(CONTRACTS[family].invariants) === JSON.stringify(invariants(block)) && invariants(block).length > 0);
|
|
271
|
+
}
|
|
272
|
+
const { FAMILIES } = require(path.resolve(__dirname, '..', '..', '..', '..', 'scripts', 'sync-agent-blocks.js'));
|
|
273
|
+
for (const [technique, family] of Object.entries(FAMILY_OF)) {
|
|
274
|
+
ok(`contracts: technique ${technique} maps to the family of trailhead-${technique} in the sync script`, FAMILIES[`trailhead-${technique}`] === family);
|
|
275
|
+
ok(`contracts: TOKENS.${technique} are the ${family} tokens`, JSON.stringify(TOKENS[technique]) === JSON.stringify(CONTRACTS[family].tokens));
|
|
276
|
+
}
|
|
277
|
+
ok('contracts: every non-review agent has a technique here',
|
|
278
|
+
Object.entries(FAMILIES).filter(([, f]) => f !== 'reviewing').every(([a]) => FAMILY_OF[a.replace(/^trailhead-/, '')] !== undefined));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// --- CLI -------------------------------------------------------------------------
|
|
282
|
+
function cli(args, input) {
|
|
283
|
+
const r = spawnSync(process.execPath, [scriptPath, ...args], { input, encoding: 'utf8' });
|
|
284
|
+
return { code: r.status, out: r.stdout, err: r.stderr };
|
|
285
|
+
}
|
|
286
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-report-status-test-'));
|
|
287
|
+
const file = (name, text) => { const p = path.join(tmp, name); fs.writeFileSync(p, text); return p; };
|
|
288
|
+
const oneLine = (out) => { const lines = out.split('\n').filter(Boolean); return out.endsWith('\n') && lines.length === 1 ? JSON.parse(lines[0]) : null; };
|
|
289
|
+
|
|
290
|
+
{
|
|
291
|
+
const r = cli(['--technique', 'verify', '--file', file('a.md', 'status: met\nbody\n')]);
|
|
292
|
+
const j = oneLine(r.out);
|
|
293
|
+
ok('cli --file accept: exit 0', r.code === 0);
|
|
294
|
+
ok('cli --file accept: one JSON line with the six keys', !!j && JSON.stringify(Object.keys(j)) === JSON.stringify(KEYS));
|
|
295
|
+
ok('cli --file accept: values', j.action === 'accept' && j.verdict === 'met' && j.problem === null && j.report === 'status: met\nbody\n');
|
|
296
|
+
ok('cli --file accept: fields {} and body null for a review', JSON.stringify(j.fields) === '{}' && j.body === null);
|
|
297
|
+
}
|
|
298
|
+
{
|
|
299
|
+
const r = cli(['--technique', 'plan', '--file', file('p.md', 'status: ready\n\nStep 1\n')]);
|
|
300
|
+
const j = oneLine(r.out);
|
|
301
|
+
ok('cli plan ready: exit 0, body is the plan', r.code === 0 && j.action === 'accept' && j.body === 'Step 1');
|
|
302
|
+
const e = cli(['--technique', 'executor'], fullImpl('done', 'none'));
|
|
303
|
+
const je = oneLine(e.out);
|
|
304
|
+
ok('cli executor stdin: fields returned', e.code === 0 && je.action === 'accept' && je.fields.tests === 'npm test, green');
|
|
305
|
+
const f = cli(['--technique', 'research'], 'status: needs_input\nanswer: a\nevidence: e\n');
|
|
306
|
+
const jf = oneLine(f.out);
|
|
307
|
+
ok('cli research: a fields reask, exit 0', f.code === 0 && jf.action === 'reask' && jf.problem === 'fields' && jf.fields === null);
|
|
308
|
+
}
|
|
309
|
+
{
|
|
310
|
+
const r = cli(['--technique', 'verify', '--file', file('b.md', '**Status:** GAPS_FOUND\r\nbody\r\n')]);
|
|
311
|
+
const j = oneLine(r.out);
|
|
312
|
+
ok('cli --file normalise: exit 0', r.code === 0);
|
|
313
|
+
ok('cli --file normalise: values', j.action === 'normalise' && j.verdict === 'gaps_found' && j.report === 'status: gaps_found\nbody\n');
|
|
314
|
+
}
|
|
315
|
+
{
|
|
316
|
+
const r = cli(['--technique', 'verify', '--file', file('c.md', 'status: PASS\n')]);
|
|
317
|
+
const j = oneLine(r.out);
|
|
318
|
+
ok('cli --file reask: exit 0', r.code === 0);
|
|
319
|
+
ok('cli --file reask: values', j.action === 'reask' && j.verdict === null && j.problem === 'unknown-token' && j.report === 'status: PASS\n');
|
|
320
|
+
}
|
|
321
|
+
{
|
|
322
|
+
const r = cli(['--technique', 'code-review'], `status: clean\n${K}\nbody\n`);
|
|
323
|
+
const j = oneLine(r.out);
|
|
324
|
+
ok('cli stdin: exit 0', r.code === 0);
|
|
325
|
+
ok('cli stdin: code-review accept', j.action === 'accept' && j.verdict === 'clean' && j.report === `status: clean\n${K}\nbody\n`);
|
|
326
|
+
}
|
|
327
|
+
{
|
|
328
|
+
const r = cli(['--technique', 'code-review'], 'status: clean\nbody\n');
|
|
329
|
+
const j = oneLine(r.out);
|
|
330
|
+
ok('cli stdin: counts reask', r.code === 0 && j.action === 'reask' && j.problem === 'counts');
|
|
331
|
+
}
|
|
332
|
+
{
|
|
333
|
+
const r = cli(['--technique', 'verify'], '');
|
|
334
|
+
const j = oneLine(r.out);
|
|
335
|
+
ok('cli stdin: empty input is a missing reask, exit 0', r.code === 0 && j.action === 'reask' && j.problem === 'missing');
|
|
336
|
+
}
|
|
337
|
+
{
|
|
338
|
+
// A report over the 64 KB pipe buffer must come back whole through a pipe.
|
|
339
|
+
const big = 'status: met\n' + 'x'.repeat(300000) + '\n';
|
|
340
|
+
const r = cli(['--technique', 'verify'], big);
|
|
341
|
+
const j = oneLine(r.out);
|
|
342
|
+
ok('cli: a report over 64 KB arrives whole through a pipe', r.code === 0 && !!j && j.action === 'accept' && j.report === big);
|
|
343
|
+
const f = cli(['--technique', 'verify', '--file', file('big.md', big)]);
|
|
344
|
+
const jf = oneLine(f.out);
|
|
345
|
+
ok('cli --file: a report over 64 KB arrives whole through a pipe', f.code === 0 && !!jf && jf.report === big);
|
|
346
|
+
}
|
|
347
|
+
const usageErrors = [
|
|
348
|
+
['no technique', ['--file', file('d.md', 'status: met')]],
|
|
349
|
+
['unknown technique', ['--technique', 'bogus', '--file', file('e.md', 'status: met')]],
|
|
350
|
+
['technique without a value', ['--technique']],
|
|
351
|
+
['unknown flag', ['--technique', 'verify', '--bogus', 'x', '--file', file('f.md', 'status: met')]],
|
|
352
|
+
['--file without a value', ['--technique', 'verify', '--file']],
|
|
353
|
+
['empty --file value', ['--technique', 'verify', '--file', '']],
|
|
354
|
+
['unreadable file', ['--technique', 'verify', '--file', path.join(tmp, 'nope.md')]],
|
|
355
|
+
];
|
|
356
|
+
for (const [name, args] of usageErrors) {
|
|
357
|
+
const r = cli(args, 'status: met\n');
|
|
358
|
+
let e = null;
|
|
359
|
+
try { e = JSON.parse(r.err.trim()); } catch (_) { /* leave null */ }
|
|
360
|
+
ok(`cli usage error (${name}): exit 2`, r.code === 2);
|
|
361
|
+
ok(`cli usage error (${name}): nothing on stdout`, r.out === '');
|
|
362
|
+
ok(`cli usage error (${name}): {"error": message} on stderr`, !!e && typeof e.error === 'string' && e.error.length > 0);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
366
|
+
console.log(`report-status.test.js: ${passed} checks passed`);
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
// surrounding whitespace, the latter keeps a multi-word quoted phrase as ONE
|
|
13
13
|
// opaque token instead of splitting it into separate words.
|
|
14
14
|
//
|
|
15
|
-
// Consumers:
|
|
15
|
+
// Consumers: several guards and libs (grep `lib/shell-scan`), e.g. the search,
|
|
16
|
+
// secret-read, secret and install guards and lib/unmodelled.js.
|
|
16
17
|
// Self-contained, no external dependencies. Best-effort, like the guards that
|
|
17
18
|
// use it: not a full shell parser, just enough to close their known gaps.
|
|
18
19
|
|
|
@@ -658,7 +659,41 @@ function scanArgsSkippingPattern(args, config, isMatch) {
|
|
|
658
659
|
return null;
|
|
659
660
|
}
|
|
660
661
|
|
|
662
|
+
// --- redirections ----------------------------------------------------------
|
|
663
|
+
|
|
664
|
+
// Redirection token shapes (tokens are raw, so a quoted word never matches).
|
|
665
|
+
const FD_DUP_RE = /^\d*[<>]&(?:\d+|-)$/; // 2>&1 >&2 >&- 0<&3
|
|
666
|
+
const OUT_SEP_RE = /^(?:\d*|&)?>(?:>|&)?\|?$/; // > >> >| 2> &> &>> >& 2>& (target is next token)
|
|
667
|
+
const OUT_GLUED_RE = /^(?:\d*|&)>>?\|?./; // >file 2>/dev/null &>f 2>>f
|
|
668
|
+
const HEREDOC_SEP_RE = /^\d*<<[<-]?$/; // << <<- <<< (tag/word is next token)
|
|
669
|
+
const HEREDOC_GLUED_RE = /^\d*<<./; // <<EOF <<-'EOF' <<<word
|
|
670
|
+
const IN_SEP_RE = /^\d*<$/; // < 0< (target is next token, still a read)
|
|
671
|
+
const IN_GLUED_RE = /^\d*<(.+)$/; // <file 0<file
|
|
672
|
+
|
|
673
|
+
// Return a NEW arg list with redirections removed. Output/dup/heredoc pieces
|
|
674
|
+
// vanish with their targets; an input redirection keeps only its target (a real
|
|
675
|
+
// read). Done before the pattern-skipping walk so a redirection token can never
|
|
676
|
+
// be mistaken for (or swallow) the leading pattern positional of grep/rg/sed/awk.
|
|
677
|
+
function stripRedirections(args) {
|
|
678
|
+
const out = [];
|
|
679
|
+
for (let i = 0; i < args.length; i++) {
|
|
680
|
+
const t = args[i];
|
|
681
|
+
// Separate-operator forms first: `>>` would otherwise also satisfy the glued regex.
|
|
682
|
+
if (OUT_SEP_RE.test(t) || HEREDOC_SEP_RE.test(t)) { i++; continue; }
|
|
683
|
+
if (FD_DUP_RE.test(t) || HEREDOC_GLUED_RE.test(t) || OUT_GLUED_RE.test(t)) continue;
|
|
684
|
+
if (IN_SEP_RE.test(t)) continue; // operator dropped; next token is the read target
|
|
685
|
+
const m = IN_GLUED_RE.exec(t);
|
|
686
|
+
if (m) {
|
|
687
|
+
if (!m[1].startsWith('(')) out.push(m[1]); // `<(cmd)` process substitution: not a file
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
out.push(t);
|
|
691
|
+
}
|
|
692
|
+
return out;
|
|
693
|
+
}
|
|
694
|
+
|
|
661
695
|
module.exports = {
|
|
696
|
+
stripRedirections,
|
|
662
697
|
basename,
|
|
663
698
|
stripQuotes,
|
|
664
699
|
stripHeredocBodies,
|
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
inWordContinuations,
|
|
17
17
|
scanArgsSkippingPattern,
|
|
18
18
|
SEARCH_VERB_CONFIG,
|
|
19
|
+
stripRedirections,
|
|
19
20
|
} = require('./shell-scan.js');
|
|
20
21
|
|
|
21
22
|
let passed = 0;
|
|
@@ -456,6 +457,19 @@ ok('scanArgsSkippingPattern still finds a secret FILE operand after the pattern'
|
|
|
456
457
|
ok('inWordContinuations is empty for plain text', iw('echo hi').length === 0);
|
|
457
458
|
}
|
|
458
459
|
|
|
460
|
+
// --- stripRedirections ---
|
|
461
|
+
{
|
|
462
|
+
const sr = (a) => JSON.stringify(stripRedirections(a));
|
|
463
|
+
ok('stripRedirections drops an fd dup (2>&1)', sr(['x', '2>&1']) === JSON.stringify(['x']));
|
|
464
|
+
ok('stripRedirections drops a glued output (>f)', sr(['x', '>f']) === JSON.stringify(['x']));
|
|
465
|
+
ok('stripRedirections drops a separate output and its target (> f)', sr(['x', '>', 'f', 'y']) === JSON.stringify(['x', 'y']));
|
|
466
|
+
ok('stripRedirections drops a glued append (>>f)', sr(['x', '>>f']) === JSON.stringify(['x']));
|
|
467
|
+
ok('stripRedirections keeps a glued input target (<f)', sr(['x', '<f']) === JSON.stringify(['x', 'f']));
|
|
468
|
+
ok('stripRedirections keeps a separate input target (< f)', sr(['x', '<', 'f']) === JSON.stringify(['x', 'f']));
|
|
469
|
+
ok('stripRedirections drops a glued heredoc (<<EOF)', sr(['x', '<<EOF']) === JSON.stringify(['x']));
|
|
470
|
+
ok('stripRedirections drops a process substitution (<(x))', sr(['x', '<(x)']) === JSON.stringify(['x']));
|
|
471
|
+
}
|
|
472
|
+
|
|
459
473
|
// --- the new hooks leave the existing lexer output untouched ---
|
|
460
474
|
{
|
|
461
475
|
const c = 'a ${ b; } c; d \\\ne | f $(g ${ h; })\ncat <<E\n${ x; }\nE';
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tests for templates/statusline-trailhead.sh (barra contesto, --context-only).
|
|
3
|
+
// Run: node statusline-context.test.js
|
|
4
|
+
// No framework: plain asserts, same style as work-state.test.js.
|
|
5
|
+
const assert = require('assert');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
|
|
11
|
+
const script = path.resolve(__dirname, '..', '..', 'templates', 'statusline-trailhead.sh');
|
|
12
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-statusline-ctx-'));
|
|
13
|
+
const home = path.join(root, 'home');
|
|
14
|
+
const cfg = path.join(root, 'cfg');
|
|
15
|
+
const proj = path.join(root, 'proj');
|
|
16
|
+
for (const d of [home, cfg, path.join(proj, '.claude')]) fs.mkdirSync(d, { recursive: true });
|
|
17
|
+
|
|
18
|
+
const SETTINGS = {
|
|
19
|
+
user: path.join(cfg, 'settings.json'),
|
|
20
|
+
project: path.join(proj, '.claude', 'settings.json'),
|
|
21
|
+
local: path.join(proj, '.claude', 'settings.local.json'),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
let passed = 0;
|
|
25
|
+
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
26
|
+
const eq = (name, got, want) => { assert.strictEqual(got, want, `${name}: got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`); passed++; };
|
|
27
|
+
|
|
28
|
+
function setSettings(files) {
|
|
29
|
+
for (const f of Object.values(SETTINGS)) fs.rmSync(f, { force: true });
|
|
30
|
+
for (const [k, v] of Object.entries(files || {})) {
|
|
31
|
+
fs.writeFileSync(SETTINGS[k], typeof v === 'string' ? v : JSON.stringify(v));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Esegue lo script e ritorna { pct, shown, out }
|
|
36
|
+
function ctx(tok, { env = {}, settings = {}, modelId = 'claude-opus-5-5[1m]', size = 1000000 } = {}) {
|
|
37
|
+
setSettings(settings);
|
|
38
|
+
const base = { ...process.env };
|
|
39
|
+
for (const k of ['CLAUDE_CODE_AUTO_COMPACT_WINDOW', 'CLAUDE_AUTOCOMPACT_PCT_OVERRIDE',
|
|
40
|
+
'DISABLE_AUTO_COMPACT', 'DISABLE_COMPACT', 'CONTEXT_LIMIT', 'CONTEXT_USABLE_RATIO']) delete base[k];
|
|
41
|
+
const input = {
|
|
42
|
+
model: { id: modelId, display_name: 'Test' },
|
|
43
|
+
workspace: { project_dir: proj, current_dir: proj },
|
|
44
|
+
context_window: { context_window_size: size, current_usage: { input_tokens: tok } },
|
|
45
|
+
};
|
|
46
|
+
const r = spawnSync('bash', [script, '--context-only'], {
|
|
47
|
+
input: JSON.stringify(input),
|
|
48
|
+
env: { ...base, NO_COLOR: '1', TRAILHEAD_STATUSLINE_USAGE: '0', HOME: home, CLAUDE_CONFIG_DIR: cfg, ...env },
|
|
49
|
+
encoding: 'utf8',
|
|
50
|
+
});
|
|
51
|
+
const out = r.stdout.trim();
|
|
52
|
+
const m = out.match(/^\[.{10}\] (\d+)k\/(\d+)k \((\d+)%\)$/);
|
|
53
|
+
assert.ok(m, `unexpected output: ${JSON.stringify(out)} ${r.stderr}`);
|
|
54
|
+
return { pct: Number(m[3]), shown: `${m[1]}k/${m[2]}k`, out };
|
|
55
|
+
}
|
|
56
|
+
const pct = (tok, o) => ctx(tok, o).pct;
|
|
57
|
+
|
|
58
|
+
// --- default 1M: soglia = 1M - 33k = 967k ---
|
|
59
|
+
eq('default 966k', pct(966000), 100);
|
|
60
|
+
eq('default 967k', pct(967000), 100);
|
|
61
|
+
eq('default 900k', pct(900000), 93);
|
|
62
|
+
ok('default 800k not 100', pct(800000) < 100);
|
|
63
|
+
eq('display full window', ctx(900000).shown, '900k/1000k');
|
|
64
|
+
|
|
65
|
+
// --- autoCompactWindow ---
|
|
66
|
+
const win650 = { autoCompactWindow: 650000 };
|
|
67
|
+
eq('top-level 617k', pct(617000, { settings: { user: win650 } }), 100);
|
|
68
|
+
ok('top-level 600k', pct(600000, { settings: { user: win650 } }) < 100);
|
|
69
|
+
const perModel = { modelSettings: { 'claude-opus-5-5': { autoCompactWindow: 650000 } } };
|
|
70
|
+
eq('per-model 617k', pct(617000, { settings: { user: perModel } }), 100);
|
|
71
|
+
ok('per-model 600k', pct(600000, { settings: { user: perModel } }) < 100);
|
|
72
|
+
eq('per-model beats top-level', pct(617000, {
|
|
73
|
+
settings: { user: { autoCompactWindow: 900000, ...perModel } } }), 100);
|
|
74
|
+
eq('per-model beats top-level (larger top)', pct(300000, {
|
|
75
|
+
settings: { user: { autoCompactWindow: 400000, modelSettings: { 'claude-opus-5-5': { autoCompactWindow: 1000000 } } } } }), 31);
|
|
76
|
+
eq('local beats user', pct(617000, { settings: { user: { autoCompactWindow: 1000000 }, local: win650 } }), 100);
|
|
77
|
+
eq('local beats project', pct(617000, { settings: { project: { autoCompactWindow: 1000000 }, local: win650 } }), 100);
|
|
78
|
+
eq('project beats user', pct(617000, { settings: { user: { autoCompactWindow: 1000000 }, project: win650 } }), 100);
|
|
79
|
+
eq('invalid json skipped', pct(617000, { settings: { user: win650, local: '{nope' } }), 100);
|
|
80
|
+
eq('non-positive setting ignored', pct(900000, { settings: { user: { autoCompactWindow: -5 } } }), 93);
|
|
81
|
+
eq('non-numeric setting ignored', pct(900000, { settings: { user: { autoCompactWindow: 'big' } } }), 93);
|
|
82
|
+
eq('invalid higher file value falls to lower', pct(617000, {
|
|
83
|
+
settings: { user: win650, local: { autoCompactWindow: 'x' } } }), 100);
|
|
84
|
+
|
|
85
|
+
// --- env CLAUDE_CODE_AUTO_COMPACT_WINDOW ---
|
|
86
|
+
eq('env beats setting', pct(617000, { env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: '650000' },
|
|
87
|
+
settings: { user: { autoCompactWindow: 1000000 } } }), 100);
|
|
88
|
+
for (const bad of ['abc', '0', '-100']) {
|
|
89
|
+
eq(`env ${bad} ignored`, pct(900000, { env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: bad } }), 93);
|
|
90
|
+
}
|
|
91
|
+
eq('env bad falls back to setting', pct(617000, { env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: 'abc' },
|
|
92
|
+
settings: { user: win650 } }), 100);
|
|
93
|
+
eq('window above model capped', pct(966000, { env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: '5000000' } }), 100);
|
|
94
|
+
eq('window above model capped 900k', pct(900000, { env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: '5000000' } }), 93);
|
|
95
|
+
|
|
96
|
+
// --- CLAUDE_AUTOCOMPACT_PCT_OVERRIDE ---
|
|
97
|
+
eq('pct 50 at threshold', pct(500000, { env: { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: '50' } }), 100);
|
|
98
|
+
eq('pct 50 at 450k', pct(450000, { env: { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: '50' } }), 90);
|
|
99
|
+
for (const v of ['100', '101', '0', '-3', 'abc']) {
|
|
100
|
+
eq(`pct ${v} no change`, pct(900000, { env: { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: v } }), 93);
|
|
101
|
+
}
|
|
102
|
+
eq('pct 1 -> threshold 10k', pct(10000, { env: { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: '1' } }), 100);
|
|
103
|
+
eq('pct 1 at 5k', pct(5000, { env: { CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: '1' } }), 50);
|
|
104
|
+
|
|
105
|
+
// --- disabilitato ---
|
|
106
|
+
eq('DISABLE_AUTO_COMPACT', pct(400000, { env: { DISABLE_AUTO_COMPACT: '1' } }), 40);
|
|
107
|
+
eq('DISABLE_COMPACT true', pct(400000, { env: { DISABLE_COMPACT: 'TRUE' } }), 40);
|
|
108
|
+
eq('disabled ignores settings and pct', pct(400000, { env: { DISABLE_AUTO_COMPACT: 'yes', CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: '10' },
|
|
109
|
+
settings: { user: win650 } }), 40);
|
|
110
|
+
eq('falsy DISABLE ignored', pct(900000, { env: { DISABLE_AUTO_COMPACT: '0' } }), 93);
|
|
111
|
+
eq('DISABLE_COMPACT on', pct(400000, { env: { DISABLE_COMPACT: 'on' } }), 40);
|
|
112
|
+
eq('DISABLE false ignored', pct(900000, { env: { DISABLE_COMPACT: 'false' } }), 93);
|
|
113
|
+
|
|
114
|
+
// --- per-modello in un file meno specifico batte top-level in uno piu' specifico ---
|
|
115
|
+
eq('per-model user beats top-level local', pct(617000, {
|
|
116
|
+
settings: { user: perModel, local: { autoCompactWindow: 1000000 } } }), 100);
|
|
117
|
+
|
|
118
|
+
// --- fallback tabella modello (niente context_window_size): 200k, soglia 167k ---
|
|
119
|
+
eq('model table fallback', pct(100000, { size: 0 }), 60);
|
|
120
|
+
eq('model table fallback display', ctx(100000, { size: 0 }).shown, '100k/200k');
|
|
121
|
+
|
|
122
|
+
// --- tetto modello ---
|
|
123
|
+
eq('CONTEXT_LIMIT caps display', ctx(100000, { env: { CONTEXT_LIMIT: '200000' } }).shown, '100k/200k');
|
|
124
|
+
eq('CONTEXT_LIMIT pct (threshold 167k)', pct(100000, { env: { CONTEXT_LIMIT: '200000' } }), 60);
|
|
125
|
+
|
|
126
|
+
// --- override manuale CONTEXT_USABLE_RATIO ---
|
|
127
|
+
eq('ratio 0.8 old behaviour', pct(800000, { env: { CONTEXT_USABLE_RATIO: '0.8' } }), 100);
|
|
128
|
+
eq('ratio 0.8 at 400k', pct(400000, { env: { CONTEXT_USABLE_RATIO: '0.8' } }), 50);
|
|
129
|
+
eq('ratio bypasses window setting', pct(400000, { env: { CONTEXT_USABLE_RATIO: '0.8' }, settings: { user: win650 } }), 50);
|
|
130
|
+
eq('ratio invalid ignored', pct(900000, { env: { CONTEXT_USABLE_RATIO: '2' } }), 93);
|
|
131
|
+
|
|
132
|
+
// --- bordo soglia: finestra 34k -> soglia 1000 ---
|
|
133
|
+
const edge = { env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: '34000' } };
|
|
134
|
+
eq('edge threshold-1', pct(994, edge), 99);
|
|
135
|
+
eq('edge threshold', pct(1000, edge), 100);
|
|
136
|
+
// finestra minuscola: soglia mai sotto 1
|
|
137
|
+
eq('tiny window guard', pct(5, { env: { CLAUDE_CODE_AUTO_COMPACT_WINDOW: '10' } }), 100);
|
|
138
|
+
|
|
139
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
140
|
+
console.log(`statusline-context.test.js: ${passed} assertions passed`);
|