@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.
Files changed (120) hide show
  1. package/README.it.md +142 -29
  2. package/README.md +141 -28
  3. package/bin/base-preflight-docs.test.js +65 -0
  4. package/bin/execute-split-docs.test.js +146 -0
  5. package/bin/isolation-answer-docs.test.js +41 -0
  6. package/bin/isolation-copy-docs.test.js +28 -0
  7. package/bin/lib/codex-projection.js +17 -10
  8. package/bin/lib/codex-projection.test.js +80 -11
  9. package/bin/lib/install-verify.js +10 -5
  10. package/bin/lib/install-verify.test.js +51 -0
  11. package/bin/report-status-docs.test.js +195 -0
  12. package/bin/resolve-helpers-docs.test.js +36 -0
  13. package/bin/scope-confirm-skip-docs.test.js +70 -0
  14. package/bin/trailhead.js +40 -28
  15. package/bin/trailhead.test.js +503 -6
  16. package/bin/uat-setup-docs.test.js +65 -0
  17. package/package.json +4 -1
  18. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  19. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  20. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  21. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  22. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  23. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  24. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  25. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  26. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  27. package/plugins/trailhead/commands/panel.md +10 -0
  28. package/plugins/trailhead/hooks/hooks.json +3 -18
  29. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  30. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  31. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  32. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  33. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  34. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  35. package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
  36. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  37. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  38. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  39. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  40. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  41. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  42. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  43. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  44. package/plugins/trailhead/hooks/lib/shell-scan.js +532 -65
  45. package/plugins/trailhead/hooks/lib/shell-scan.test.js +434 -0
  46. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  47. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  48. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  49. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  50. package/plugins/trailhead/hooks/lib/work-state.test.js +297 -0
  51. package/plugins/trailhead/hooks/mod/README.md +219 -0
  52. package/plugins/trailhead/hooks/mod/map-pane-view.ts +606 -0
  53. package/plugins/trailhead/hooks/mod/map-pane.tsx +832 -0
  54. package/plugins/trailhead/hooks/mod/register.tsx +293 -0
  55. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  56. package/plugins/trailhead/hooks/mod/work-state-view.ts +110 -0
  57. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  58. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  59. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  60. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  61. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  62. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  63. package/plugins/trailhead/hooks/trailhead-install-guard.js +187 -42
  64. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +182 -2
  65. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  66. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
  67. package/plugins/trailhead/hooks/trailhead-search-guard.js +59 -13
  68. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +68 -3
  69. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  70. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  71. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  72. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  73. package/plugins/trailhead/skills/_shared/choices.md +7 -3
  74. package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
  75. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  76. package/plugins/trailhead/skills/_shared/multi-map.md +3 -3
  77. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  78. package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
  79. package/plugins/trailhead/skills/_shared/substrate.md +6 -4
  80. package/plugins/trailhead/skills/_shared/teamwork.md +17 -6
  81. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
  82. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  83. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  84. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  85. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  86. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  87. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  88. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  89. package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
  90. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  91. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
  92. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  93. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  94. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  95. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  96. package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
  97. package/plugins/trailhead/skills/trailhead-work/SKILL.md +16 -13
  98. package/plugins/trailhead/skills/trailhead-work/references/auto.md +6 -4
  99. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  100. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +28 -10
  101. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  102. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  103. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  104. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  105. package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
  106. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  107. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  108. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  109. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  110. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  111. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  112. package/plugins/trailhead/templates/trailhead-work-state.js +217 -0
  113. package/plugins/trailhead/tests/map-pane-ui.test.ts +1921 -0
  114. package/plugins/trailhead/tests/map-pane-view.test.ts +853 -0
  115. package/plugins/trailhead/tests/mod.test.ts +14 -0
  116. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  117. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  118. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  119. package/plugins/trailhead/tests/work-state-view.test.ts +110 -0
  120. package/plugins/trailhead/types/index.d.ts +103 -0
@@ -4,10 +4,19 @@ const assert = require('assert');
4
4
  const {
5
5
  basename,
6
6
  stripQuotes,
7
+ stripHeredocBodies,
8
+ heredocBodies,
9
+ countHeredocs,
7
10
  splitStatements,
11
+ splitStatementsWithSeparators,
8
12
  tokenize,
13
+ substitutionBodies,
14
+ heredocSubstitutionBodies,
15
+ functionSubstitutions,
16
+ inWordContinuations,
9
17
  scanArgsSkippingPattern,
10
18
  SEARCH_VERB_CONFIG,
19
+ stripRedirections,
11
20
  } = require('./shell-scan.js');
12
21
 
13
22
  let passed = 0;
@@ -28,6 +37,274 @@ ok('splitStatements splits a glued semicolon', JSON.stringify(splitStatements('c
28
37
  ok('splitStatements splits a glued &&', JSON.stringify(splitStatements('cat .env&&echo done')) === JSON.stringify(['cat .env', 'echo done']));
29
38
  ok('splitStatements keeps a quoted separator intact', JSON.stringify(splitStatements('grep "a;b" f.txt')) === JSON.stringify(['grep "a;b" f.txt']));
30
39
 
40
+ // --- splitStatements: a live backslash-newline continuation joins the line (#222) ---
41
+ ok('splitStatements joins a continuation before a word', JSON.stringify(splitStatements('cat \\\n.env')) === JSON.stringify(['cat .env']));
42
+ ok('splitStatements joins a continuation inside a word', JSON.stringify(splitStatements('cat .e\\\nnv')) === JSON.stringify(['cat .env']));
43
+ ok('splitStatements keeps an escaped backslash before a newline as two statements', JSON.stringify(splitStatements('cat .e\\\\\nnv')) === JSON.stringify(['cat .e\\\\', 'nv']));
44
+ ok('splitStatements keeps a single-quoted continuation literal', JSON.stringify(splitStatements("echo 'a\\\nb'")) === JSON.stringify(["echo 'a\\\nb'"]));
45
+
46
+ // --- splitStatementsWithSeparators: statements plus the operator that ended each ---
47
+ {
48
+ const seps = (c) => JSON.stringify(splitStatementsWithSeparators(c).map((x) => [x.text, x.sep]));
49
+ ok('withSeparators reports ; and the final null', seps('a; b') === JSON.stringify([['a', ';'], ['b', null]]));
50
+ ok('withSeparators reports &&, ||, | and newline',
51
+ seps('a && b || c | d\ne') === JSON.stringify([['a', '&&'], ['b', '||'], ['c', '|'], ['d', '\n'], ['e', null]]));
52
+ ok('withSeparators treats ;; as two separators with no empty statement',
53
+ seps('case x in a) one;; b) two;; esac') === JSON.stringify([['case x in a) one', ';'], ['b) two', ';'], ['esac', null]]));
54
+ ok('withSeparators splits on a lone & (background)', seps('a & b') === JSON.stringify([['a', '&'], ['b', null]]));
55
+ ok('withSeparators does not split on redirections (2>&1, &>)',
56
+ seps('a 2>&1 | b') === JSON.stringify([['a 2>&1', '|'], ['b', null]]) &&
57
+ seps('a &> f') === JSON.stringify([['a &> f', null]]));
58
+ ok('withSeparators keeps a quoted separator intact', seps('grep "a;b&c" f') === JSON.stringify([['grep "a;b&c" f', null]]));
59
+ ok('withSeparators skips heredoc bodies', seps("cat <<EOF\na; b\nEOF\nz") === JSON.stringify([['cat <<EOF', '\n'], ['z', null]]));
60
+ ok('splitStatements is unchanged on a lone &', JSON.stringify(splitStatements('a & b')) === JSON.stringify(['a & b']));
61
+ }
62
+
63
+ // --- heredocs: a herestring (<<<) is not a heredoc; bodies are extractable ---
64
+ {
65
+ ok('a herestring does not swallow the following lines',
66
+ stripHeredocBodies('cat <<< "hello"\ngh issue comment 1 -F x') === 'cat <<< "hello"\ngh issue comment 1 -F x');
67
+ ok('a herestring with a bare word does not swallow the following lines',
68
+ stripHeredocBodies('cat <<<word\nnext line') === 'cat <<<word\nnext line');
69
+ ok('a real heredoc is still stripped', stripHeredocBodies('cat <<EOF\nbody\nEOF\nnext') === 'cat <<EOF\nnext');
70
+ const hb = heredocBodies("cat <<EOF | x <<'B'\nfirst $(a)\nEOF\nsecond\nB\nlast");
71
+ ok('heredocBodies returns bodies in order with the quoting of the delimiter',
72
+ hb.length === 2 && hb[0].body === 'first $(a)' && hb[0].quoted === false && hb[1].body === 'second' && hb[1].quoted === true);
73
+ ok('heredocBodies handles <<- with tab-indented terminator',
74
+ heredocBodies('cat <<-EOF\n\tx\n\tEOF\n')[0].body === '\tx');
75
+ ok('heredocBodies ignores a herestring', heredocBodies('cat <<< "a"\nb').length === 0);
76
+ ok('countHeredocs counts introducers in a statement',
77
+ countHeredocs('cat <<A <<B') === 2 && countHeredocs('cat <<< x') === 0 && countHeredocs('ls') === 0);
78
+ }
79
+
80
+ // --- heredocs: a `<<` with shell context (#212) ---
81
+ {
82
+ // Each is followed by a newline and `cat .env`, which must stay visible.
83
+ const notHeredoc = [
84
+ 'echo $((1<<3))', 'x=$(( y << 2 ))', 'echo $[1<<3]', '((x=1<<3))',
85
+ 'echo "a <<EOF b"', "echo '<<EOF'", '# <<EOF', 'echo hi # <<EOF',
86
+ 'echo \\<<EOF', 'cat <<<x', 'echo "${#x}" <<<EOF', 'echo $((1\n<<3))',
87
+ "echo $'a\\'b <<EOF'", 'x=abc; echo ${x#<<EOF}', 'echo ${x:-<<EOF}', 'echo "${x:-<<EOF}"', 'echo ${x:-"}" <<EOF}',
88
+ ];
89
+ for (const c of notHeredoc) {
90
+ const full = `${c}\ncat .env`;
91
+ ok(`not a heredoc: ${JSON.stringify(c)}`,
92
+ stripHeredocBodies(full) === full && countHeredocs(c) === 0 && heredocBodies(full).length === 0 &&
93
+ splitStatements(full).includes('cat .env'));
94
+ }
95
+ ok('a quoted string spanning lines suppresses an introducer on the next line',
96
+ stripHeredocBodies('echo "start\n<<EOF\nend"\ncat .env') === 'echo "start\n<<EOF\nend"\ncat .env' &&
97
+ heredocBodies('echo "start\n<<EOF\nend"\ncat .env').length === 0);
98
+ ok('a single-quoted string spanning lines suppresses an introducer too',
99
+ stripHeredocBodies("echo 'a\n<<EOF\nb'\ncat .env") === "echo 'a\n<<EOF\nb'\ncat .env");
100
+ ok('# mid-word is not a comment, so the heredoc after it is real',
101
+ stripHeredocBodies('a#b <<EOF\nx\nEOF\ncat .env') === 'a#b <<EOF\ncat .env');
102
+ ok('let x=1<<3 is a heredoc with tag 3',
103
+ stripHeredocBodies('let x=1<<3\nbody\n3\ncat .env') === 'let x=1<<3\ncat .env');
104
+ ok('a heredoc inside $(...) is still found',
105
+ stripHeredocBodies('x=$(cat <<EOF\nbody\nEOF\n)\ncat .env') === 'x=$(cat <<EOF\n)\ncat .env');
106
+ ok('quoted tags and <<- keep working',
107
+ stripHeredocBodies("cat <<'E'\na\nE\ncat <<\"F\"\nb\nF\ncat <<-G\n\tc\n\tG\nz") === "cat <<'E'\ncat <<\"F\"\ncat <<-G\nz");
108
+ ok('an ANSI-C string closes on its own quote, so a heredoc after it is real',
109
+ stripHeredocBodies("echo $'a\\'b' <<EOF\nx\nEOF\ncat .env") === "echo $'a\\'b' <<EOF\ncat .env" &&
110
+ stripHeredocBodies('echo ${x} <<EOF\nx\nEOF\ncat .env') === 'echo ${x} <<EOF\ncat .env');
111
+ const cont = 'true <<EOF \\\n; echo REACHED\nbody\nEOF\ncat .env';
112
+ ok('a backslash-newline joins the lines: the continuation stays visible, the body starts after it',
113
+ stripHeredocBodies(cont) === 'true <<EOF \\\n; echo REACHED\ncat .env' && heredocBodies(cont)[0].body === 'body' &&
114
+ JSON.stringify(splitStatements(cont)) === JSON.stringify(['true <<EOF', 'echo REACHED', 'cat .env']));
115
+ ok('an escaped space is not a word boundary, so \\ #x <<EOF is a real heredoc',
116
+ stripHeredocBodies('echo \\ #x <<EOF\nx\nEOF\ncat .env') === 'echo \\ #x <<EOF\ncat .env' &&
117
+ countHeredocs('echo \\ #x <<EOF') === 1);
118
+ ok('a comment ending in a backslash does not continue', stripHeredocBodies('echo hi # <<A \\\n<<EOF\nx\nEOF\ncat .env') === 'echo hi # <<A \\\n<<EOF\ncat .env');
119
+ ok('countHeredocs ignores a quoted or arithmetic << but counts a real one',
120
+ countHeredocs('echo "<<A" $((1<<2)) <<B') === 1);
121
+ }
122
+
123
+ // --- heredocs: splitters and the scanner share one context model (#212) ---
124
+ // secret-guard pairs heredocBodies with per-statement countHeredocs by position,
125
+ // so the two must agree even when a separator sits inside $((..)), a comment, etc.
126
+ {
127
+ const agree = (c) => heredocBodies(c).length === splitStatementsWithSeparators(c).reduce((n, x) => n + x.heredocs, 0);
128
+ const body = "bash <<'EOF'\ngh issue comment 1 --body hi\nEOF";
129
+ for (const first of [
130
+ 'echo $((1|2<<3))', 'echo $((1&&2<<3))', 'echo $((1||2<<3))', 'echo $((1;2<<3))', 'echo $[1|2<<3]',
131
+ '((x=1|2<<3))', ': # ;true <<A', ': # |true <<A', 'echo ${x:-a;b <<A}', 'echo ok',
132
+ ]) ok(`heredocBodies agrees with countHeredocs per statement: ${JSON.stringify(first)}`, agree(`${first}\n${body}`));
133
+ ok('splitStatements does not cut inside $((..)), $[..] or a comment',
134
+ JSON.stringify(splitStatements('echo $((1|2)) $[3;4] # a;b\nls')) === JSON.stringify(['echo $((1|2)) $[3;4] # a;b', 'ls']));
135
+ }
136
+
137
+ // --- a newline inside an open quote is not a statement boundary (#212) ---
138
+ {
139
+ const one = (c) => splitStatements(c).length === 1 && splitStatementsWithSeparators(c).length === 1;
140
+ ok('a multi-line double-quoted commit message stays one statement',
141
+ one('git commit -m "fix\n\nrun:\ncat .env\nmore"'));
142
+ ok('a multi-line single-quoted string stays one statement', one("echo 'a\ncat .env\nb'"));
143
+ ok('a multi-line ANSI-C string stays one statement', one("echo $'a\ncat .env\nb'"));
144
+ ok('a multi-line ${..} stays one statement', one('echo ${x:-a\ncat .env\nb}'));
145
+ const agree = (c) => heredocBodies(c).length === splitStatementsWithSeparators(c).reduce((n, x) => n + x.heredocs, 0);
146
+ for (const c of ['echo "a\n<<3\nb"\ncat .env', "echo 'a\n<<EOF\nb'\ncat .env", 'echo "a\n<<EOF\nb" <<EOF\nx\nEOF\ncat .env'])
147
+ ok(`heredoc counts agree across a quoted newline: ${JSON.stringify(c)}`, agree(c));
148
+ }
149
+
150
+ // --- a continuation line starts a word only if the text before the backslash ended one (#212) ---
151
+ {
152
+ const plain = (c) => heredocBodies(c).length === 0 && stripHeredocBodies(c) === c;
153
+ ok('echo a \\<nl># is a comment, so its << is no heredoc', plain('echo a \\\n# x <<EOF\ncat .env\nEOF'));
154
+ ok('echo a\\<nl># is a word, not a comment, so the << is a heredoc',
155
+ stripHeredocBodies('echo a\\\n# x <<EOF\nx\nEOF\ncat .env') === 'echo a\\\n# x <<EOF\ncat .env');
156
+ ok('a lone backslash line keeps the earlier boundary', plain('echo a \\\n\\\n# x <<EOF\ncat .env\nEOF'));
157
+ ok('an escaped space before the backslash is not a boundary',
158
+ countHeredocs('echo a\\ \\\n# x <<EOF') === 1);
159
+ }
160
+
161
+ // --- a # glued to the ) of an expansion is not a comment (#212) ---
162
+ {
163
+ const J = JSON.stringify;
164
+ ok('$(..)# keeps splitting: bash runs the later statement',
165
+ J(splitStatements('$(echo b)# cm ; echo MARK')) === J(['$(echo b)# cm', 'echo MARK']));
166
+ ok('<(..)# keeps splitting on a background &',
167
+ J(splitStatementsWithSeparators('cat <(echo z)# cm & echo MARK').map((x) => x.text)) === J(['cat <(echo z)# cm', 'echo MARK']));
168
+ ok('>(..)# and $((..))# keep splitting',
169
+ J(splitStatements('tee >(cat)# c ; echo MARK')) === J(['tee >(cat)# c', 'echo MARK']) &&
170
+ J(splitStatements('echo $((1+2))# c ; echo MARK')) === J(['echo $((1+2))# c', 'echo MARK']));
171
+ ok('a # after a plain subshell or group is still a comment',
172
+ J(splitStatements('(echo b)# cm ; echo NOMARK')) === J(['(echo b)# cm ; echo NOMARK']) &&
173
+ J(splitStatements('((x=1))# cm ; echo NOMARK')) === J(['((x=1))# cm ; echo NOMARK']));
174
+ ok('$(..)# glued word: a later << is a real heredoc, a comment one is not',
175
+ countHeredocs('cat $(echo b)# <<EOF') === 1 && countHeredocs('(echo b)# <<EOF') === 0);
176
+ ok('a process substitution separator splits inside it', J(splitStatements('cat <(a; b)')) === J(['cat <(a', 'b)']));
177
+ }
178
+
179
+ // --- a doubled or escaped dollar opens no ANSI-C span (#217) ---
180
+ {
181
+ const J = JSON.stringify;
182
+ const BS = '\\';
183
+ ok('$$ before a quote: the quote is plain, so the later statement is split',
184
+ J(splitStatements(`echo $$'x${BS}' ; echo ran # '`)) === J([`echo $$'x${BS}'`, 'echo ran # \'']));
185
+ ok('an escaped $ before a quote is plain too',
186
+ J(splitStatements(`echo ${BS}$'x${BS}' ; echo ran # '`)) === J([`echo ${BS}$'x${BS}'`, 'echo ran # \'']));
187
+ ok('a bare $\' span still honours an escaped quote',
188
+ splitStatements(`echo $'x${BS}' ; y' ; echo ran`).length === 2);
189
+ ok('$$$\' opens a span (the third dollar is bare)',
190
+ splitStatements(`echo $$$'x${BS}' ; y' ; echo ran`).length === 2);
191
+ // A bare `$` ending a line stays bare across the backslash-newline continuation.
192
+ ok('$ then a continued $\' is PID + plain quote: three statements',
193
+ splitStatements(`echo $${BS}\n$'x${BS}' ; echo ran ; echo ''`).length === 3);
194
+ ok('$ then a continued quote opens ANSI-C: one statement',
195
+ splitStatements(`echo $${BS}\n'x${BS}' ; echo ran # '`).length === 1);
196
+ // A doubled dollar is the PID: the brace or bracket after it is a literal char.
197
+ ok('$${ is PID + a literal brace: three statements',
198
+ splitStatements('echo $${ ; echo ran ; echo }').length === 3);
199
+ ok('$$[ is PID + a literal bracket: three statements',
200
+ splitStatements('echo $$[ ; echo ran ; echo ]').length === 3);
201
+ ok('a continued $ then ${ is PID + a literal brace: three statements',
202
+ splitStatements(`echo $${BS}\n${'${'} ; echo ran ; echo }`).length === 3);
203
+ ok('$$$( is PID + a real substitution',
204
+ splitStatements('echo $$$(echo a; echo b)').length === 2);
205
+ ok('a bare ${x} still forms one frame', splitStatements('echo ${x:-a;b} ; echo ran').length === 2);
206
+ ok('a bare $[1;2] still forms one frame', splitStatements('echo $[1|2] ; echo ran').length === 2);
207
+ }
208
+
209
+ // --- nested subshells and substitutions inside quotes / ${..} (#212) ---
210
+ {
211
+ const J = JSON.stringify;
212
+ ok('((cmd); cmd) is nested subshells, not arithmetic', J(splitStatements('((echo a); cat .env)')) === J(['((echo a)', 'cat .env)']));
213
+ ok('$((cmd); cmd) falls back to a substitution too', J(splitStatements('x=$((echo a); cat .env)')) === J(['x=$((echo a)', 'cat .env)']));
214
+ ok('a real ((..)) and $((..)) stay arithmetic',
215
+ J(splitStatements('((a=(1+2)*3; b=1|2)); echo $((1|(2)))')) === J(['((a=(1+2)*3; b=1|2))', 'echo $((1|(2)))']));
216
+ ok('$(..) inside double quotes splits its inner ;', J(splitStatements('echo "$(echo a; cat .env)"')) === J(['echo "$(echo a', 'cat .env)"']));
217
+ ok('$(..) inside ${..} splits its inner ;', J(splitStatements('echo ${x:-$(echo a; cat .env)}')) === J(['echo ${x:-$(echo a', 'cat .env)}']));
218
+ ok('text after a closed $(..) in dq is quoted again', splitStatements('echo "$(a) ; b"').length === 1);
219
+ }
220
+
221
+ // --- a heredoc body starts after the LOGICAL line (#212) ---
222
+ {
223
+ const J = JSON.stringify;
224
+ ok('a quote opened after <<E runs the heredoc body past its close',
225
+ J(splitStatements("cat <<E 'a\nb'; cat .env\nbody\nE")) === J(["cat <<E 'a\nb'", 'cat .env']));
226
+ ok('same with a double-quoted newline', J(splitStatements('cat <<E "a\nb"; cat .env\nbody\nE')) === J(['cat <<E "a\nb"', 'cat .env']));
227
+ ok('same with an ANSI-C quoted newline', J(splitStatements("cat <<E $'a\nb'; cat .env\nbody\nE")) === J(["cat <<E $'a\nb'", 'cat .env']));
228
+ ok('1<<2 is no heredoc even before a multi-line quote',
229
+ J(splitStatements("echo 1<<2 ; echo 'a\nb'; cat .env")) === J(['echo 1<<2', "echo 'a\nb'", 'cat .env']));
230
+ ok('an ordinary heredoc body still starts on the next line',
231
+ J(splitStatements('cat <<E\nx; y\nE\ncat .env')) === J(['cat <<E', 'cat .env']));
232
+ ok('the $(cat <<EOF) idiom inside "..." keeps its body on the next line',
233
+ heredocBodies('x "$(cat <<\'EOF\'\nhi; there\nEOF\n)"; cat .env')[0].body === 'hi; there' &&
234
+ splitStatements('x "$(cat <<\'EOF\'\nhi; there\nEOF\n)"; cat .env').pop() === 'cat .env');
235
+ }
236
+
237
+ // --- a newline inside an open $(..) keeps the logical line open for an outer heredoc (#212) ---
238
+ {
239
+ const J = JSON.stringify;
240
+ ok('an outer heredoc waits for a multi-line "$(..)" to close',
241
+ J(splitStatements('cat <<E "$(true\ntrue)"; cat .env\nbody\nE')) === J(['cat <<E "$(true', 'true)"', 'cat .env']));
242
+ ok('an outer heredoc waits for a multi-line bare $(..) to close',
243
+ J(splitStatements('cat <<E $(true\ntrue); cat .env\nbody\nE')) === J(['cat <<E $(true', 'true)', 'cat .env']));
244
+ ok('a heredoc inside the innermost $(..) starts its body on the next physical line',
245
+ heredocBodies('gh issue comment 1 --body "$(cat <<\'EOF\'\nbody\nEOF\n)"; cat .env')[0].body === 'body' &&
246
+ splitStatements('gh issue comment 1 --body "$(cat <<\'EOF\'\nbody\nEOF\n)"; cat .env').pop() === 'cat .env');
247
+ }
248
+
249
+ // --- per-statement heredoc counts pair with heredocBodies (#212) ---
250
+ {
251
+ const J = JSON.stringify;
252
+ const sumBoth = (c) => [
253
+ splitStatementsWithSeparators(c).reduce((n, s) => n + s.heredocs, 0),
254
+ splitStatementsWithSeparators(c).reduce((n, s) => n + countHeredocs(s), 0),
255
+ ];
256
+ const cases = [
257
+ 'echo "$(true; echo hi) <<X" ; cat <<\'EOF\'\nbody\nEOF',
258
+ 'echo "$(true; cat <<A\nb\nA\n) <<X" ; cat <<B\nz\nB',
259
+ "cat <<E 'a\nb'; cat <<F\nbody\nE\nF",
260
+ 'echo $((1<<3)); cat <<A <<B\n1\nA\n2\nB',
261
+ ];
262
+ for (const c of cases) {
263
+ const n = heredocBodies(c).length;
264
+ ok('heredoc count pairs for ' + J(c), sumBoth(c).every((x) => x === n));
265
+ }
266
+ // property check over a fragment soup
267
+ const frags = ['echo "$(true; echo hi) <<X"', 'cat <<E\nb\nE', "echo '<<Q'", 'echo $((1<<2))', 'x <<A <<B\n1\nA\n2\nB',
268
+ 'echo "$(cat <<Z\nq\nZ\n)"', 'echo "$(a; b <<Y\ny\nY\n)"', "cat <<E 'a\nb'", 'true # <<C', 'echo ${x:-$(a; b)} <<D\nd\nD'];
269
+ const seps = [' ; ', '\n', ' && ', ' | ', ' & '];
270
+ let seed = 12345;
271
+ const rnd = (n) => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed % n; };
272
+ let bad = null;
273
+ for (let t = 0; t < 400 && !bad; t++) {
274
+ let c = frags[rnd(frags.length)];
275
+ for (let k = rnd(4); k > 0; k--) c += seps[rnd(seps.length)] + frags[rnd(frags.length)];
276
+ const n = heredocBodies(c).length;
277
+ if (!sumBoth(c).every((x) => x === n)) bad = c;
278
+ }
279
+ ok('heredoc count pairs over a fragment soup ' + J(bad), bad === null);
280
+ }
281
+
282
+ // --- countHeredocs on a string is a pure, standalone re-lex (#212) ---
283
+ {
284
+ const probe = 'echo "x" <<A';
285
+ const before = countHeredocs(probe);
286
+ const tail = 'echo hi) <<X"'; // the tail a cut inside "$(..)" leaves; standalone it lexes to 1
287
+ const tailBefore = countHeredocs(tail);
288
+ splitStatements('echo "$(true; true)" <<A\nb\nA');
289
+ splitStatementsWithSeparators('cat <<E \'a\nb\'; cat <<F\nbody\nE\nF');
290
+ splitStatements('echo "$(true; echo hi) <<X"');
291
+ ok('countHeredocs(string) is unchanged by unrelated splits',
292
+ before === 1 && countHeredocs(probe) === before && tailBefore === 1 && countHeredocs(tail) === tailBefore);
293
+ ok('countHeredocs(string) lexes the text standalone, from an empty stack',
294
+ countHeredocs('b)" <<A') === 0 && countHeredocs('true) <<A') === 1);
295
+ ok('countHeredocs(entry) returns its whole-scan count',
296
+ countHeredocs(splitStatementsWithSeparators('echo "$(true; cat <<A\nb\nA\n) <<X"')[1]) === 1);
297
+ }
298
+
299
+ // --- scanning stays linear on paren-heavy input (#212) ---
300
+ {
301
+ const time = (c) => { const t = Date.now(); splitStatements(c); return Date.now() - t; };
302
+ const k = 16000;
303
+ ok('nested (( ... ) y scans in linear time', time('(( '.repeat(k) + ') y'.repeat(2 * k + 2)) < 500);
304
+ ok('a 200KB line scans quickly', time('echo (a) $((1+2)) '.repeat(10000)) < 500);
305
+ ok('a 5000-line command scans quickly', time('echo $((1<<2)); ((a)); cat <<E\nbody\nE\n'.repeat(1700)) < 500);
306
+ }
307
+
31
308
  // --- tokenize: quoted multi-word phrase stays ONE token ---
32
309
  ok('tokenize keeps a multi-word double-quoted phrase as one token',
33
310
  JSON.stringify(tokenize('git commit -m "document .env usage"')) ===
@@ -46,4 +323,161 @@ ok('scanArgsSkippingPattern skips the pattern positional (grep ".env" config.txt
46
323
  ok('scanArgsSkippingPattern still finds a secret FILE operand after the pattern',
47
324
  scanArgsSkippingPattern(['-n', 'KEY', '.env'], SEARCH_VERB_CONFIG.grep, isDotEnv) === '.env');
48
325
 
326
+ // --- substitutionBodies: outermost command-substitution bodies (#214) ---
327
+ {
328
+ const bodies = (c) => JSON.stringify(substitutionBodies(c));
329
+ ok('substitutionBodies finds an unquoted $(..)', bodies('echo $(cat .env)') === JSON.stringify(['cat .env']));
330
+ ok('substitutionBodies finds a double-quoted $(..)', bodies('echo "$(cat .env)"') === JSON.stringify(['cat .env']));
331
+ ok('substitutionBodies keeps a ; inside the body', bodies('echo "$(true; cat .env)"') === JSON.stringify(['true; cat .env']));
332
+ ok('substitutionBodies skips a single-quoted $(..)', bodies("echo '$(cat .env)'") === '[]');
333
+ ok('substitutionBodies skips $((..)) arithmetic', bodies('echo "$((1+2))"') === '[]');
334
+ ok('substitutionBodies finds a $(..) inside ${..} (it runs)', bodies('echo ${x:-$(cat .env)}') === JSON.stringify(['cat .env']));
335
+ ok('substitutionBodies skips a literal ${..} with no substitution', bodies('echo ${x:-$HOME}') === '[]');
336
+ ok('substitutionBodies skips a comment', bodies('echo hi # $(cat .env)') === '[]');
337
+ ok('substitutionBodies returns only the outermost of a nest',
338
+ bodies('echo "$(echo "$(cat .env)")"') === JSON.stringify(['echo "$(cat .env)"']));
339
+ ok('substitutionBodies finds <(..) and >(..)', bodies('diff <(cat a) >(tee b)') === JSON.stringify(['cat a', 'tee b']));
340
+ ok('substitutionBodies finds several siblings', bodies('echo $(a) "$(b)"') === JSON.stringify(['a', 'b']));
341
+ ok('substitutionBodies joins a multi-line body with a newline', bodies('echo "$(\ncat .env\n)"') === JSON.stringify(['\ncat .env\n']));
342
+ ok('substitutionBodies skips heredoc bodies', bodies('cat <<E\n$(cat .env)\nE') === '[]');
343
+ ok('substitutionBodies returns the remainder of an unterminated body', bodies('echo "$(cat .env') === JSON.stringify(['cat .env']));
344
+ ok('substitutionBodies is empty for plain text', bodies('echo hi') === '[]');
345
+ // W2: a heredoc inside a body stays in the body text (the recursion strips it itself)
346
+ ok('substitutionBodies keeps a heredoc inside a body',
347
+ bodies('echo "$(cat <<E\nfoo\nE\n; cat .env)"') === JSON.stringify(['cat <<E\nfoo\nE\n; cat .env']));
348
+ ok('substitutionBodies skips a $(..) inside a heredoc inside a body',
349
+ bodies('echo $(cat <<E\n$(cat .env)\nE\n)') === JSON.stringify(['cat <<E\n$(cat .env)\nE\n']));
350
+ // W3: a substitution inside arithmetic runs
351
+ ok('substitutionBodies finds $(..) inside $((..))', bodies('echo "$(( $(cat .env) ))"') === JSON.stringify(['cat .env']));
352
+ ok('substitutionBodies finds $(..) inside $[..]', bodies('echo "$[ $(cat .env) ]"') === JSON.stringify(['cat .env']));
353
+ ok('substitutionBodies keeps arithmetic parens balanced around a sub',
354
+ bodies('echo "$(( ($(a)) + 1 ))" "$(b)"') === JSON.stringify(['a', 'b']));
355
+ ok('substitutionBodies still skips plain nested arithmetic', bodies('echo $((a+(b)))') === '[]');
356
+ // #216: backtick substitutions
357
+ const cat = JSON.stringify(['cat .env']);
358
+ ok('substitutionBodies finds an unquoted backtick body', bodies('echo `cat .env`') === cat);
359
+ ok('substitutionBodies finds a double-quoted backtick body', bodies('echo "`cat .env`"') === cat);
360
+ ok('substitutionBodies finds a backtick body in an assignment', bodies('x="`cat .env`"') === cat);
361
+ ok('substitutionBodies finds a backtick body inside ${..}', bodies('echo "${x:-`cat .env`}"') === cat);
362
+ ok('substitutionBodies skips a single-quoted backtick', bodies("echo '`cat .env`'") === '[]');
363
+ ok('substitutionBodies skips a backtick inside $\'..\'', bodies("echo $'`cat .env`'") === '[]');
364
+ ok('substitutionBodies skips a backtick in a comment', bodies('echo hi # `cat .env`') === '[]');
365
+ ok('substitutionBodies skips an escaped backtick', bodies('echo "\\`cat .env\\`"') === '[]');
366
+ ok('substitutionBodies reports a bq inside $(..) only as part of the outer body',
367
+ bodies('echo "$(echo `cat .env`)"') === JSON.stringify(['echo `cat .env`']));
368
+ ok('substitutionBodies reports a $(..) inside a bq only as part of the bq body',
369
+ bodies('echo `echo $(cat .env)`') === JSON.stringify(['echo $(cat .env)']));
370
+ ok('substitutionBodies returns the remainder of an unterminated backtick', bodies('echo "`cat .env') === cat);
371
+ ok('substitutionBodies unescapes a nested backtick in a bq body',
372
+ bodies('echo `echo \\`cat .env\\``') === JSON.stringify(['echo `cat .env`']));
373
+ // review: the span is opaque (a quote inside it does not desync the lexer)
374
+ ok('substitutionBodies: a quote inside a bq body does not hide its close',
375
+ bodies('echo "`echo \'"\'`"; cat .env') === JSON.stringify(["echo '\"'"]));
376
+ ok('substitutionBodies: a $(..) after a quote-bearing bq is still found',
377
+ bodies('echo "`echo \'x`"; echo "$(cat .env)"') === JSON.stringify(["echo 'x", 'cat .env']));
378
+ ok('splitStatements does not cut at a ; inside a backtick span',
379
+ JSON.stringify(splitStatements('echo `a; b` ; c')) === JSON.stringify(['echo `a; b`', 'c']));
380
+ ok('splitStatements does not cut at a newline inside a backtick span',
381
+ JSON.stringify(splitStatements('echo `a\nb` ; c')) === JSON.stringify(['echo `a\nb`', 'c']));
382
+ // review: \" is unescaped in a bq body only inside double quotes
383
+ ok('substitutionBodies unescapes \\" in a bq body inside double quotes',
384
+ bodies('echo "`cat \\".env\\"`"') === JSON.stringify(['cat ".env"']));
385
+ ok('substitutionBodies keeps \\" in an unquoted bq body',
386
+ bodies('echo `cat \\".env\\"`') === JSON.stringify(['cat \\".env\\"']));
387
+ ok('substitutionBodies: a dq below a $(..) does not count as double-quoted',
388
+ bodies('echo "$(echo `cat \\".env\\"`)"') === JSON.stringify(['echo `cat \\".env\\"`']));
389
+ // review: multi-line span and double-nested escaped backtick
390
+ ok('substitutionBodies finds a backtick span across lines', bodies('echo `true\ncat .env\n`') === JSON.stringify(['true\ncat .env\n']));
391
+ ok('substitutionBodies keeps a double-nested escaped backtick for the recursion',
392
+ bodies('echo `echo \\`echo \\\\\\`cat .env\\\\\\`\\``') === JSON.stringify(['echo `echo \\`cat .env\\``']));
393
+ }
394
+
395
+ // --- heredocSubstitutionBodies: substitutions in an unquoted heredoc body (#218) ---
396
+ {
397
+ const hb = (b) => JSON.stringify(heredocSubstitutionBodies(b));
398
+ const cat = JSON.stringify(['cat .env']);
399
+ ok('heredocSubstitutionBodies finds a $(..)', hb('$(cat .env)') === cat);
400
+ ok('heredocSubstitutionBodies finds a $(..) after an apostrophe (quotes are literal)', hb("it's $(cat .env)") === cat);
401
+ ok('heredocSubstitutionBodies finds a $(..) after a double quote', hb('say "hi $(cat .env)') === cat);
402
+ ok('heredocSubstitutionBodies finds a backtick body', hb('`cat .env`') === cat);
403
+ ok('heredocSubstitutionBodies skips an escaped $(..)', hb('\\$(cat .env)') === '[]');
404
+ ok('heredocSubstitutionBodies skips an escaped backtick', hb('\\`cat .env\\`') === '[]');
405
+ ok('heredocSubstitutionBodies finds substitutions on several lines', hb('a $(one)\nb\n`two`') === JSON.stringify(['one', 'two']));
406
+ ok('heredocSubstitutionBodies returns only the outer of a nest', hb('$(a $(b))') === JSON.stringify(['a $(b)']));
407
+ ok('heredocSubstitutionBodies finds a $(..) inside ${..}', hb('${x:-$(cat .env)}') === cat);
408
+ ok('heredocSubstitutionBodies joins a backslash-newline before lexing', hb('$(cat .e\\\nnv)') === cat);
409
+ ok('heredocSubstitutionBodies joins a backslash-newline inside the opener', hb('$\\\n(cat .env)') === cat);
410
+ ok('heredocSubstitutionBodies keeps a newline after an escaped backslash', hb('a\\\\\n$(cat .env)') === cat);
411
+ ok('heredocSubstitutionBodies does not join an escaped backslash with its newline', hb('$(a \\\\\nb)') === JSON.stringify(['a \\\\\nb']));
412
+ ok('heredocSubstitutionBodies keeps a heredoc nested in a substitution with its own body',
413
+ hb('$(cat <<B\n)\nB\ncat .env)') === JSON.stringify(['cat <<B\n)\nB\ncat .env']));
414
+ ok('heredocSubstitutionBodies finds a tab-indented $(..)', hb('\t$(cat .env)') === cat);
415
+ ok('heredocSubstitutionBodies is empty for plain text', hb('cat .env') === '[]');
416
+ ok('heredocSubstitutionBodies treats <(..) as literal text', hb('diff <(cat .env)') === '[]');
417
+ ok('heredocSubstitutionBodies treats a # as literal text', hb('# $(cat .env)') === cat);
418
+ }
419
+
420
+ // --- functionSubstitutions (bash 5.3 ${ cmd; } / ${| cmd; }) ---
421
+ {
422
+ const fs_ = (c) => functionSubstitutions(c).map((f) => f.payload);
423
+ ok('functionSubstitutions finds ${ cmd; }', JSON.stringify(fs_('echo ${ date; }')) === JSON.stringify([' date; ']));
424
+ ok('functionSubstitutions finds ${| cmd; } (value substitution)', fs_('echo ${| REPLY=x; }').length === 1);
425
+ ok('functionSubstitutions finds ${<tab>', fs_('echo ${\tdate; }').length === 1);
426
+ ok('functionSubstitutions finds ${ at the end of a line', fs_('echo ${\ndate\n}').length === 1);
427
+ ok('functionSubstitutions reports the offset of the opener', functionSubstitutions('echo ${ date; }')[0].at === 5);
428
+ ok('functionSubstitutions finds one inside double quotes', fs_('echo "a ${ date; } b"').length === 1);
429
+ ok('functionSubstitutions finds one inside $(..)', fs_('echo $(echo ${ date; })').length === 1);
430
+ ok('functionSubstitutions ignores a plain ${x}', fs_('echo ${x} ${x:-y} ${#x}').length === 0);
431
+ ok('functionSubstitutions ignores single quotes', fs_("echo '${ date; }'").length === 0);
432
+ ok('functionSubstitutions ignores ANSI-C quotes', fs_("echo $'${ date; }'").length === 0);
433
+ ok('functionSubstitutions ignores a comment', fs_('echo hi # ${ date; }').length === 0);
434
+ ok('functionSubstitutions ignores an escaped dollar', fs_('echo \\${ date; }').length === 0);
435
+ ok('functionSubstitutions ignores the 2nd dollar of $$', fs_('echo $${ date; }').length === 0);
436
+ ok('functionSubstitutions ignores a quoted-heredoc body', fs_("cat <<'E'\n${ date; }\nE").length === 0);
437
+ ok('functionSubstitutions finds one in an unquoted heredoc body', fs_('cat <<E\n${ date; }\nE').length === 1);
438
+ ok('functionSubstitutions ends the payload at the closing brace', fs_('echo ${ a; } ${ b; }').join('|') === ' a; | b; ');
439
+ ok('functionSubstitutions keeps a nested ${x} in the payload', fs_('echo ${ echo ${x}; }')[0] === ' echo ${x}; ');
440
+ ok('functionSubstitutions runs an unclosed payload to the end', fs_('echo ${ date')[0] === ' date');
441
+ }
442
+
443
+ // --- inWordContinuations (a backslash-newline inside a word) ---
444
+ {
445
+ const iw = (c) => inWordContinuations(c);
446
+ ok('inWordContinuations finds a split word', iw('cat .e\\\nnv').length === 1);
447
+ ok('inWordContinuations reports the glued junction', iw('cat .e\\\nnv')[0].junction === '.e' + 'nv');
448
+ ok('inWordContinuations ignores a continuation after a space', iw('gh issue comment 1 \\\n --body x').length === 0);
449
+ ok('inWordContinuations ignores a continuation after a tab', iw('echo\t\\\nhi').length === 0);
450
+ ok('inWordContinuations ignores a continuation before an indented line', iw('echo a\\\n b').length === 0);
451
+ ok('inWordContinuations ignores single-quoted text', iw("echo 'a\\\nb'").length === 0);
452
+ ok('inWordContinuations ignores double-quoted text', iw('echo "a\\\nb"').length === 0);
453
+ ok('inWordContinuations ignores a comment', iw('# a\\\nb').length === 0);
454
+ ok('inWordContinuations ignores an escaped backslash', iw('echo a\\\\\nb').length === 0);
455
+ ok('inWordContinuations finds one inside $(..)', iw('echo $(cat .e\\\nnv)').length === 1);
456
+ ok('inWordContinuations ignores a heredoc body', iw('cat <<E\na\\\nb\nE').length === 0);
457
+ ok('inWordContinuations is empty for plain text', iw('echo hi').length === 0);
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
+
473
+ // --- the new hooks leave the existing lexer output untouched ---
474
+ {
475
+ const c = 'a ${ b; } c; d \\\ne | f $(g ${ h; })\ncat <<E\n${ x; }\nE';
476
+ const before = JSON.stringify([splitStatements(c), splitStatementsWithSeparators(c), substitutionBodies(c), heredocBodies(c)]);
477
+ functionSubstitutions(c);
478
+ inWordContinuations(c);
479
+ ok('functionSubstitutions / inWordContinuations do not change the other exports',
480
+ before === JSON.stringify([splitStatements(c), splitStatementsWithSeparators(c), substitutionBodies(c), heredocBodies(c)]));
481
+ }
482
+
49
483
  console.log(`✓ shell-scan: ${passed} assertions passed`);
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/trailhead-uat-backfill.js. Run: node uat-backfill.test.js
3
+ // No framework: plain asserts, same style as dashboard.test.js.
4
+ //
5
+ // Layers covered:
6
+ // 1. main() against a fake tracker behind an injected `gh` runner: what is
7
+ // tagged, what is skipped, partial failure, the label ensure, the marker.
8
+ // 2. End-to-end: the script itself with a failing fake `gh` on PATH.
9
+ const assert = require('assert');
10
+ const { spawnSync } = require('child_process');
11
+ const path = require('path');
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+
15
+ const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-uat-backfill.js');
16
+ const { main } = require(scriptPath);
17
+
18
+ let passed = 0;
19
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
20
+
21
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'uat-backfill-'));
22
+ let markerSeq = 0;
23
+ const newMarker = () => path.join(tmp, `m${markerSeq++}`, 'uat-backfill');
24
+
25
+ // --- fake tracker ------------------------------------------------------------
26
+ function makeTracker(init = {}) {
27
+ const t = {
28
+ issues: init.issues || [],
29
+ labelExists: init.labelExists === undefined ? false : init.labelExists,
30
+ createFails: !!init.createFails,
31
+ failEdit: init.failEdit || [],
32
+ failList: !!init.failList,
33
+ calls: [],
34
+ tagged: [],
35
+ };
36
+ const res = (stdout, status = 0) => ({ status, stdout, stderr: status ? 'forced failure' : '' });
37
+ t.gh = (args) => {
38
+ t.calls.push(args.join(' '));
39
+ if (args[0] === 'label' && args[1] === 'create') {
40
+ if (t.createFails) return res('', 1);
41
+ if (t.labelExists) return res('', 1); // already exists: gh exits non-zero
42
+ t.labelExists = true;
43
+ return res('');
44
+ }
45
+ if (args[0] === 'label' && args[1] === 'list') return res(JSON.stringify(t.labelExists ? [{ name: 'trailhead:uat' }] : []));
46
+ if (args[0] === 'issue' && args[1] === 'list') {
47
+ if (t.failList) return res('', 1);
48
+ return res(JSON.stringify(t.issues.map((i) => ({ number: i.number, body: i.body, labels: (i.labels || []).map((name) => ({ name })) }))));
49
+ }
50
+ if (args[0] === 'issue' && args[1] === 'edit') {
51
+ const n = Number(args[2]);
52
+ if (t.failEdit.includes(n)) return res('', 1);
53
+ t.tagged.push(n);
54
+ return res('');
55
+ }
56
+ return res('', 1);
57
+ };
58
+ return t;
59
+ }
60
+
61
+ function run(t, argv) {
62
+ let out = '';
63
+ const code = main(argv, { gh: t.gh, stdout: { write: (s) => { out += s; } }, stderr: { write: () => {} } });
64
+ let json = null;
65
+ try { json = JSON.parse(out.trim()); } catch { /* not json */ }
66
+ return { code, out, json };
67
+ }
68
+
69
+ const uat = (n) => ({ number: n, body: `Intro\n\nUAT of: [Parent](https://github.com/o/r/issues/1)\n\n- [ ] step` });
70
+
71
+ // --- tagging -----------------------------------------------------------------
72
+ {
73
+ const t = makeTracker({ issues: [uat(10), { number: 11, body: 'See the UAT of: thing mid-line' }, { number: 12, body: 'x', labels: [] }] });
74
+ const r = run(t, ['--repo', 'o/r']);
75
+ ok('tags a UAT of: line-start match', r.code === 0 && JSON.stringify(t.tagged) === '[10]');
76
+ ok('skips a body that only mentions UAT of: mid-line', !t.tagged.includes(11));
77
+ ok('prints one JSON line', r.json && r.json.status === 'ok' && JSON.stringify(r.json.tagged) === '[10]' && r.json.failed.length === 0 && r.out.trim().split('\n').length === 1);
78
+ ok('ensures the label before listing', t.calls.findIndex((c) => c.startsWith('label create trailhead:uat')) < t.calls.findIndex((c) => c.startsWith('issue list')));
79
+ ok('confirms the label with a label list', t.calls.some((c) => /^label list .*--search trailhead:uat/.test(c)));
80
+ ok('list query excludes the label and caps at 1000', t.calls.some((c) => c.startsWith('issue list') && c.includes('-label:trailhead:uat') && c.includes('--limit 1000') && c.includes('--json number,body,labels')));
81
+ }
82
+ {
83
+ const t = makeTracker({ issues: [{ ...uat(20), labels: ['trailhead:uat'] }, uat(21), uat(22)] });
84
+ const r = run(t, []);
85
+ ok('skips an already-labelled ticket, tags several', JSON.stringify(t.tagged) === '[21,22]' && JSON.stringify(r.json.tagged) === '[21,22]');
86
+ }
87
+ {
88
+ const t = makeTracker({ issues: [uat(30), uat(31), uat(32)], failEdit: [31] });
89
+ const mk = newMarker();
90
+ const r = run(t, ['--marker', mk]);
91
+ ok('a failing edit is reported and does not stop the rest', r.code === 0 && JSON.stringify(r.json.tagged) === '[30,32]' && JSON.stringify(r.json.failed) === '[31]');
92
+ ok('a run with failed edits writes no marker', !fs.existsSync(mk));
93
+ }
94
+ {
95
+ const t = makeTracker({ issues: [uat(40)], failList: true });
96
+ const mk = newMarker();
97
+ const r = run(t, ['--marker', mk]);
98
+ ok('a failing list read exits non-zero with nothing written', r.code === 1 && t.tagged.length === 0 && r.json && r.json.status === 'error' && !fs.existsSync(mk));
99
+ }
100
+
101
+ // --- label ensure ------------------------------------------------------------
102
+ {
103
+ const t = makeTracker({ labelExists: true, issues: [uat(50)] });
104
+ const r = run(t, []);
105
+ ok('an existing label (create exits non-zero) is tolerated', r.code === 0 && JSON.stringify(t.tagged) === '[50]');
106
+ }
107
+ {
108
+ const t = makeTracker({ createFails: true, issues: [uat(51)] });
109
+ const mk = newMarker();
110
+ const r = run(t, ['--marker', mk]);
111
+ ok('a create failure with the label absent is an error, nothing tagged, no marker', r.code === 1 && r.json.status === 'error' && t.tagged.length === 0 && !fs.existsSync(mk));
112
+ }
113
+
114
+ // --- marker ------------------------------------------------------------------
115
+ {
116
+ const t = makeTracker({ issues: [uat(60)] });
117
+ const mk = newMarker();
118
+ const r = run(t, ['--marker', mk]);
119
+ ok('a clean run writes the marker', r.code === 0 && fs.existsSync(mk));
120
+ const t2 = makeTracker({ issues: [uat(61)] });
121
+ const r2 = run(t2, ['--marker', mk]);
122
+ ok('a present marker exits skipped without calling gh', r2.code === 0 && r2.json.status === 'skipped' && t2.calls.length === 0 && t2.tagged.length === 0);
123
+ }
124
+ {
125
+ const t = makeTracker({ issues: [] });
126
+ const mk = newMarker();
127
+ const r = run(t, ['--marker', mk]);
128
+ ok('a clean run that tags nothing still writes the marker', r.code === 0 && JSON.stringify(r.json.tagged) === '[]' && fs.existsSync(mk));
129
+ }
130
+
131
+ // --- usage -------------------------------------------------------------------
132
+ for (const argv of [['--bogus'], ['--repo'], ['--repo', 'nope'], ['--marker']]) {
133
+ const t = makeTracker();
134
+ const r = run(t, argv);
135
+ ok(`usage error exit 2: ${argv.join(' ')}`, r.code === 2 && t.calls.length === 0);
136
+ }
137
+
138
+ // --- end-to-end with a failing fake gh on PATH --------------------------------
139
+ {
140
+ const binDir = fs.mkdtempSync(path.join(tmp, 'bin-'));
141
+ fs.writeFileSync(path.join(binDir, 'gh'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
142
+ const mk = newMarker();
143
+ const res = spawnSync('node', [scriptPath, '--repo', 'o/r', '--marker', mk], { encoding: 'utf8', env: { ...process.env, PATH: binDir + path.delimiter + process.env.PATH } });
144
+ ok('e2e: gh failing exits 1 with one JSON error line and no marker', res.status === 1 && JSON.parse(res.stdout.trim()).status === 'error' && !fs.existsSync(mk));
145
+ const bad = spawnSync('node', [scriptPath, '--bogus'], { encoding: 'utf8' });
146
+ ok('e2e: usage error exits 2', bad.status === 2);
147
+ }
148
+
149
+ fs.rmSync(tmp, { recursive: true, force: true });
150
+ console.log(`uat-backfill.test.js: ${passed} checks passed`);