@marcomigozzi/trailhead 0.12.1 → 0.14.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 (66) hide show
  1. package/README.it.md +38 -13
  2. package/README.md +48 -13
  3. package/bin/lib/codex-projection.js +18 -11
  4. package/bin/lib/codex-projection.test.js +39 -11
  5. package/bin/lib/install-verify.js +10 -5
  6. package/bin/lib/install-verify.test.js +51 -0
  7. package/bin/trailhead.js +37 -25
  8. package/bin/trailhead.test.js +162 -5
  9. package/package.json +4 -1
  10. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  11. package/plugins/trailhead/commands/panel.md +10 -0
  12. package/plugins/trailhead/hooks/hooks.json +3 -18
  13. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  14. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  15. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  16. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  17. package/plugins/trailhead/hooks/lib/shell-scan.js +496 -64
  18. package/plugins/trailhead/hooks/lib/shell-scan.test.js +420 -0
  19. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  20. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  21. package/plugins/trailhead/hooks/lib/work-state.test.js +252 -0
  22. package/plugins/trailhead/hooks/mod/README.md +187 -0
  23. package/plugins/trailhead/hooks/mod/map-pane-view.ts +453 -0
  24. package/plugins/trailhead/hooks/mod/map-pane.tsx +663 -0
  25. package/plugins/trailhead/hooks/mod/register.tsx +280 -0
  26. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  27. package/plugins/trailhead/hooks/mod/work-state-view.ts +93 -0
  28. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  29. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  30. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  31. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  32. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  33. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  34. package/plugins/trailhead/hooks/trailhead-install-guard.js +22 -12
  35. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +2 -1
  36. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  37. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +79 -7
  38. package/plugins/trailhead/hooks/trailhead-mockup-link-stop.test.js +1 -1
  39. package/plugins/trailhead/hooks/trailhead-search-guard.js +19 -9
  40. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +2 -1
  41. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  42. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  43. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  44. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  45. package/plugins/trailhead/skills/_shared/choices.md +3 -1
  46. package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -4
  47. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  48. package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
  49. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  50. package/plugins/trailhead/skills/_shared/teamwork.md +4 -2
  51. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  52. package/plugins/trailhead/skills/_shared/techniques/prototype.md +15 -16
  53. package/plugins/trailhead/skills/_shared/techniques.md +1 -1
  54. package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
  55. package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
  56. package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
  57. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +3 -1
  58. package/plugins/trailhead/templates/trailhead-work-state.js +199 -0
  59. package/plugins/trailhead/tests/map-pane-ui.test.ts +1374 -0
  60. package/plugins/trailhead/tests/map-pane-view.test.ts +565 -0
  61. package/plugins/trailhead/tests/mod.test.ts +14 -0
  62. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  63. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  64. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  65. package/plugins/trailhead/tests/work-state-view.test.ts +86 -0
  66. package/plugins/trailhead/types/index.d.ts +69 -0
@@ -42,87 +42,512 @@ function stripQuotes(t) {
42
42
 
43
43
  // --- heredoc bodies ------------------------------------------------------------
44
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) {
45
+ // What follows a `<<` introducer: an optional `-` (the terminator line may then
46
+ // be indented with leading tabs), then the tag, quoted or bare. Captures the
47
+ // `-` and the tag (single-quoted, double-quoted or bare) separately. Sticky, so
48
+ // it only matches right at the operator.
49
+ const HEREDOC_TAG_RE = /(-?)[ \t]*(?:'([^']+)'|"([^"]+)"|([A-Za-z0-9_]+))/y;
50
+
51
+ // A `#` starts a comment, and a `((` an arithmetic command, only at a word start.
52
+ const WORD_START_PREV_RE = /[\s;&|()]/;
53
+
54
+ // The ONE lexer behind heredoc detection and statement splitting. It scans a
55
+ // line with the shell context a bare regex lacks: a `<<` inside quotes, a
56
+ // comment, `$((..))`, `$[..]`, `((..))` or after a backslash is not an
57
+ // introducer, and `<<<` is a herestring. `st.stack` holds the open contexts as
58
+ // uniform { k, ... } frames: k is 'sq', 'dq', 'ansi' (`$'..'`), 'br' (`${..}`),
59
+ // 'sub' (`$(..)`, with a paren count n), 'ar' (an arithmetic span) or 'hd' (the
60
+ // body of an unquoted heredoc, only ever the bottom frame of a dedicated scan,
61
+ // see heredocSubstitutionBodies); `st.cont` is set when the line
62
+ // ends in a backslash-newline continuation. It is mutated and carried to the next line, so a quote or arithmetic
63
+ // span that runs across lines keeps suppressing introducers. A `$(..)` is code
64
+ // again, even inside double quotes, so its heredocs count. Returns
65
+ // [{ at, depth, dash, tag, quoted }] (`at` is the index of the `<<`, `depth` the
66
+ // number of open contexts there). When `seps` is given, the statement separators found
67
+ // in splittable code (top level or inside `$(..)`, never inside a quote,
68
+ // arithmetic span or comment) are pushed to it as { at, len, op }, so the
69
+ // splitters cut exactly where this lexer says code is. Best-effort: not a full
70
+ // shell parser (`$(( (a) ))` nesting tricks and `<<\TAG` are not modelled,
71
+ // nor is a backtick nested inside a backtick span other than as an escaped
72
+ // char; nor is the body order of a heredoc nested in another heredoc's pending `"$(..)"`, e.g. `cat <<E "$(cat <<A ..)"`).
73
+ // When `subs` is given ({ open(at, bq), close(at) }), it is told where each
74
+ // OUTERMOST `$(..)` / `<(..)` / `>(..)` / backtick body starts (the index after
75
+ // the opener; `bq` is true for a backtick) and where it ends (the index of the
76
+ // closing `)` or backtick), so substitutionBodies reads the bodies off this
77
+ // lexer instead of running a second one.
78
+ // A backtick span is NOT a stack frame: it lives in `st.bq` ({ outer } or unset)
79
+ // and is opened by an unescaped backtick in code, `dq`, `br` or `sub` context
80
+ // (never in `sq`, `ansi`, arithmetic or a comment) and closed by the next
81
+ // unescaped one. The span is OPAQUE: while it is open only a backslash (skips
82
+ // the next char) and the closing backtick are looked at, as in the shell, which
83
+ // finds the closing backtick first and parses the body separately (so a quote
84
+ // inside it cannot desync this lexer). Its separators and heredocs therefore
85
+ // do not count; the guards recurse into the body via substitutionBodies. It is
86
+ // kept off the stack so heredoc depth is untouched. `st.bq.dq` records whether
87
+ // it sits in double quotes (nearest `dq`/`sub` frame is `dq`), where the shell
88
+ // also unescapes `\"` in the body. A span that never closes runs to the end of
89
+ // the input.
90
+ function scanLine(line, st, seps, subs) {
91
+ const found = [];
92
+ const stack = st.stack;
93
+ const parens = {}; // parenIsArith's per-line match cache
94
+ const cont0 = st.cont; // the previous line ended in a backslash: this one continues it
95
+ let escAt = -2; // index of the last escaped char
96
+ // Index of the last bare `$` (not escaped, not the 2nd of `$$`). A bare `$` that
97
+ // ended the previous line before its `\<nl>` is still adjacent: it counts as index -1.
98
+ let bareAt = cont0 && st.bareEnd ? -1 : -2;
99
+ st.cont = false;
100
+ st.bareEnd = false;
101
+ // Is the char before index i a word boundary? An escaped char (`\ #x`) is not.
102
+ // The `)` that closes an expansion (`$(..)`, `<(..)`, `$((..))`) ends no word
103
+ // either: `$(x)# y` is not a comment, `(x)# y` is.
104
+ let expCloseAt = -2;
105
+ // Push a `$(..)` / `<(..)` frame; the outermost one is reported to `subs`.
106
+ const openSub = (bodyAt) => {
107
+ const outer = !stack.some((f) => f.k === 'sub');
108
+ stack.push({ k: 'sub', n: 0, outer });
109
+ if (subs && outer) subs.open(bodyAt);
110
+ };
111
+ const isBoundary = (i) => WORD_START_PREV_RE.test(line[i - 1]) && escAt !== i - 1 && expCloseAt !== i - 1;
112
+ for (let i = 0; i < line.length; i++) {
113
+ const c = line[i];
114
+ if (st.bq) { // opaque backtick span: only a backslash and the closing backtick matter
115
+ if (c === '\\') i++;
116
+ else if (c === '`') { if (subs && st.bq.outer) subs.close(i); st.bq = null; }
117
+ continue;
118
+ }
119
+ const top = stack[stack.length - 1];
120
+ if (top && top.k === 'sq') { if (c === "'") stack.pop(); continue; }
121
+ if (top && top.k === 'ansi') { // $'..': a backslash escapes the next char, even a quote
122
+ if (c === '\\') i++;
123
+ else if (c === "'") stack.pop();
124
+ continue;
125
+ }
126
+ if (top && top.k === 'ar') {
127
+ // A `$(..)` inside arithmetic runs: push a sub frame (its own parens
128
+ // balance there), unless it is itself an arithmetic `$((..))`.
129
+ if (c === '$' && line[i + 1] === '(' && !(line[i + 2] === '(' && parenIsArith(line, i + 3, parens))) { openSub(i + 2); i++; continue; }
130
+ if (top.mode === 'bracket') { if (c === ']') stack.pop(); continue; }
131
+ if (c === '(') top.depth++;
132
+ else if (c === ')' && --top.depth === 0) { stack.pop(); if (top.exp) expCloseAt = i; }
133
+ continue;
134
+ }
135
+ if (c === '\\') { // escaped char stays literal
136
+ if (i + 1 >= line.length) { // backslash-newline: the line continues
137
+ st.cont = true;
138
+ // The next line is glued to this text: it starts a word only if the
139
+ // char before the backslash was a boundary (`a \<nl>#x` is a comment,
140
+ // `a\<nl>#x` is not). A line of just `\` inherits the earlier answer.
141
+ st.contBoundary = i === 0 ? (cont0 ? st.contBoundary : true) : isBoundary(i);
142
+ st.bareEnd = i === 0 ? bareAt === -1 : bareAt === i - 1; // carry the bare-dollar bit over
143
+ if (st.onCont) st.onCont(i, st.contBoundary, top); // optional observer (inWordContinuations)
144
+ }
145
+ i++;
146
+ escAt = i;
147
+ continue;
148
+ }
149
+ if (c === '`') { // a backtick substitution opens here (its close is handled above)
150
+ const outer = !stack.some((f) => f.k === 'sub');
151
+ const near = stack.slice().reverse().find((f) => f.k === 'dq' || f.k === 'sub');
152
+ const dq = !!near && near.k === 'dq';
153
+ st.bq = { outer, dq };
154
+ if (subs && outer) subs.open(i + 1, true, dq);
155
+ continue;
156
+ }
157
+ // Only a bare `$` opens `$'..'`, `${..}`, `$[..]`, `$(..)` or `$((..))`: not an escaped one
158
+ // (skipped above) and not the 2nd of `$$` (the PID, so `$${` is PID + a literal brace).
159
+ const bare = c === '$' && bareAt !== i - 1;
160
+ if (bare) bareAt = i;
161
+ if (bare && line[i + 1] === '(' && line[i + 2] === '(' && parenIsArith(line, i + 3, parens)) { stack.push({ k: 'ar', mode: 'paren', depth: 2, exp: true }); i += 2; continue; }
162
+ if (bare && line[i + 1] === '(') { openSub(i + 2); i++; continue; }
163
+ if (bare && line[i + 1] === '{') {
164
+ // `${ ` / `${<tab>` / `${|` / `${` at the end of a line is a bash 5.3 function
165
+ // substitution (it runs code); the optional observer `st.funsubs` records it.
166
+ const funsub = st.funsubs && (i + 2 >= line.length || ' \t|'.includes(line[i + 2]));
167
+ const entry = funsub ? { line: st.lineNo, at: i } : null;
168
+ if (entry) st.funsubs.push(entry);
169
+ stack.push(entry ? { k: 'br', fs: entry } : { k: 'br' });
170
+ i++;
171
+ continue;
172
+ }
173
+ if (top && top.k === 'dq') {
174
+ if (c === '"') stack.pop();
175
+ continue;
176
+ }
177
+ // An unquoted heredoc body is like double quotes without a closing `"`: the
178
+ // expansions above stay live, but quotes, comments, separators and
179
+ // introducers are literal text.
180
+ if (top && top.k === 'hd') continue;
181
+ if (bare && line[i + 1] === "'") { stack.push({ k: 'ansi' }); i++; continue; }
182
+ // A `'` glued to a bare `$` that ended the previous line (`$\<nl>'..'`) is ANSI-C too.
183
+ if (c === "'" && i === 0 && bareAt === -1) { stack.push({ k: 'ansi' }); continue; }
184
+ if (c === "'") { stack.push({ k: 'sq' }); continue; }
185
+ if (c === '"') { stack.push({ k: 'dq' }); continue; }
186
+ if (bare && line[i + 1] === '[') { stack.push({ k: 'ar', mode: 'bracket' }); i++; continue; }
187
+ if (top && top.k === 'br') { // ${..}: no code until the matching }
188
+ if (c === '}') {
189
+ stack.pop();
190
+ if (top.fs) { top.fs.endLine = st.lineNo; top.fs.endAt = i; }
191
+ }
192
+ continue;
193
+ }
194
+ const wordStart = i === 0 ? (cont0 ? st.contBoundary : true) : isBoundary(i);
195
+ if (c === '#' && wordStart) break; // comment to end of line
196
+ if (c === '(' && line[i + 1] === '(' && wordStart && parenIsArith(line, i + 2, parens)) { stack.push({ k: 'ar', mode: 'paren', depth: 2 }); i++; continue; }
197
+ if (top && top.k === 'sub') {
198
+ if (c === '(') { top.n++; continue; }
199
+ if (c === ')') { if (top.n === 0) { stack.pop(); expCloseAt = i; if (subs && top.outer) subs.close(i); } else top.n--; continue; }
200
+ }
201
+ // A process substitution `<(..)` / `>(..)` is code, like `$(..)`.
202
+ if ((c === '<' || c === '>') && line[i + 1] === '(') { openSub(i + 2); i++; continue; }
203
+ if (seps && splittable(stack)) {
204
+ const two = c + line[i + 1];
205
+ if (two === '&&' || two === '||') { seps.push({ at: i, len: 2, op: two }); i++; continue; }
206
+ if (c === ';' || c === '|') { seps.push({ at: i, len: 1, op: c }); continue; }
207
+ // A lone `&` backgrounds, unless it is part of a redirection (`2>&1`, `>&2`, `&>f`).
208
+ if (c === '&' && line[i - 1] !== '>' && line[i - 1] !== '<' && line[i + 1] !== '>') { seps.push({ at: i, len: 1, op: '&' }); continue; }
209
+ }
210
+ if (c === '<' && line[i + 1] === '<') {
211
+ if (line[i + 2] === '<') { i += 2; continue; } // herestring
212
+ HEREDOC_TAG_RE.lastIndex = i + 2;
213
+ const m = HEREDOC_TAG_RE.exec(line);
214
+ if (m) {
215
+ found.push({ at: i, depth: stack.length, dash: m[1] === '-', tag: m[2] || m[3] || m[4], quoted: m[2] !== undefined || m[3] !== undefined });
216
+ i = HEREDOC_TAG_RE.lastIndex - 1;
217
+ } else {
218
+ i++;
219
+ }
220
+ }
221
+ }
222
+ return found;
223
+ }
224
+
225
+ // `((` / `$((` open arithmetic only when the paren that closes the inner `(` is
226
+ // directly followed by `)`; `((a); b)` is nested subshells (and `$((a); b)` a
227
+ // substitution). `from` is the index just after the two opening parens. A span
228
+ // that runs past the end of the line is assumed to be arithmetic. `cache` is one
229
+ // object per line: a single left-to-right pass (skipping quoted spans and
230
+ // escapes) matches every `(` it visits to its `)`, so the many calls on a line
231
+ // full of `((` stay linear instead of each rescanning the rest of the line.
232
+ function parenIsArith(line, from, cache) {
233
+ const open = from - 1; // the inner `(`
234
+ if (!cache.close || !cache.close.has(open)) cache.close = matchParens(line, from);
235
+ const close = cache.close.get(open);
236
+ return close < 0 ? true : line[close + 1] === ')';
237
+ }
238
+
239
+ // Match every `(` visited from `from` on to its `)`: Map(open -> close, or -1
240
+ // when it never closes or an unclosed quote hides the rest of the line). The
241
+ // paren just before `from` is the inner one of a `((`, so it is seeded as open.
242
+ function matchParens(line, from) {
243
+ const close = new Map([[from - 1, -1]]);
244
+ const opens = [from - 1];
245
+ for (let j = from; j < line.length; j++) {
246
+ const c = line[j];
247
+ if (c === '\\') { j++; continue; }
248
+ if (c === "'" || c === '"') { // skip a quoted span on this line
249
+ const end = line.indexOf(c, j + 1);
250
+ if (end < 0) break;
251
+ j = end;
252
+ } else if (c === '(') { close.set(j, -1); opens.push(j); }
253
+ else if (c === ')' && opens.length > 0) close.set(opens.pop(), j);
254
+ }
255
+ return close;
256
+ }
257
+
258
+ // Code that can be split into statements: the innermost open context is a
259
+ // `$(..)` substitution (or nothing). Frames are uniform { k, ... } objects, so
260
+ // no string can ever hit a String.prototype property by accident.
261
+ const splittable = (stack) => stack.length === 0 || stack[stack.length - 1].k === 'sub';
262
+
263
+ // The one walk shared by stripHeredocBodies, heredocBodies, countHeredocs and
264
+ // both splitters, so they always agree (secret-guard pairs heredocBodies with
265
+ // per-statement countHeredocs by position). A heredoc BODY is literal data
266
+ // (e.g. a script being written to a file), not shell statements run in the
267
+ // current shell. Whenever a line introduces heredocs, every following line up
268
+ // to and including each terminator is body. Returns { lines, keep, docs, seps,
269
+ // nl, cont, hd }: `keep[i]` is false for body and terminator lines; `docs` is
270
+ // [{ quoted, body }] in order of appearance (`hd[i]` holds the introducer positions on line i) (`quoted` is true for a quoted
271
+ // delimiter, whose body is literal text); `seps[i]` are the separators inside
272
+ // line i and `nl[i]` says whether the newline after it separates statements;
273
+ // `cont[i]` says whether line i ends in a live backslash-newline continuation.
274
+ function scanHeredocs(cmd, initialStack) {
58
275
  const lines = String(cmd).split('\n');
59
- const out = [];
276
+ const keep = new Array(lines.length).fill(true);
277
+ const docs = [];
278
+ const seps = lines.map(() => []);
279
+ const nl = new Array(lines.length).fill(false);
280
+ const cont = new Array(lines.length).fill(false);
281
+ const hd = lines.map(() => []); // hd[i]: indexes of the heredoc introducers on line i
282
+ const st = { stack: (initialStack || []).map((f) => ({ ...f })) }; // a copy: never shared with the caller
60
283
  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.
284
+ const found = scanLine(lines[i], st, seps[i]);
285
+ hd[i] = found.map((f) => f.at);
286
+ nl[i] = !st.cont && !st.bq && splittable(st.stack);
287
+ cont[i] = st.cont;
288
+ // A line ending in a backslash continues on the next: that is ONE logical
289
+ // line, and any heredoc body starts after its last physical line. So does a
290
+ // quote opened after the introducer (`cat <<E 'a<nl>b'`): bash starts the
291
+ // body after the logical line, which ends only when the quote closes.
292
+ // A heredoc introduced inside the innermost open `$(..)` (the `"$(cat <<EOF`
293
+ // idiom) starts its body on the next physical line; one introduced at an
294
+ // outer depth waits until every context opened after it has closed.
295
+ while ((st.cont || (st.bq && found.length > 0) || found.some((f) => st.stack.length > f.depth)) && i + 1 < lines.length) {
296
+ i++;
297
+ const more = scanLine(lines[i], st, seps[i]);
298
+ hd[i] = more.map((f) => f.at);
299
+ found.push(...more);
300
+ nl[i] = !st.cont && !st.bq && splittable(st.stack);
301
+ cont[i] = st.cont;
302
+ }
303
+ // A single line can introduce more than one heredoc (`cmd <<A <<B`); the
304
+ // bodies follow in order.
305
+ for (const { dash, tag, quoted } of found) {
306
+ const body = [];
73
307
  while (i + 1 < lines.length) {
74
308
  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.
309
+ keep[i] = false; // body or terminator: never a candidate statement
310
+ const l = lines[i];
311
+ if ((dash ? l.replace(/^\t+/, '') : l) === tag) break; // exact match, or (for `<<-`) tabs then the tag
312
+ body.push(l);
80
313
  }
314
+ docs.push({ quoted, body: body.join('\n') });
315
+ }
316
+ }
317
+ return { lines, keep, docs, seps, nl, cont, hd };
318
+ }
319
+
320
+ // Remove every heredoc body (and terminator line) before the statement splitter
321
+ // sees the command. Handles multiple heredocs on one line and quoted tags.
322
+ function stripHeredocBodies(cmd) {
323
+ const { lines, keep } = scanHeredocs(cmd);
324
+ return lines.filter((_, i) => keep[i]).join('\n');
325
+ }
326
+
327
+ // The heredoc bodies of a command, in order of appearance: [{ quoted, body }].
328
+ function heredocBodies(cmd) {
329
+ return scanHeredocs(cmd).docs;
330
+ }
331
+
332
+ function lexCount(text) {
333
+ const st = { stack: [] };
334
+ return String(text).split('\n').reduce((n, line) => n + scanLine(line, st).length, 0);
335
+ }
336
+
337
+ // How many heredoc introducers a statement holds. A splitter entry carries the
338
+ // count from the single whole-command scan (`heredocs`), which is what callers
339
+ // pairing statements with heredocBodies must use. A plain string is lexed
340
+ // standalone from an empty stack: pure, no memory of any earlier split, so a
341
+ // fragment cut out of a larger command may count differently from its entry.
342
+ function countHeredocs(stmt) {
343
+ if (stmt && typeof stmt === 'object' && typeof stmt.heredocs === 'number') return stmt.heredocs;
344
+ return lexCount(stmt && typeof stmt === 'object' ? stmt.text : stmt);
345
+ }
346
+
347
+ // The text of every outermost command substitution in a command: `$(..)`,
348
+ // `<(..)`, `>(..)` and backticks, including those inside double quotes (the
349
+ // shell runs them), never one in single quotes, `$'..'`, arithmetic, a comment or a
350
+ // heredoc body. Nested ones are not returned: callers recurse into a body to
351
+ // reach them. A backtick body is returned as the shell would run it: `\``,
352
+ // `\\` and `\$` (and `\"` inside double quotes) are unescaped, so a nested `\`..\`` becomes a real backtick
353
+ // span in the body. A body can span lines (joined with '\n'); one that never closes
354
+ // runs to the end of the input (fail toward scanning). Read off scanLine's
355
+ // `sub` frames, so it agrees with the splitters on what is code.
356
+ function substitutionBodies(cmd) {
357
+ // Cut from the ORIGINAL text: a heredoc inside a body keeps its body and
358
+ // terminator lines there (the caller's own scan strips them), while lines
359
+ // that are heredoc body are never lexed, so a `$(..)` in one is not returned.
360
+ const { lines, keep } = scanHeredocs(cmd);
361
+ return collectSubstitutionBodies(lines, keep, { stack: [] });
362
+ }
363
+
364
+ // The outermost substitution bodies of an UNQUOTED heredoc body (#218): the
365
+ // shell runs `$(..)` and backticks there, though substitutionBodies never lexes
366
+ // a body line. The body is lexed as the shell reads a heredoc body, not as
367
+ // command text: quotes are literal (`it's $(cat .env)` still has its
368
+ // substitution), `\$` and `\`` stay escaped. Every line is lexed (a heredoc
369
+ // introducer in the body is text, not another heredoc). Same result shape and
370
+ // backtick unescaping as substitutionBodies. A quoted-delimiter body is literal
371
+ // and must not be passed here.
372
+ function heredocSubstitutionBodies(body) {
373
+ const hd = () => [{ k: 'hd' }];
374
+ // Scan once for the keep marks: a heredoc introduced inside a substitution in
375
+ // the body owns the lines after it (its body and terminator are data, so a `)`
376
+ // there does not close the substitution). Then collect from a fresh state.
377
+ const { lines, keep } = scanHeredocs(joinLineContinuations(body), hd());
378
+ return collectSubstitutionBodies(lines, keep, { stack: hd() });
379
+ }
380
+
381
+ // Remove every backslash-newline pair whose backslash is itself unescaped, as
382
+ // the shell does in an unquoted heredoc body before it expands anything. A run
383
+ // of backslashes before the newline pairs up from the left: an odd run leaves
384
+ // one live backslash (joined), an even run is escaped backslashes plus a real
385
+ // newline (kept).
386
+ function joinLineContinuations(text) {
387
+ const s = String(text);
388
+ let out = '';
389
+ for (let i = 0; i < s.length; i++) {
390
+ if (s[i] === '\\') {
391
+ if (s[i + 1] === '\n') { i++; continue; } // live backslash-newline: dropped
392
+ out += s[i] + (s[i + 1] === undefined ? '' : s[i + 1]); // escaped pair: kept whole
393
+ i++;
394
+ continue;
81
395
  }
396
+ out += s[i];
82
397
  }
83
- return out.join('\n');
398
+ return out;
399
+ }
400
+
401
+ // The one collector behind substitutionBodies and heredocSubstitutionBodies:
402
+ // lex the `keep`-marked lines from the start state `st` and read the outermost
403
+ // body texts off scanLine's `subs` callbacks.
404
+ function collectSubstitutionBodies(lines, keep, st) {
405
+ const out = [];
406
+ let cur = null; // text of the open body so far, or null
407
+ let from = 0; // where the open body's text starts on the current line
408
+ let bq = null; // the open body is a backtick span: null, or { dq } (in double quotes)
409
+ const unescape = (t) => (bq ? t.replace(bq.dq ? /\\([`\\$"])/g : /\\([`\\$])/g, '$1') : t);
410
+ const subs = {
411
+ open: (at, isBq, dq) => { cur = ''; from = at; bq = isBq ? { dq: !!dq } : null; },
412
+ close: (at) => { out.push(unescape(cur + lines[li].slice(from, at))); cur = null; },
413
+ };
414
+ let li = 0;
415
+ for (; li < lines.length; li++) {
416
+ if (!keep[li]) { if (cur !== null) cur += lines[li] + '\n'; continue; }
417
+ from = 0;
418
+ scanLine(lines[li], st, undefined, subs);
419
+ if (cur !== null) cur += lines[li].slice(from) + '\n';
420
+ }
421
+ if (cur !== null) out.push(unescape(cur.slice(0, -1))); // unterminated: the remainder
422
+ return out;
423
+ }
424
+
425
+ // Lex the `keep`-marked lines of a command from the start state `st`, telling the
426
+ // optional observers on `st` (funsubs, onCont) which line they are on.
427
+ function lexKeptLines(lines, keep, st) {
428
+ for (let li = 0; li < lines.length; li++) {
429
+ if (!keep[li]) continue;
430
+ st.lineNo = li;
431
+ scanLine(lines[li], st);
432
+ }
433
+ }
434
+
435
+ // Every live bash 5.3 function substitution in a command: `${ cmd; }`, `${| cmd; }`
436
+ // and `${` at the end of a line. They run code the statement splitter treats as
437
+ // ordinary text. Read off scanLine, so a `${ ` in single quotes, `$'..'`, a comment,
438
+ // arithmetic or a quoted-delimiter heredoc body is not reported, while one in double
439
+ // quotes, `$(..)` or an unquoted heredoc body is. Returns [{ at, payload }]: `at`
440
+ // is the offset of the `${` in `cmd` (-1 inside a heredoc body), `payload` the code
441
+ // up to the closing brace (to the end of the text when it never closes). A backtick
442
+ // span is opaque to the lexer: callers recurse into substitutionBodies for those.
443
+ function functionSubstitutions(cmd) {
444
+ const out = [];
445
+ const lexText = (text, initial, absolute) => {
446
+ const { lines, keep } = scanHeredocs(text, initial);
447
+ const starts = [];
448
+ lines.reduce((n, l, i) => { starts[i] = n; return n + l.length + 1; }, 0);
449
+ const st = { stack: (initial || []).map((f) => ({ ...f })), funsubs: [] };
450
+ lexKeptLines(lines, keep, st);
451
+ for (const f of st.funsubs) {
452
+ const endLine = f.endLine === undefined ? lines.length - 1 : f.endLine;
453
+ const endAt = f.endAt === undefined ? lines[endLine].length : f.endAt;
454
+ const parts = lines.slice(f.line, endLine + 1);
455
+ parts[parts.length - 1] = parts[parts.length - 1].slice(0, endAt);
456
+ parts[0] = parts[0].slice(f.at + 2);
457
+ out.push({ at: absolute ? starts[f.line] + f.at : -1, payload: parts.join('\n') });
458
+ }
459
+ };
460
+ lexText(String(cmd), [], true);
461
+ for (const doc of heredocBodies(cmd)) {
462
+ if (!doc.quoted) lexText(joinLineContinuations(doc.body), [{ k: 'hd' }], false);
463
+ }
464
+ return out;
465
+ }
466
+
467
+ // Every live backslash-newline that splits a WORD: the char before the backslash is
468
+ // not a word boundary and the next line starts with a non-blank (`cat .e\<nl>nv`).
469
+ // A continuation between words (` \<nl> --body x`, the usual multi-line form) is
470
+ // not reported, nor is one inside quotes, a comment or a heredoc body. Returns
471
+ // [{ line, junction }]: `junction` is the glued text around the join (`.env`).
472
+ function inWordContinuations(cmd) {
473
+ const out = [];
474
+ const { lines, keep } = scanHeredocs(cmd);
475
+ const st = {
476
+ stack: [],
477
+ onCont: (at, boundary, top) => {
478
+ const next = lines[st.lineNo + 1];
479
+ if (boundary || next === undefined || !/^\S/.test(next)) return;
480
+ if (top && top.k !== 'sub') return; // inside quotes the existing emulation joins it
481
+ const before = /[^\s;&|()<>]{0,6}$/.exec(lines[st.lineNo].slice(0, at))[0];
482
+ out.push({ line: st.lineNo, junction: before + /^[^\s;&|()<>]{0,6}/.exec(next)[0] });
483
+ },
484
+ };
485
+ lexKeptLines(lines, keep, st);
486
+ return out;
84
487
  }
85
488
 
86
489
  // --- statement splitting -------------------------------------------------------
87
490
 
491
+ // Cut a command at the separators the lexer reports. Heredoc bodies are skipped
492
+ // (literal data, never statements). Returns [{ text, sep }] with the operator
493
+ // that ENDED each statement (null for the last) and the number of heredoc
494
+ // introducers it holds (counted from the scan positions), untrimmed and unfiltered.
495
+ // A live backslash-newline continuation is removed like bash does (the backslash
496
+ // and the newline both go), so a word split across lines is one word again.
497
+ function cutStatements(cmd, ops) {
498
+ const { lines, keep, seps, nl, cont, hd } = scanHeredocs(cmd);
499
+ const kept = lines.map((_, i) => i).filter((i) => keep[i]);
500
+ const out = [];
501
+ let cur = '';
502
+ let heredocs = 0;
503
+ kept.forEach((i, k) => {
504
+ let from = 0;
505
+ const cuts = seps[i].filter((s) => ops.includes(s.op));
506
+ let h = 0;
507
+ for (const { at, len, op } of cuts) {
508
+ while (h < hd[i].length && hd[i][h] < at) { heredocs++; h++; }
509
+ out.push({ text: cur + lines[i].slice(from, at), sep: op, heredocs });
510
+ cur = '';
511
+ heredocs = 0;
512
+ from = at + len;
513
+ }
514
+ heredocs += hd[i].length - h;
515
+ cur += lines[i].slice(from);
516
+ if (k === kept.length - 1) return;
517
+ if (cont[i] && cur.endsWith('\\')) { cur = cur.slice(0, -1); return; } // joined: no separator, no newline
518
+ if (nl[i]) { out.push({ text: cur, sep: '\n', heredocs }); cur = ''; heredocs = 0; } else cur += '\n';
519
+ });
520
+ out.push({ text: cur, sep: null, heredocs });
521
+ return out;
522
+ }
523
+
88
524
  // 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
525
+ // newlines, respecting quotes, `$((..))`, `$[..]`, `((..))` and comments (so a
526
+ // separator INSIDE any of them, e.g. a grep pattern, does not fracture the
527
+ // statement). Heredoc bodies are stripped first so literal data written via
528
+ // `<<TAG` is never mistaken for statements. Splitting is purely
93
529
  // character-driven (not whitespace-driven), so a glued separator with no
94
530
  // surrounding spaces (`cat .env|grep KEY`, `cat .env;echo done`) still splits
95
531
  // correctly. Best-effort: not a full shell parser, just enough to avoid the
96
532
  // common false splits.
97
533
  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);
534
+ return cutStatements(cmd, [';', '|', '&&', '||'])
535
+ .map((x) => ({ text: x.text.trim(), heredocs: x.heredocs }))
536
+ .filter((x) => x.text)
537
+ .map((x) => x.text);
538
+ }
539
+
540
+ // Like splitStatements, but each statement carries the operator that ENDED it
541
+ // (`;`, `\n`, `&&`, `||`, `|`, `&`, or null for the last one), and a lone `&`
542
+ // (background) also splits. Callers use the separators to tell which statements
543
+ // ran in the current shell and which in a subshell (pipeline or background).
544
+ // Same lexing and heredoc rules as splitStatements, which stays unchanged for
545
+ // its existing consumers. A `&` that is part of a redirection (`2>&1`, `>&2`,
546
+ // `&>f`) is not a separator.
547
+ function splitStatementsWithSeparators(cmd) {
548
+ return cutStatements(cmd, [';', '|', '&&', '||', '&'])
549
+ .map((x) => ({ text: x.text.trim(), sep: x.sep, heredocs: x.heredocs }))
550
+ .filter((x) => x.text);
126
551
  }
127
552
 
128
553
  // --- tokenizing ----------------------------------------------------------------
@@ -237,7 +662,14 @@ module.exports = {
237
662
  basename,
238
663
  stripQuotes,
239
664
  stripHeredocBodies,
665
+ heredocBodies,
666
+ countHeredocs,
240
667
  splitStatements,
668
+ splitStatementsWithSeparators,
669
+ substitutionBodies,
670
+ heredocSubstitutionBodies,
671
+ functionSubstitutions,
672
+ inWordContinuations,
241
673
  tokenize,
242
674
  skipEnvPrefix,
243
675
  GREP_VALUE_FLAGS,