@brainervirus/workit-core 8.12.1 → 8.12.3

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.
@@ -1,20 +1,117 @@
1
1
  // A small shell tokenizer shared by the hooks: quoted words, operators,
2
- // redirects, heredoc bodies and `( … )` group markers. Pure string parsing,
3
- // no I/O. Two dialects:
4
- // - posix (Bash, sh, zsh): `\` escapes, '…' is literal, "…" honors `\"`;
2
+ // redirects, heredoc bodies, comments and `( … )` group markers. Pure string
3
+ // parsing, no I/O. Two dialects:
4
+ // - posix (Bash, sh, zsh): `\` escapes, '…' is literal, "…" honors `\"`,
5
+ // $'…' decodes C escapes, `\<newline>` continues the line, unquoted
6
+ // `{a,b}` expands, `$( … )`, backticks and `<( … )` are substitutions
7
+ // read apart, `(( … ))` is arithmetic, `#` starts a comment;
5
8
  // - powershell (Claude Code's PowerShell tool): `\` is a path separator,
6
- // the backtick escapes, '…' is literal with '' for a quote, "…" honors `".
9
+ // the backtick escapes (and continues a line), '…' is literal with '' for
10
+ // a quote, "…" honors `", `{ … }` script blocks run their commands, `#`
11
+ // and `<# … #>` are comments.
7
12
  export type Segment = {
8
13
  words: string[];
9
14
  redirects: string[];
10
15
  /** A `(`/`$(` group starts or ends here; the segment carries no words. */
11
16
  group?: "open" | "close";
17
+ /** Its standard output feeds the next segment (`a | b`). */
18
+ pipe?: true;
19
+ /** Text it reads on standard input: heredoc bodies and here-strings. */
20
+ stdin?: string[];
21
+ /** Command substitutions in its words (`$(…)`, `` `…` ``, `<(…)`), run with it. */
22
+ subs?: string[];
23
+ /** The command could not be analyzed (too deeply nested): why. The only segment then. */
24
+ unanalyzable?: string;
12
25
  };
13
26
 
14
27
  export type ShellDialect = "posix" | "powershell";
15
28
 
29
+ /**
30
+ * A command the tokenizer cannot analyze within its budget (nesting too
31
+ * deep). The shell hook denies it with this reason instead of guessing.
32
+ */
33
+ export class ShellAnalysisError extends Error {}
34
+
35
+ /** Substitutions and quotes nest at most this deep. */
36
+ const MAX_NESTING = 64;
37
+
38
+ // Unquoted brace characters, told apart from quoted ones until a word ends.
39
+ const OPEN = "\uE000";
40
+ const COMMA = "\uE001";
41
+ const CLOSE = "\uE002";
42
+ const MARKERS = /[\uE000-\uE002]/u;
43
+ const literal = (word: string) =>
44
+ word.replaceAll(OPEN, "{").replaceAll(COMMA, ",").replaceAll(CLOSE, "}");
45
+ /** At most this many words come out of one brace expansion. */
46
+ const BRACE_LIMIT = 64;
47
+ /** At most this many words wait to expand, and braces nest this deep, per word. */
48
+ const BRACE_PENDING = 512;
49
+ const BRACE_DEPTH = 16;
50
+
51
+ /** The first brace group with a comma at its top level: its bounds and commas. */
52
+ const braceGroup = (word: string) => {
53
+ for (let start = 0; start < word.length; start++) {
54
+ if (word[start] !== OPEN) continue;
55
+ let depth = 0;
56
+ let end = start;
57
+ const commas: number[] = [];
58
+ for (; end < word.length; end++) {
59
+ if (word[end] === OPEN) depth++;
60
+ else if (word[end] === CLOSE && --depth === 0) break;
61
+ else if (word[end] === COMMA && depth === 1) commas.push(end);
62
+ }
63
+ if (end < word.length && commas.length) return { start, end, commas };
64
+ }
65
+ return null;
66
+ };
67
+
68
+ /**
69
+ * Bash brace expansion of one word (`a{b,c}d` → `abd acd`); no sequences.
70
+ * Empty results are dropped (`{git,}` is `git`) and repeats collapse.
71
+ * Iterative and budgeted: a word nested too deep or expanding past the
72
+ * budget stays one literal word.
73
+ */
74
+ const expandBraces = (word: string): string[] => {
75
+ let depth = 0;
76
+ for (const char of word) {
77
+ if (char === OPEN && ++depth > BRACE_DEPTH) return [literal(word)];
78
+ if (char === CLOSE && depth > 0) depth--;
79
+ }
80
+ const done = new Set<string>();
81
+ const queue = [word];
82
+ const queued = new Set(queue);
83
+ for (let at = 0; at < queue.length; at++) {
84
+ const current = queue[at];
85
+ const group = braceGroup(current);
86
+ if (!group) {
87
+ const text = literal(current);
88
+ if (text) done.add(text);
89
+ if (done.size > BRACE_LIMIT) return [literal(word)];
90
+ continue;
91
+ }
92
+ const pre = current.slice(0, group.start);
93
+ const post = current.slice(group.end + 1);
94
+ let from = group.start + 1;
95
+ for (const comma of [...group.commas, group.end]) {
96
+ const next = pre + current.slice(from, comma) + post;
97
+ from = comma + 1;
98
+ if (queued.has(next)) continue;
99
+ queued.add(next);
100
+ queue.push(next);
101
+ }
102
+ // A comma group leaves more words waiting than it takes: bounding them bounds the work.
103
+ if (queue.length - at > BRACE_PENDING) return [literal(word)];
104
+ }
105
+ return [...done];
106
+ };
107
+
16
108
  /** The index of the quote closing the one at `start`, or -1. */
17
- const closingQuote = (command: string, start: number, dialect: ShellDialect): number => {
109
+ const closingQuote = (
110
+ command: string,
111
+ start: number,
112
+ dialect: ShellDialect,
113
+ nesting = 0,
114
+ ): number => {
18
115
  const quote = command[start];
19
116
  for (let index = start + 1; index < command.length; index++) {
20
117
  const char = command[index];
@@ -31,72 +128,323 @@ const closingQuote = (command: string, start: number, dialect: ShellDialect): nu
31
128
  index++;
32
129
  continue;
33
130
  }
131
+ // posix: a `$( … )` inside double quotes is parsed on its own (its own quotes and heredocs).
132
+ if (dialect === "posix" && char === "$" && command[index + 1] === "(") {
133
+ index = substitutionEnd(command, index + 1, nesting + 1);
134
+ continue;
135
+ }
136
+ if (dialect === "posix" && char === "`") {
137
+ index = backtickEnd(command, index);
138
+ continue;
139
+ }
34
140
  if (char === '"') return index;
35
141
  }
36
142
  return -1;
37
143
  };
38
144
 
145
+ /** The index of the backtick closing the one at `start`, or the end. */
146
+ const backtickEnd = (command: string, start: number): number => {
147
+ for (let index = start + 1; index < command.length; index++) {
148
+ if (command[index] === "\\") index++;
149
+ else if (command[index] === "`") return index;
150
+ }
151
+ return command.length;
152
+ };
153
+
154
+ /** A heredoc operator's delimiter at `start` (just past `<<`): the word and where it ends. */
155
+ const heredocDelimiter = (command: string, start: number) => {
156
+ let index = start;
157
+ if (command[index] === "-") index++;
158
+ while (command[index] === " " || command[index] === "\t") index++;
159
+ let delimiter = "";
160
+ for (; index < command.length && !/[\s;&|<>()]/.test(command[index]); index++) {
161
+ const char = command[index];
162
+ if (char === "'" || char === '"') {
163
+ const end = command.indexOf(char, index + 1);
164
+ if (end < 0) break;
165
+ delimiter += command.slice(index + 1, end);
166
+ index = end;
167
+ } else if (char === "\\") delimiter += command[++index] ?? "";
168
+ else delimiter += char;
169
+ }
170
+ return { delimiter, end: index - 1 };
171
+ };
172
+
173
+ /** Past the heredoc bodies that start after the newline at `newline`: the index of the last line's end. */
174
+ const skipHeredocs = (command: string, newline: number, delimiters: string[]): number => {
175
+ let index = newline;
176
+ while (delimiters.length) {
177
+ const delimiter = delimiters.shift()!;
178
+ for (;;) {
179
+ const lineEnd = command.indexOf("\n", index + 1);
180
+ const line = command.slice(index + 1, lineEnd < 0 ? undefined : lineEnd);
181
+ index = lineEnd < 0 ? command.length : lineEnd;
182
+ if (line.trim() === delimiter || lineEnd < 0) break;
183
+ }
184
+ }
185
+ return index;
186
+ };
187
+
188
+ /**
189
+ * The index of the `)` closing the `$(` whose `(` is at `start`, read as
190
+ * bash reads it: quotes, nested substitutions, backticks, comments and
191
+ * heredoc bodies (`$(cat <<'EOF' … 1) … EOF)`) do not count.
192
+ */
193
+ const substitutionEnd = (command: string, start: number, nesting = 0): number => {
194
+ if (nesting > MAX_NESTING) throw new ShellAnalysisError("substitutions nest too deep");
195
+ let depth = 0;
196
+ let wordStart = true;
197
+ const heredocs: string[] = [];
198
+ for (let index = start; index < command.length; index++) {
199
+ const char = command[index];
200
+ const atWordStart = wordStart;
201
+ wordStart = /[\s;&|()]/.test(char);
202
+ if (char === "\\") index++;
203
+ else if (char === "\n" && heredocs.length) {
204
+ index = skipHeredocs(command, index, heredocs);
205
+ wordStart = true;
206
+ } else if (char === "'" || char === '"') {
207
+ const end = closingQuote(command, index, "posix", nesting);
208
+ if (end < 0) return command.length;
209
+ index = end;
210
+ } else if (char === "`") index = backtickEnd(command, index);
211
+ else if (char === "#" && atWordStart) {
212
+ const newline = command.indexOf("\n", index);
213
+ index = newline < 0 ? command.length : newline - 1;
214
+ } else if (char === "<" && command[index + 1] === "<" && command[index + 2] !== "<") {
215
+ const { delimiter, end } = heredocDelimiter(command, index + 2);
216
+ if (delimiter) heredocs.push(delimiter);
217
+ index = end;
218
+ } else if (char === "<" && command[index + 1] === "<") index += 2;
219
+ else if (char === "$" && command[index + 1] === "(")
220
+ index = substitutionEnd(command, index + 1, nesting + 1);
221
+ else if (char === "(") depth++;
222
+ else if (char === ")" && --depth === 0) return index;
223
+ }
224
+ return command.length;
225
+ };
226
+
39
227
  /** The text of a quoted span without its quotes, escapes resolved. */
40
228
  const unquote = (body: string, quote: string, dialect: ShellDialect): string => {
41
229
  if (quote === "'") return dialect === "powershell" ? body.replaceAll("''", "'") : body;
42
- return dialect === "posix" ? body.replace(/\\(["\\$`])/g, "$1") : body.replace(/`(.)/g, "$1");
230
+ return dialect === "posix"
231
+ ? body.replace(/\\\n/g, "").replace(/\\(["\\$`])/g, "$1")
232
+ : body.replace(/`(.)/g, "$1");
233
+ };
234
+
235
+ const C_ESCAPES: Record<string, string> = {
236
+ n: "\n",
237
+ t: "\t",
238
+ r: "\r",
239
+ a: "\x07",
240
+ b: "\b",
241
+ e: "\x1b",
242
+ E: "\x1b",
243
+ f: "\f",
244
+ v: "\v",
245
+ };
246
+
247
+ /** A posix `$'…'` span starting at its quote: the decoded text and the closing index. */
248
+ const ansiC = (command: string, start: number): { text: string; end: number } => {
249
+ let text = "";
250
+ let index = start + 1;
251
+ for (; index < command.length && command[index] !== "'"; index++) {
252
+ const char = command[index];
253
+ if (char !== "\\" || index + 1 >= command.length) {
254
+ text += char;
255
+ continue;
256
+ }
257
+ const rest = command.slice(++index);
258
+ const hex = /^x([0-9a-fA-F]{1,2})/.exec(rest);
259
+ const octal = /^[0-7]{1,3}/.exec(rest);
260
+ if (hex) {
261
+ text += String.fromCharCode(Number.parseInt(hex[1], 16));
262
+ index += hex[0].length - 1;
263
+ } else if (octal) {
264
+ text += String.fromCharCode(Number.parseInt(octal[0], 8));
265
+ index += octal[0].length - 1;
266
+ } else text += C_ESCAPES[rest[0]] ?? rest[0];
267
+ }
268
+ return { text, end: index };
269
+ };
270
+
271
+ /** The command substitutions (`$( … )`, `` `…` ``) inside a double-quoted body. */
272
+ const substitutions = (body: string): string[] => {
273
+ const found: string[] = [];
274
+ for (let index = 0; index < body.length; index++) {
275
+ const char = body[index];
276
+ if (char === "\\") index++;
277
+ else if (char === "`") {
278
+ const end = backtickEnd(body, index);
279
+ found.push(body.slice(index + 1, end).replace(/\\(.)/g, "$1"));
280
+ index = end;
281
+ } else if (char === "$" && body[index + 1] === "(") {
282
+ const end = substitutionEnd(body, index + 1);
283
+ found.push(body.slice(index + 2, end));
284
+ index = end;
285
+ }
286
+ }
287
+ return found;
288
+ };
289
+
290
+ /** Characters that are only ever part of a word, per dialect. */
291
+ const PLAIN_POSIX = /[^\s'"\\`$(){},<>|;&#]+/uy;
292
+ const PLAIN_POWERSHELL = /[^\s'"`(){}<>|;&#]+/uy;
293
+
294
+ /** The `)` of a `(( … ))` arithmetic command starting at `start`, or 0 for a subshell (`((a) )`). */
295
+ const arithmeticEnd = (command: string, start: number): number => {
296
+ const end = substitutionEnd(command, start);
297
+ return command[end - 1] === ")" && end > start + 2 ? end : 0;
43
298
  };
44
299
 
45
- /** Tokenize `command` into simple-command segments. */
300
+ /**
301
+ * Tokenize `command` into simple-command segments. Never throws: a command
302
+ * it cannot analyze comes back as one segment that says why.
303
+ */
46
304
  export const segmentsOf = (command: string, dialect: ShellDialect = "posix"): Segment[] => {
305
+ try {
306
+ return tokenize(command, dialect);
307
+ } catch (error) {
308
+ const why = error instanceof ShellAnalysisError ? error.message : String(error);
309
+ return [{ words: [], redirects: [], unanalyzable: why }];
310
+ }
311
+ };
312
+
313
+ const tokenize = (command: string, dialect: ShellDialect): Segment[] => {
314
+ const posix = dialect === "posix";
47
315
  const segments: Segment[] = [];
48
316
  let current: Segment = { words: [], redirects: [] };
49
317
  let word = "";
50
318
  let inWord = false;
51
- let pending: "redirect" | "input" | "heredoc" | null = null;
52
- const heredocs: string[] = [];
53
- const escape = dialect === "posix" ? "\\" : "`";
319
+ let pending: "redirect" | "input" | "heredoc" | "herestring" | null = null;
320
+ const heredocs: Array<{ delimiter: string; owner: Segment }> = [];
321
+ const escape = posix ? "\\" : "`";
54
322
  const flushWord = () => {
55
323
  if (!inWord) return;
324
+ const text = MARKERS.test(word) ? literal(word) : word;
56
325
  if (pending === "redirect") {
57
- if (!word.startsWith("&")) current.redirects.push(word);
58
- } else if (pending === "heredoc") heredocs.push(word.replace(/^-/, ""));
59
- else if (pending !== "input") current.words.push(word);
326
+ if (!text.startsWith("&")) current.redirects.push(text);
327
+ } else if (pending === "heredoc")
328
+ heredocs.push({ delimiter: text.replace(/^-/, ""), owner: current });
329
+ else if (pending === "herestring") (current.stdin ??= []).push(text);
330
+ else if (pending !== "input")
331
+ current.words.push(...(MARKERS.test(word) ? expandBraces(word) : [word]));
60
332
  pending = null;
61
333
  word = "";
62
334
  inWord = false;
63
335
  };
64
336
  const flushSegment = () => {
65
337
  flushWord();
66
- if (current.words.length || current.redirects.length) segments.push(current);
338
+ if (current.words.length || current.redirects.length || current.stdin || current.subs)
339
+ segments.push(current);
67
340
  current = { words: [], redirects: [] };
68
341
  };
342
+ const group = (kind: "open" | "close") => {
343
+ flushSegment();
344
+ pending = null;
345
+ segments.push({ words: [], redirects: [], group: kind });
346
+ };
347
+ /** A substitution read apart: its command runs, its text stays in the word. */
348
+ const substitution = (inner: string, text: string) => {
349
+ (current.subs ??= []).push(inner);
350
+ word += text;
351
+ inWord = true;
352
+ };
353
+ const plain = posix ? PLAIN_POSIX : PLAIN_POWERSHELL;
69
354
  for (let index = 0; index < command.length; index++) {
355
+ // A run of characters no rule treats specially joins the word at once.
356
+ plain.lastIndex = index;
357
+ const run = plain.exec(command);
358
+ if (run) {
359
+ word += run[0];
360
+ inWord = true;
361
+ index += run[0].length - 1;
362
+ continue;
363
+ }
70
364
  const char = command[index];
71
- if (char === "'" || char === '"') {
365
+ const next = command[index + 1];
366
+ if (char === escape && (next === "\n" || (next === "\r" && command[index + 2] === "\n")))
367
+ index += next === "\r" ? 2 : 1;
368
+ else if (char === "#" && !inWord) {
369
+ // A comment runs to the end of the line; its quotes are not quotes.
370
+ const newline = command.indexOf("\n", index);
371
+ index = (newline < 0 ? command.length : newline) - 1;
372
+ } else if (!posix && char === "<" && next === "#") {
373
+ const end = command.indexOf("#>", index + 2);
374
+ index = end < 0 ? command.length : end + 1;
375
+ } else if (posix && char === "$" && next === "'") {
376
+ const { text, end } = ansiC(command, index + 1);
377
+ word += text;
378
+ inWord = true;
379
+ index = end;
380
+ } else if (posix && char === "$" && next === '"') continue;
381
+ else if (char === "'" || char === '"') {
72
382
  const end = closingQuote(command, index, dialect);
73
- word += unquote(command.slice(index + 1, end < 0 ? undefined : end), char, dialect);
383
+ const body = command.slice(index + 1, end < 0 ? undefined : end);
384
+ if (posix && char === '"' && /\$\(|`/.test(body))
385
+ (current.subs ??= []).push(...substitutions(body));
386
+ word += unquote(body, char, dialect);
74
387
  inWord = true;
75
388
  index = end < 0 ? command.length : end;
389
+ } else if (posix && char === "`") {
390
+ const end = backtickEnd(command, index);
391
+ const inner = command.slice(index + 1, end);
392
+ substitution(inner.replace(/\\(.)/g, "$1"), `\`${inner}\``);
393
+ index = end;
76
394
  } else if (char === escape && index + 1 < command.length) {
77
395
  word += command[++index];
78
396
  inWord = true;
79
397
  } else if (char === "\n") {
80
398
  flushSegment();
81
- // Skip heredoc bodies up to each delimiter line.
399
+ // Each heredoc body, up to its delimiter line, is its command's stdin.
82
400
  while (heredocs.length) {
83
- const delimiter = heredocs.shift()!;
401
+ const { delimiter, owner } = heredocs.shift()!;
84
402
  const lines = command.slice(index + 1).split("\n");
85
403
  const at = lines.findIndex((line) => line.trim() === delimiter);
86
- const skipped = (at < 0 ? lines : lines.slice(0, at + 1)).join("\n").length + 1;
87
- index += skipped;
404
+ (owner.stdin ??= []).push((at < 0 ? lines : lines.slice(0, at)).join("\n"));
405
+ index += (at < 0 ? lines : lines.slice(0, at + 1)).join("\n").length + 1;
88
406
  }
89
407
  } else if (/\s/.test(char)) flushWord();
90
- else if (char === "(" || char === ")") {
91
- // `$(` opens a command substitution: the `$` is not a word.
408
+ else if (posix && char === "(" && inWord && word.endsWith("$")) {
409
+ // `$( … )` stays part of its word (`"$(…)"`, `x$(…)y`); its command is read apart.
410
+ const end = substitutionEnd(command, index);
411
+ const inner = command.slice(index + 1, end);
412
+ substitution(inner, `(${inner})`);
413
+ index = end;
414
+ } else if (posix && char === "(" && (pending === "input" || pending === "redirect")) {
415
+ // `<( … )`, `>( … )`: a process substitution is an operand whose command runs.
416
+ const sign = pending === "input" ? "<" : ">";
417
+ pending = null;
418
+ const end = substitutionEnd(command, index);
419
+ const inner = command.slice(index + 1, end);
420
+ substitution(inner, `${sign}(${inner})`);
421
+ index = end;
422
+ } else if (posix && char === "(" && next === "(" && !inWord && arithmeticEnd(command, index)) {
423
+ // `(( … ))` is arithmetic: `<<` there is a shift, not a heredoc.
424
+ flushSegment();
425
+ index = arithmeticEnd(command, index);
426
+ } else if (char === "(" || char === ")") {
427
+ // PowerShell `$(`: the `$` is not a word.
92
428
  if (char === "(" && inWord && word.endsWith("$")) {
93
429
  word = word.slice(0, -1);
94
430
  inWord = word !== "";
95
431
  }
432
+ group(char === "(" ? "open" : "close");
433
+ } else if (!posix && (char === "{" || char === "}")) {
434
+ // A script block's commands run (`& { … }`, `Invoke-Command { … }`, `% { … }`).
435
+ if (char === "{" && inWord && word.endsWith("@")) {
436
+ word += char;
437
+ continue;
438
+ }
96
439
  flushSegment();
97
- segments.push({ words: [], redirects: [], group: char === "(" ? "open" : "close" });
98
440
  } else if (";&|".includes(char)) {
99
- if (char === "&" && command[index + 1] === ">") continue;
441
+ if (char === "&" && next === ">") continue;
442
+ if (char === "|" && next === "|") index++;
443
+ else if (char === "|") {
444
+ flushWord();
445
+ if (current.words.length) current.pipe = true;
446
+ if (next === "&") index++;
447
+ }
100
448
  flushSegment();
101
449
  } else if (char === ">") {
102
450
  // `2>`, `&>`: the fd digit is not a word.
@@ -111,14 +459,21 @@ export const segmentsOf = (command: string, dialect: ShellDialect = "posix"): Se
111
459
  inWord = true;
112
460
  }
113
461
  pending = "redirect";
114
- } else if (char === "<" && dialect === "posix") {
462
+ } else if (char === "<" && posix) {
115
463
  flushWord();
116
- if (command[index + 1] === "<") {
464
+ if (next === "<" && command[index + 2] === "<") {
465
+ index += 2;
466
+ pending = "herestring";
467
+ } else if (next === "<") {
117
468
  index++;
118
- if (command[index + 1] === "<") index++;
119
469
  pending = "heredoc";
120
470
  } else pending = "input";
121
- } else {
471
+ } else if (posix && char === "{" && !word.endsWith("$")) {
472
+ word += OPEN;
473
+ inWord = true;
474
+ } else if (posix && char === "," && word.includes(OPEN)) word += COMMA;
475
+ else if (posix && char === "}" && word.includes(OPEN)) word += CLOSE;
476
+ else {
122
477
  word += char;
123
478
  inWord = true;
124
479
  }