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