@marcomigozzi/trailhead 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.it.md +12 -6
  2. package/README.md +12 -6
  3. package/bin/lib/codex-projection.js +9 -6
  4. package/bin/lib/codex-projection.test.js +5 -1
  5. package/bin/trailhead.js +50 -5
  6. package/bin/trailhead.test.js +55 -0
  7. package/package.json +1 -1
  8. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  9. package/plugins/trailhead/agents/trailhead-code-review.md +2 -0
  10. package/plugins/trailhead/agents/trailhead-codebase-map.md +2 -0
  11. package/plugins/trailhead/agents/trailhead-debug.md +2 -0
  12. package/plugins/trailhead/agents/trailhead-executor.md +2 -0
  13. package/plugins/trailhead/agents/trailhead-fix.md +2 -0
  14. package/plugins/trailhead/agents/trailhead-plan.md +2 -0
  15. package/plugins/trailhead/agents/trailhead-research.md +2 -0
  16. package/plugins/trailhead/agents/trailhead-verify.md +2 -0
  17. package/plugins/trailhead/commands/auto.md +6 -0
  18. package/plugins/trailhead/commands/quick.md +1 -1
  19. package/plugins/trailhead/hooks/hooks.json +13 -0
  20. package/plugins/trailhead/hooks/lib/shell-scan.js +247 -0
  21. package/plugins/trailhead/hooks/lib/shell-scan.test.js +49 -0
  22. package/plugins/trailhead/hooks/trailhead-commit-guard.js +13 -2
  23. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +101 -0
  24. package/plugins/trailhead/hooks/trailhead-search-guard.js +279 -0
  25. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +116 -0
  26. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +291 -0
  27. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +95 -0
  28. package/plugins/trailhead/skills/_shared/choices.md +77 -0
  29. package/plugins/trailhead/skills/_shared/configuration-reference.md +4 -2
  30. package/plugins/trailhead/skills/_shared/session-handoff.md +4 -3
  31. package/plugins/trailhead/skills/_shared/substrate-commands.md +1 -1
  32. package/plugins/trailhead/skills/_shared/substrate.md +1 -1
  33. package/plugins/trailhead/skills/_shared/teamwork.md +2 -2
  34. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  35. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +3 -0
  36. package/plugins/trailhead/skills/_shared/techniques/grilling.md +1 -1
  37. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +1 -1
  38. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  39. package/plugins/trailhead/skills/_shared/techniques/verify.md +20 -3
  40. package/plugins/trailhead/skills/_shared/techniques.md +1 -1
  41. package/plugins/trailhead/skills/trailhead/SKILL.md +4 -2
  42. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -0
  43. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -0
  44. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +2 -0
  45. package/plugins/trailhead/skills/trailhead-view/SKILL.md +3 -1
  46. package/plugins/trailhead/skills/trailhead-work/SKILL.md +19 -7
  47. package/plugins/trailhead/skills/trailhead-work/references/auto.md +54 -0
  48. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +6 -6
@@ -0,0 +1,247 @@
1
+ 'use strict';
2
+ // shell-scan.js: shared quote-aware shell scanning helpers, extracted from
3
+ // trailhead-search-guard.js (#135 follow-up) so trailhead's Bash-command
4
+ // guards share ONE tokenizer/statement-splitter instead of drifting copies.
5
+ // trailhead-secret-read-guard.js's original Bash leg used a naive
6
+ // `.split(/\s+/)` tokenizer over the WHOLE command string, which let glued
7
+ // shell metacharacters (`cat .env|grep KEY`, `cat .env;echo done`) slip past
8
+ // detection entirely (a false negative in a security guard) and let a bare
9
+ // `.env` inside multi-word quoted prose (`"document .env usage"`) trip a
10
+ // false positive. This module's `splitStatements` + `tokenize` fix both: the
11
+ // former is quote-aware and splits on operator characters regardless of
12
+ // surrounding whitespace, the latter keeps a multi-word quoted phrase as ONE
13
+ // opaque token instead of splitting it into separate words.
14
+ //
15
+ // Consumers: trailhead-search-guard.js and trailhead-secret-read-guard.js.
16
+ // Self-contained, no external dependencies. Best-effort, like the guards that
17
+ // use it: not a full shell parser, just enough to close their known gaps.
18
+
19
+ // --- path helpers ------------------------------------------------------------
20
+
21
+ function basename(p) {
22
+ const s = String(p).split('/');
23
+ return s[s.length - 1];
24
+ }
25
+
26
+ // --- quoting -----------------------------------------------------------------
27
+
28
+ // Strip a single pair of enclosing quotes: the WHOLE token must start AND end
29
+ // with the same quote character. A multi-word quoted phrase kept as one token
30
+ // by tokenize() (below) round-trips through this to its bare content
31
+ // (spaces and all) - callers testing that bare content against a basename
32
+ // pattern then correctly see prose, not a path, unless the phrase IS exactly
33
+ // a path (e.g. `".env"` -> `.env`, still flagged by design).
34
+ function stripQuotes(t) {
35
+ const s = String(t);
36
+ if ((s.startsWith('"') && s.endsWith('"') && s.length >= 2) ||
37
+ (s.startsWith("'") && s.endsWith("'") && s.length >= 2)) {
38
+ return s.slice(1, -1);
39
+ }
40
+ return s;
41
+ }
42
+
43
+ // --- heredoc bodies ------------------------------------------------------------
44
+
45
+ // Matches a heredoc introducer anywhere it can appear on a line: `<<TAG`,
46
+ // `<<'TAG'`, `<<"TAG"`, and `<<-TAG` (which also allows the terminator line to
47
+ // be indented with leading tabs). Captures the operator's `-` and the tag
48
+ // (quoted or bare) separately.
49
+ const HEREDOC_RE = /<<(-?)\s*(?:'([^']+)'|"([^"]+)"|([A-Za-z0-9_]+))/;
50
+
51
+ // A heredoc BODY is literal data (e.g. a script being written to a file), not
52
+ // shell statements executed in the current shell. Scan the command line by
53
+ // line; whenever a line introduces a heredoc, skip every following line up to
54
+ // and including the matching terminator line before it ever reaches the
55
+ // statement splitter below. Handles multiple heredocs on the same command and
56
+ // both quoted and unquoted tags. Best-effort: not a full shell parser.
57
+ function stripHeredocBodies(cmd) {
58
+ const lines = String(cmd).split('\n');
59
+ const out = [];
60
+ for (let i = 0; i < lines.length; i++) {
61
+ let line = lines[i];
62
+ out.push(line);
63
+ let m;
64
+ // A single line can introduce more than one heredoc (`cmd <<A <<B`); walk
65
+ // left to right and skip each body in turn.
66
+ while ((m = HEREDOC_RE.exec(line))) {
67
+ const dashStrip = m[1] === '-';
68
+ const tag = m[2] || m[3] || m[4];
69
+ line = line.slice(m.index + m[0].length); // keep scanning the rest of this line for more `<<TAG`s
70
+ if (!tag) break;
71
+ // Skip lines until the terminator: an exact match, or (for `<<-`) the
72
+ // tag preceded only by tabs.
73
+ while (i + 1 < lines.length) {
74
+ i++;
75
+ const body = lines[i];
76
+ const term = dashStrip ? body.replace(/^\t+/, '') : body;
77
+ if (term === tag) break; // terminator line: consumed, not a candidate statement either
78
+ // else: heredoc data line, deliberately NOT pushed to `out` - it must
79
+ // never become a candidate statement.
80
+ }
81
+ }
82
+ }
83
+ return out.join('\n');
84
+ }
85
+
86
+ // --- statement splitting -------------------------------------------------------
87
+
88
+ // Split a shell string into top-level statements on `&&`, `||`, `;`, `|`, and
89
+ // newlines, respecting single/double quotes (so a separator INSIDE a quoted
90
+ // argument, e.g. a grep pattern, does not fracture the statement). Heredoc
91
+ // bodies are stripped first (see stripHeredocBodies) so literal data written
92
+ // via `<<TAG` is never mistaken for statements. Splitting is purely
93
+ // character-driven (not whitespace-driven), so a glued separator with no
94
+ // surrounding spaces (`cat .env|grep KEY`, `cat .env;echo done`) still splits
95
+ // correctly. Best-effort: not a full shell parser, just enough to avoid the
96
+ // common false splits.
97
+ function splitStatements(cmd) {
98
+ const s = stripHeredocBodies(String(cmd));
99
+ const stmts = [];
100
+ let cur = '';
101
+ let inSingle = false;
102
+ let inDouble = false;
103
+ for (let i = 0; i < s.length; i++) {
104
+ const c = s[i];
105
+ if (inSingle) {
106
+ cur += c;
107
+ if (c === "'") inSingle = false;
108
+ continue;
109
+ }
110
+ if (inDouble) {
111
+ cur += c;
112
+ if (c === '\\' && i + 1 < s.length) { cur += s[++i]; continue; } // escaped char stays literal
113
+ if (c === '"') inDouble = false;
114
+ continue;
115
+ }
116
+ if (c === "'") { inSingle = true; cur += c; continue; }
117
+ if (c === '"') { inDouble = true; cur += c; continue; }
118
+ if (c === '\\' && i + 1 < s.length) { cur += c + s[++i]; continue; }
119
+ if (c === '&' && s[i + 1] === '&') { stmts.push(cur); cur = ''; i++; continue; }
120
+ if (c === '|' && s[i + 1] === '|') { stmts.push(cur); cur = ''; i++; continue; }
121
+ if (c === ';' || c === '|' || c === '\n') { stmts.push(cur); cur = ''; continue; }
122
+ cur += c;
123
+ }
124
+ stmts.push(cur);
125
+ return stmts.map((t) => t.trim()).filter(Boolean);
126
+ }
127
+
128
+ // --- tokenizing ----------------------------------------------------------------
129
+
130
+ // Quote-aware whitespace tokenizer: splits a statement on UNQUOTED
131
+ // whitespace, keeping a `"..."`/`'...'` span - even a MULTI-WORD one - as a
132
+ // single opaque token (quotes are kept in the token; stripQuotes() unwraps
133
+ // them). This is what keeps quoted prose like `"document .env usage"` safe:
134
+ // the whole phrase is one token, so a bare `.env` glued mid-phrase is never a
135
+ // separate candidate token, while a lone quoted path like `".env"` still
136
+ // round-trips to the bare `.env` via stripQuotes for basename testing.
137
+ // Mirrors splitStatements' escape handling (`\"` inside a double-quoted span
138
+ // stays literal, never closes the quote early). Best-effort: not a full shell
139
+ // parser (a real shell also strips quotes/joins adjacent quoted+bare runs
140
+ // into one word; this keeps the surrounding quotes intact instead, which is
141
+ // enough for the callers' basename tests).
142
+ function tokenize(stmt) {
143
+ const s = String(stmt).trim();
144
+ const tokens = [];
145
+ let cur = '';
146
+ let inSingle = false;
147
+ let inDouble = false;
148
+ for (let i = 0; i < s.length; i++) {
149
+ const c = s[i];
150
+ if (inSingle) {
151
+ cur += c;
152
+ if (c === "'") inSingle = false;
153
+ continue;
154
+ }
155
+ if (inDouble) {
156
+ cur += c;
157
+ if (c === '\\' && i + 1 < s.length) { cur += s[++i]; continue; }
158
+ if (c === '"') inDouble = false;
159
+ continue;
160
+ }
161
+ if (c === "'") { inSingle = true; cur += c; continue; }
162
+ if (c === '"') { inDouble = true; cur += c; continue; }
163
+ if (c === '\\' && i + 1 < s.length) { cur += c + s[++i]; continue; }
164
+ if (/\s/.test(c)) {
165
+ if (cur) { tokens.push(cur); cur = ''; }
166
+ continue;
167
+ }
168
+ cur += c;
169
+ }
170
+ if (cur) tokens.push(cur);
171
+ return tokens;
172
+ }
173
+
174
+ function skipEnvPrefix(tokens) {
175
+ let i = 0;
176
+ while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) i++;
177
+ return i;
178
+ }
179
+
180
+ // --- search-verb argument scanning ----------------------------------------
181
+
182
+ // grep/rg/ag/sed/awk take a PATTERN (or script/program) as their first
183
+ // positional. Shared between the search guard (which skips the pattern before
184
+ // flagging a relative FILE arg) and the secret-read guard (which skips the
185
+ // pattern before flagging a SECRET FILE arg), so e.g. `grep ".env" config.txt`
186
+ // is never mistaken for a `.env` read: the FILE operand is config.txt, the
187
+ // PATTERN just happens to look like a secret path.
188
+ const GREP_VALUE_FLAGS = new Set([
189
+ '-e', '-f', '-A', '-B', '-C', '-m', '-D',
190
+ '--include', '--exclude', '--exclude-dir', '--exclude-from',
191
+ '--after-context', '--before-context', '--context', '--max-count',
192
+ ]);
193
+
194
+ // ripgrep has several value-taking flags GNU grep doesn't (glob/type filters,
195
+ // column limits); without these, scanning treats their value as a plain
196
+ // positional and misattributes it as the pattern/file in the block reason.
197
+ const RG_VALUE_FLAGS = new Set([
198
+ ...GREP_VALUE_FLAGS,
199
+ '-g', '--glob', '-t', '--type', '-T', '--type-not', '-M', '--max-columns',
200
+ ]);
201
+
202
+ const SEARCH_VERB_CONFIG = {
203
+ grep: { patternFirst: true, valueFlags: GREP_VALUE_FLAGS, patternFlags: new Set(['-e']) },
204
+ rg: { patternFirst: true, valueFlags: RG_VALUE_FLAGS, patternFlags: new Set(['-e']) },
205
+ ag: { patternFirst: true, valueFlags: GREP_VALUE_FLAGS, patternFlags: new Set(['-e']) },
206
+ sed: { patternFirst: true, valueFlags: new Set(['-e', '-f']), patternFlags: new Set(['-e']) },
207
+ awk: { patternFirst: true, valueFlags: new Set(['-f', '-v']), patternFlags: new Set() },
208
+ };
209
+
210
+ // Walk a verb's argument tokens (verb itself already stripped), skipping
211
+ // flags (and their separate-token values), an explicit `--` end-of-options
212
+ // marker (which must not itself swallow the next real positional), and the
213
+ // leading pattern/script positional when the verb has one (`config.patternFirst`).
214
+ // Returns the first token for which `isMatch(token)` is true, or null.
215
+ // Generic over the predicate so both guards can drive it with their own
216
+ // notion of "interesting" (a relative file arg vs. a secret path arg).
217
+ function scanArgsSkippingPattern(args, config, isMatch) {
218
+ let patternConsumed = !config.patternFirst;
219
+ let afterDashDash = false; // once seen, every remaining token is positional, never a flag
220
+ for (let j = 0; j < args.length; j++) {
221
+ const t = args[j];
222
+ if (!afterDashDash && t === '--') { afterDashDash = true; continue; }
223
+ if (!afterDashDash && t.startsWith('-') && t !== '-') {
224
+ if (config.valueFlags.has(t)) {
225
+ j++; // consume its separate-token value
226
+ if (config.patternFlags && config.patternFlags.has(t)) patternConsumed = true;
227
+ }
228
+ continue;
229
+ }
230
+ if (!patternConsumed) { patternConsumed = true; continue; } // leading pattern/script positional
231
+ if (isMatch(t)) return t;
232
+ }
233
+ return null;
234
+ }
235
+
236
+ module.exports = {
237
+ basename,
238
+ stripQuotes,
239
+ stripHeredocBodies,
240
+ splitStatements,
241
+ tokenize,
242
+ skipEnvPrefix,
243
+ GREP_VALUE_FLAGS,
244
+ RG_VALUE_FLAGS,
245
+ SEARCH_VERB_CONFIG,
246
+ scanArgsSkippingPattern,
247
+ };
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // Tests for shell-scan.js. Run: node shell-scan.test.js
3
+ const assert = require('assert');
4
+ const {
5
+ basename,
6
+ stripQuotes,
7
+ splitStatements,
8
+ tokenize,
9
+ scanArgsSkippingPattern,
10
+ SEARCH_VERB_CONFIG,
11
+ } = require('./shell-scan.js');
12
+
13
+ let passed = 0;
14
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
15
+
16
+ // --- basename ---
17
+ ok('basename strips leading dirs', basename('a/b/.env') === '.env');
18
+ ok('basename passes through a bare name', basename('.env') === '.env');
19
+
20
+ // --- stripQuotes ---
21
+ ok('stripQuotes unwraps a double-quoted token', stripQuotes('".env"') === '.env');
22
+ ok('stripQuotes unwraps a single-quoted token', stripQuotes("'.env'") === '.env');
23
+ ok('stripQuotes leaves a bare token alone', stripQuotes('.env') === '.env');
24
+
25
+ // --- splitStatements: glued metacharacters (no surrounding whitespace) ---
26
+ ok('splitStatements splits a glued pipe', JSON.stringify(splitStatements('cat .env|grep KEY')) === JSON.stringify(['cat .env', 'grep KEY']));
27
+ ok('splitStatements splits a glued semicolon', JSON.stringify(splitStatements('cat .env;echo done')) === JSON.stringify(['cat .env', 'echo done']));
28
+ ok('splitStatements splits a glued &&', JSON.stringify(splitStatements('cat .env&&echo done')) === JSON.stringify(['cat .env', 'echo done']));
29
+ ok('splitStatements keeps a quoted separator intact', JSON.stringify(splitStatements('grep "a;b" f.txt')) === JSON.stringify(['grep "a;b" f.txt']));
30
+
31
+ // --- tokenize: quoted multi-word phrase stays ONE token ---
32
+ ok('tokenize keeps a multi-word double-quoted phrase as one token',
33
+ JSON.stringify(tokenize('git commit -m "document .env usage"')) ===
34
+ JSON.stringify(['git', 'commit', '-m', '"document .env usage"']));
35
+ ok('tokenize keeps a multi-word single-quoted phrase as one token',
36
+ JSON.stringify(tokenize("echo 'a b c'")) === JSON.stringify(['echo', "'a b c'"]));
37
+ ok('tokenize round-trips a lone quoted path via stripQuotes',
38
+ stripQuotes(tokenize('cat ".env"')[1]) === '.env');
39
+ ok('tokenize handles an escaped quote inside a double-quoted span without closing early',
40
+ JSON.stringify(tokenize('grep "a\\"b" f.txt')) === JSON.stringify(['grep', '"a\\"b"', 'f.txt']));
41
+
42
+ // --- scanArgsSkippingPattern: search-verb pattern-positional skipping ---
43
+ const isDotEnv = (t) => stripQuotes(t) === '.env';
44
+ ok('scanArgsSkippingPattern skips the pattern positional (grep ".env" config.txt allows)',
45
+ scanArgsSkippingPattern(['".env"', 'config.txt'], SEARCH_VERB_CONFIG.grep, isDotEnv) === null);
46
+ ok('scanArgsSkippingPattern still finds a secret FILE operand after the pattern',
47
+ scanArgsSkippingPattern(['-n', 'KEY', '.env'], SEARCH_VERB_CONFIG.grep, isDotEnv) === '.env');
48
+
49
+ console.log(`✓ shell-scan: ${passed} assertions passed`);
@@ -31,12 +31,23 @@ function isGitCommit(cmd) {
31
31
  return toks[i] === 'commit';
32
32
  }
33
33
 
34
+ // $(...) and backticks are the two command-substitution syntaxes. Inside a
35
+ // double-quoted (or unquoted) value the shell expands them, so the text the
36
+ // regex captured is shell syntax, not the real message -> fail open, or we'd
37
+ // raise false positives on valid commits (#121). But a backslash-escaped
38
+ // `\$(` / `` \` `` (an EVEN count preceding the marker means it is NOT escaped;
39
+ // an ODD count means it is) is a LITERAL, fully-static message that must still
40
+ // be validated, so only an UNESCAPED marker triggers fail-open. Inside single
41
+ // quotes everything is literal, so the captured text is always the real message.
42
+ const UNEXPANDABLE = /(?:^|[^\\])(?:\\\\)*(?:\$\(|`)/;
43
+
34
44
  function extractMessage(cmd) {
35
45
  // -m "…" / -m '…' / -m token / --message=…
36
46
  let m = cmd.match(/(?:^|\s)(?:-m|--message)(?:=|\s+)(["'])([\s\S]*?)\1/);
37
- if (m) return m[2];
47
+ if (m) return (m[1] === '"' && UNEXPANDABLE.test(m[2])) ? null : m[2];
38
48
  m = cmd.match(/(?:^|\s)(?:-m|--message)(?:=|\s+)(\S+)/);
39
- return m ? m[1] : null;
49
+ if (m) return UNEXPANDABLE.test(m[1]) ? null : m[1];
50
+ return null;
40
51
  }
41
52
 
42
53
  let data = '';
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ // Tests for trailhead-commit-guard.js. Run: node trailhead-commit-guard.test.js
3
+ // No framework: plain asserts + child_process for the end-to-end hook behaviour.
4
+ const assert = require('assert');
5
+ const { execFileSync } = require('child_process');
6
+ const path = require('path');
7
+
8
+ const HOOK = path.join(__dirname, 'trailhead-commit-guard.js');
9
+
10
+ let passed = 0;
11
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
12
+
13
+ // Run the hook end-to-end: pipe tool_input JSON on stdin, capture {code, out}.
14
+ function runHook(command) {
15
+ const input = JSON.stringify({ tool_input: { command } });
16
+ try {
17
+ const out = execFileSync('node', [HOOK], { input });
18
+ return { code: 0, out: out.toString() };
19
+ } catch (e) {
20
+ return { code: e.status, out: (e.stdout || '').toString(), err: (e.stderr || '').toString() };
21
+ }
22
+ }
23
+
24
+ // --- regression #121: a command-substitution / heredoc -m carrying a VALID
25
+ // conventional message must be ALLOWED, not blocked on the literal heredoc
26
+ // syntax the guard cannot statically expand.
27
+ const heredocCmd = [
28
+ 'git commit -m "$(cat <<\'EOF\'',
29
+ 'feat: something valid',
30
+ 'EOF',
31
+ ')"',
32
+ ].join('\n');
33
+ const r1 = runHook(heredocCmd);
34
+ ok('command-substitution/heredoc -m with a valid message is allowed (exit 0)', r1.code === 0 && r1.out.trim() === '');
35
+
36
+ // --- a backtick-containing -m value is allowed (fail open) ---
37
+ const r2 = runHook('git commit -m "`echo feat: something valid`"');
38
+ ok('backtick -m value is allowed (exit 0)', r2.code === 0 && r2.out.trim() === '');
39
+
40
+ // --- a plain quoted VALID -m is still allowed ---
41
+ const r3 = runHook('git commit -m "feat: something valid"');
42
+ ok('plain quoted valid -m is allowed (exit 0)', r3.code === 0 && r3.out.trim() === '');
43
+
44
+ // --- a plain quoted INVALID -m is still BLOCKED ---
45
+ const r4 = runHook('git commit -m "not a conventional subject"');
46
+ ok('plain quoted invalid -m is blocked (exit 2)', r4.code === 2 && /CONVENTIONAL_COMMITS_VIOLATION/.test(r4.out));
47
+
48
+ // --- a Co-Authored-By trailer anywhere is still BLOCKED ---
49
+ const r5 = runHook('git commit -m "feat: ok\n\nCo-Authored-By: Someone <a@b.c>"');
50
+ ok('Co-Authored-By trailer is blocked (exit 2)', r5.code === 2 && /CO_AUTHORED_BY_FORBIDDEN/.test(r5.out));
51
+
52
+ // --- a non-git-commit command is allowed ---
53
+ const r6 = runHook('ls -la');
54
+ ok('non-git-commit command is allowed (exit 0)', r6.code === 0 && r6.out.trim() === '');
55
+
56
+ // --- code review follow-up (#121): single-quoted values are shell-literal,
57
+ // so `$(` / backtick text inside single quotes is NOT expanded and must be
58
+ // validated like any other message. A single-quoted bad message containing
59
+ // `$(` must still be BLOCKED.
60
+ const r7 = runHook("git commit -m '$(not a conventional subject)'");
61
+ ok('single-quoted bad -m with $( is blocked (exit 2)', r7.code === 2 && /CONVENTIONAL_COMMITS_VIOLATION/.test(r7.out));
62
+
63
+ // --- single-quoted bad message containing a backtick must still be BLOCKED ---
64
+ const r8 = runHook("git commit -m 'has a ` backtick and is not conventional'");
65
+ ok('single-quoted bad -m with a backtick is blocked (exit 2)', r8.code === 2 && /CONVENTIONAL_COMMITS_VIOLATION/.test(r8.out));
66
+
67
+ // --- the `<<` sub-pattern was redundant and caused false negatives on
68
+ // ordinary text like "a<<b"; it must be removed so this is BLOCKED ---
69
+ const r9 = runHook('git commit -m "a<<b is not a conventional subject"');
70
+ ok('double-quoted bad -m containing a<<b is blocked (exit 2)', r9.code === 2 && /CONVENTIONAL_COMMITS_VIOLATION/.test(r9.out));
71
+
72
+ // --- regression guard: a single-quoted VALID conventional message is
73
+ // still allowed ---
74
+ const r10 = runHook("git commit -m 'feat: valid subject'");
75
+ ok('single-quoted valid -m is allowed (exit 0)', r10.code === 0 && r10.out.trim() === '');
76
+
77
+ // --- re-review follow-up (#121): a backslash-escaped `\$(` / `` \` `` inside
78
+ // double quotes is a LITERAL, fully-static message (bash does not expand an
79
+ // escaped marker), so it must be validated like any other message, not
80
+ // fail-opened. A bad message using the escaped form must still be BLOCKED.
81
+ const r11 = runHook('git commit -m "\\$(x) not conventional"');
82
+ ok('double-quoted bad -m with escaped \\$( is blocked (exit 2)', r11.code === 2 && /CONVENTIONAL_COMMITS_VIOLATION/.test(r11.out));
83
+
84
+ const r12 = runHook('git commit -m "\\`x\\` not conventional"');
85
+ ok('double-quoted bad -m with escaped backticks is blocked (exit 2)', r12.code === 2 && /CONVENTIONAL_COMMITS_VIOLATION/.test(r12.out));
86
+
87
+ const r13 = runHook('git commit --message="\\$(x) not conventional"');
88
+ ok('--message= bad value with escaped \\$( is blocked (exit 2)', r13.code === 2 && /CONVENTIONAL_COMMITS_VIOLATION/.test(r13.out));
89
+
90
+ // --- positive-path guard for the guard's HARD invariant (never a false
91
+ // positive): a VALID conventional message that merely contains an escaped
92
+ // `\$(` / `` \` `` marker must stay ALLOWED. Without these, a future regex
93
+ // tweak could reintroduce the #121 false positive on the escaped-literal path
94
+ // and no test would catch it.
95
+ const r14 = runHook('git commit -m "fix: escape \\$(VAR) safely"');
96
+ ok('double-quoted valid -m with escaped \\$( is allowed (exit 0)', r14.code === 0 && r14.out.trim() === '');
97
+
98
+ const r15 = runHook('git commit -m "fix: rename \\`getUser\\` to \\`fetchUser\\`"');
99
+ ok('double-quoted valid -m with escaped backticks is allowed (exit 0)', r15.code === 0 && r15.out.trim() === '');
100
+
101
+ console.log(`✓ commit-guard: ${passed} assertions passed`);