@blogic-cz/agent-tools 1.11.1 → 1.13.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 (100) hide show
  1. package/README.md +18 -43
  2. package/dist/credential-guard/bash.d.ts +3 -0
  3. package/dist/credential-guard/bash.d.ts.map +1 -0
  4. package/dist/credential-guard/claude-hook.d.ts.map +1 -1
  5. package/dist/credential-guard/engine.test.d.ts +5 -0
  6. package/dist/credential-guard/engine.test.d.ts.map +1 -0
  7. package/dist/credential-guard/index.d.ts +8 -35
  8. package/dist/credential-guard/index.d.ts.map +1 -1
  9. package/dist/credential-guard/ir.d.ts +150 -0
  10. package/dist/credential-guard/ir.d.ts.map +1 -0
  11. package/dist/credential-guard/literal-output.d.ts +2 -0
  12. package/dist/credential-guard/literal-output.d.ts.map +1 -0
  13. package/dist/credential-guard/passive-awk.d.ts +2 -0
  14. package/dist/credential-guard/passive-awk.d.ts.map +1 -0
  15. package/dist/credential-guard/passive-jq.d.ts.map +1 -1
  16. package/dist/credential-guard/passive-sed.d.ts +2 -0
  17. package/dist/credential-guard/passive-sed.d.ts.map +1 -0
  18. package/dist/credential-guard/passive.test.d.ts +2 -0
  19. package/dist/credential-guard/passive.test.d.ts.map +1 -0
  20. package/dist/credential-guard/paths.d.ts +14 -0
  21. package/dist/credential-guard/paths.d.ts.map +1 -0
  22. package/dist/credential-guard/policy.d.ts +47 -0
  23. package/dist/credential-guard/policy.d.ts.map +1 -0
  24. package/dist/credential-guard/powershell.d.ts +3 -0
  25. package/dist/credential-guard/powershell.d.ts.map +1 -0
  26. package/dist/credential-guard/registry.d.ts +89 -0
  27. package/dist/credential-guard/registry.d.ts.map +1 -0
  28. package/dist/credential-guard/review4.test.d.ts +2 -0
  29. package/dist/credential-guard/review4.test.d.ts.map +1 -0
  30. package/dist/credential-guard/review7.test.d.ts +2 -0
  31. package/dist/credential-guard/review7.test.d.ts.map +1 -0
  32. package/dist/credential-guard/shell-oracle.d.ts +2 -0
  33. package/dist/credential-guard/shell-oracle.d.ts.map +1 -0
  34. package/dist/credential-guard/xargs.d.ts +7 -0
  35. package/dist/credential-guard/xargs.d.ts.map +1 -0
  36. package/dist/gh-tool/pr/checkbox-markdown.d.ts +22 -0
  37. package/dist/gh-tool/pr/checkbox-markdown.d.ts.map +1 -0
  38. package/dist/gh-tool/pr/checkbox.d.ts +21 -0
  39. package/dist/gh-tool/pr/checkbox.d.ts.map +1 -0
  40. package/dist/gh-tool/pr/commands.d.ts +9 -0
  41. package/dist/gh-tool/pr/commands.d.ts.map +1 -1
  42. package/dist/gh-tool/pr/index.d.ts +1 -1
  43. package/dist/gh-tool/pr/index.d.ts.map +1 -1
  44. package/dist/gh-tool/read/api.d.ts +8 -0
  45. package/dist/gh-tool/read/api.d.ts.map +1 -1
  46. package/dist/gh-tool/read/cache.d.ts.map +1 -1
  47. package/dist/gh-tool/read/commands.d.ts +5 -0
  48. package/dist/gh-tool/read/commands.d.ts.map +1 -1
  49. package/dist/gh-tool/read/pulls.d.ts +1 -0
  50. package/dist/gh-tool/read/pulls.d.ts.map +1 -1
  51. package/dist/gh-tool/read/reads.d.ts +39 -0
  52. package/dist/gh-tool/read/reads.d.ts.map +1 -1
  53. package/dist/gh-tool/read/telemetry-command.d.ts +7 -0
  54. package/dist/gh-tool/read/telemetry-command.d.ts.map +1 -0
  55. package/dist/gh-tool/read/telemetry-store.d.ts +3 -0
  56. package/dist/gh-tool/read/telemetry-store.d.ts.map +1 -0
  57. package/dist/gh-tool/read/telemetry.d.ts +59 -0
  58. package/dist/gh-tool/read/telemetry.d.ts.map +1 -0
  59. package/dist/gh-tool/read/transport.d.ts +9 -0
  60. package/dist/gh-tool/read/transport.d.ts.map +1 -1
  61. package/dist/gh-tool/read/writes.d.ts +2 -1
  62. package/dist/gh-tool/read/writes.d.ts.map +1 -1
  63. package/package.json +2 -1
  64. package/schemas/agent-tools.schema.json +1 -1
  65. package/src/credential-guard/bash.ts +769 -0
  66. package/src/credential-guard/claude-hook.ts +14 -14
  67. package/src/credential-guard/engine.test.ts +2050 -0
  68. package/src/credential-guard/index.ts +285 -4268
  69. package/src/credential-guard/ir.ts +139 -0
  70. package/src/credential-guard/literal-output.ts +8 -0
  71. package/src/credential-guard/passive-awk.ts +239 -0
  72. package/src/credential-guard/passive-jq.ts +183 -24
  73. package/src/credential-guard/passive-sed.ts +78 -0
  74. package/src/credential-guard/passive.test.ts +141 -0
  75. package/src/credential-guard/paths.ts +108 -0
  76. package/src/credential-guard/policy.ts +1576 -0
  77. package/src/credential-guard/powershell.ts +220 -0
  78. package/src/credential-guard/registry.ts +4720 -0
  79. package/src/credential-guard/review4.test.ts +83 -0
  80. package/src/credential-guard/review7.test.ts +51 -0
  81. package/src/credential-guard/rg-types.json +389 -0
  82. package/src/credential-guard/shell-oracle.ts +187 -0
  83. package/src/credential-guard/xargs.ts +65 -0
  84. package/src/gh-tool/main.ts +32 -6
  85. package/src/gh-tool/pr/checkbox-markdown.ts +317 -0
  86. package/src/gh-tool/pr/checkbox.ts +113 -0
  87. package/src/gh-tool/pr/commands.ts +55 -2
  88. package/src/gh-tool/pr/core.ts +2 -2
  89. package/src/gh-tool/pr/index.ts +1 -0
  90. package/src/gh-tool/read/api.ts +73 -6
  91. package/src/gh-tool/read/cache.ts +13 -4
  92. package/src/gh-tool/read/commands.ts +17 -1
  93. package/src/gh-tool/read/pulls.ts +19 -0
  94. package/src/gh-tool/read/reads.ts +86 -2
  95. package/src/gh-tool/read/telemetry-command.ts +45 -0
  96. package/src/gh-tool/read/telemetry-store.ts +85 -0
  97. package/src/gh-tool/read/telemetry.ts +191 -0
  98. package/src/gh-tool/read/transport.ts +95 -6
  99. package/src/gh-tool/read/writes.ts +19 -2
  100. package/src/gh-tool/workflow.ts +2 -2
@@ -0,0 +1,769 @@
1
+ import { MAX_ANALYSIS_UNITS, MAX_PARSE_DEPTH, MAX_SCRIPT_BYTES, literalWord, wordText } from "./ir";
2
+ import type {
3
+ AndOr,
4
+ Assignment,
5
+ Command,
6
+ List,
7
+ ParseError,
8
+ ParseErrorCode,
9
+ Pipeline,
10
+ Redirect,
11
+ Script,
12
+ Span,
13
+ Word,
14
+ WordPart,
15
+ } from "./ir";
16
+
17
+ type Token =
18
+ | { readonly kind: "word"; readonly word: Word }
19
+ | { readonly kind: "op"; readonly text: string; readonly span: Span }
20
+ | {
21
+ readonly kind: "heredoc";
22
+ readonly body: Word;
23
+ readonly literal: boolean;
24
+ readonly span: Span;
25
+ };
26
+ class ParseFailure extends Error {
27
+ constructor(readonly problem: ParseError) {
28
+ super(problem.code);
29
+ }
30
+ }
31
+ function fail(code: ParseErrorCode, start: number, end = start + 1): never {
32
+ throw new ParseFailure({ kind: "parse-error", code, span: { start, end } });
33
+ }
34
+ const operators = [
35
+ "((",
36
+ "[[",
37
+ "]]",
38
+ ";;&",
39
+ "<<<",
40
+ "&>>",
41
+ ";;",
42
+ "&&",
43
+ "||",
44
+ "|&",
45
+ ">>",
46
+ "<<",
47
+ "<&",
48
+ ">&",
49
+ "<>",
50
+ ">|",
51
+ "&>",
52
+ ";",
53
+ "&",
54
+ "|",
55
+ "(",
56
+ ")",
57
+ "<",
58
+ ">",
59
+ ];
60
+
61
+ class Lexer {
62
+ position = 0;
63
+ constructor(
64
+ readonly text: string,
65
+ readonly depth: number,
66
+ readonly offset: number,
67
+ ) {}
68
+ span(start: number): Span {
69
+ return { start: start + this.offset, end: this.position + this.offset };
70
+ }
71
+ script(text: string, start: number): Script {
72
+ const parsed = parse(text, this.depth + 1, this.offset + start);
73
+ if (parsed.kind === "parse-error") throw new ParseFailure(parsed);
74
+ return parsed;
75
+ }
76
+ substitution(close: ")" | "`", start: number): Script {
77
+ const bodyStart = this.position;
78
+ let nesting = 1;
79
+ let quote = "";
80
+ for (; this.position < this.text.length; this.position++) {
81
+ const ch = this.text[this.position];
82
+ if (ch === "\\") {
83
+ this.position++;
84
+ continue;
85
+ }
86
+ if (quote) {
87
+ if (ch === quote) quote = "";
88
+ continue;
89
+ }
90
+ if (ch === "'" || ch === '"') {
91
+ quote = ch;
92
+ continue;
93
+ }
94
+ if (close === ")" && ch === "(") nesting++;
95
+ if (ch === close && nesting === 1) {
96
+ const body = this.script(this.text.slice(bodyStart, this.position), bodyStart);
97
+ this.position++;
98
+ return body;
99
+ }
100
+ if (ch === close) nesting--;
101
+ }
102
+ return fail("unterminated-substitution", this.offset + start, this.offset + this.position);
103
+ }
104
+ ansiQuote(): WordPart {
105
+ const start = this.position;
106
+ this.position += 2;
107
+ let text = "";
108
+ const escapes: Readonly<Record<string, string>> = {
109
+ a: "\x07",
110
+ b: "\b",
111
+ e: "\x1b",
112
+ E: "\x1b",
113
+ f: "\f",
114
+ n: "\n",
115
+ r: "\r",
116
+ t: "\t",
117
+ v: "\v",
118
+ "\\": "\\",
119
+ "'": "'",
120
+ '"': '"',
121
+ "?": "?",
122
+ };
123
+ while (this.position < this.text.length) {
124
+ const ch = this.text[this.position++];
125
+ if (ch === "'") return { kind: "literal", text, quoted: true };
126
+ if (ch !== "\\") {
127
+ text += ch;
128
+ continue;
129
+ }
130
+ const next = this.text[this.position] ?? "";
131
+ const escaped = escapes[next];
132
+ if (escaped !== undefined) {
133
+ text += escaped;
134
+ this.position++;
135
+ continue;
136
+ }
137
+ const hex = ["x", "u", "U"].includes(next);
138
+ const digits = hex
139
+ ? new RegExp(`^[0-9a-fA-F]{1,${next === "x" ? 2 : next === "u" ? 4 : 8}}`).exec(
140
+ this.text.slice(this.position + 1),
141
+ )?.[0]
142
+ : /^[0-7]{1,3}/.exec(this.text.slice(this.position))?.[0];
143
+ if (!digits) fail("unsupported-parameter-operator", this.offset + start);
144
+ const value = Number.parseInt(digits, hex ? 16 : 8);
145
+ if (value === 0 || value > 127) fail("unsupported-parameter-operator", this.offset + start);
146
+ text += String.fromCodePoint(value);
147
+ this.position += digits.length + (hex ? 1 : 0);
148
+ }
149
+ return fail("unterminated-quote", this.offset + start, this.offset + this.position);
150
+ }
151
+ parameter(quoted: boolean): WordPart {
152
+ const start = this.position++;
153
+ const ch = this.text[this.position];
154
+ if (!quoted && (ch === "'" || ch === '"'))
155
+ fail("unsupported-parameter-operator", this.offset + start);
156
+ if (ch === "(") {
157
+ this.position++;
158
+ if (this.text[this.position] === "(") fail("unsupported-keyword", this.offset + start);
159
+ return { kind: "command-substitution", body: this.substitution(")", start) };
160
+ }
161
+ if (ch === "{") {
162
+ this.position++;
163
+ const end = this.text.indexOf("}", this.position);
164
+ if (end < 0) fail("unterminated-substitution", this.offset + start);
165
+ const content = this.text.slice(this.position, end);
166
+ if (content.startsWith("!")) fail("indirect-expansion", this.offset + start);
167
+ if (/^#?(?:PIPESTATUS|pipestatus|BASH_REMATCH)\[(?:[0-9]+|[@*])\]$/.test(content)) {
168
+ this.position = end + 1;
169
+ return { kind: "special", name: content };
170
+ }
171
+ const match =
172
+ /^(#?)([A-Za-z_][A-Za-z0-9_]*|[0-9]+|[@*?$!#-])(?:(:-|-|:\+|\+)([\s\S]*))?$/.exec(content);
173
+ if (!match)
174
+ fail("unsupported-parameter-operator", this.offset + start, this.offset + end + 1);
175
+ const name = match[2] ?? "";
176
+ const operandText = match[4];
177
+ let operand: Word | null = null;
178
+ if (operandText !== undefined) {
179
+ const lexer = new Lexer(
180
+ operandText,
181
+ this.depth + 1,
182
+ this.offset + this.position + content.indexOf(operandText),
183
+ );
184
+ if (lexer.depth > MAX_PARSE_DEPTH) fail("depth-limit", this.offset + start);
185
+ operand = lexer.word(true);
186
+ }
187
+ this.position = end + 1;
188
+ if (/^[0-9@*?$!#-]+$/.test(name)) return { kind: "special", name };
189
+ return {
190
+ kind: "parameter",
191
+ name,
192
+ ambient: false,
193
+ operator: match[1]
194
+ ? "length"
195
+ : match[3]?.includes("+")
196
+ ? "alternate"
197
+ : match[3]
198
+ ? "default"
199
+ : "plain",
200
+ operand,
201
+ span: this.span(start),
202
+ };
203
+ }
204
+ const name = /^[A-Za-z_][A-Za-z0-9_]*/.exec(this.text.slice(this.position))?.[0];
205
+ if (name) {
206
+ this.position += name.length;
207
+ return {
208
+ kind: "parameter",
209
+ name,
210
+ ambient: false,
211
+ operator: "plain",
212
+ operand: null,
213
+ span: this.span(start),
214
+ };
215
+ }
216
+ if (ch && /[0-9@*?$!#-]/.test(ch)) {
217
+ this.position++;
218
+ return { kind: "special", name: ch };
219
+ }
220
+ return { kind: "literal", text: "$", quoted: true };
221
+ }
222
+ word(whole = false, heredoc = false): Word {
223
+ const start = this.position;
224
+ const parts: WordPart[] = [];
225
+ let quote = heredoc ? '"' : "";
226
+ let split = false;
227
+ let buffer = "";
228
+ if (!whole) {
229
+ const assignment = /^[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]\n]*\])?\+?=/.exec(
230
+ this.text.slice(this.position),
231
+ );
232
+ if (assignment) {
233
+ if (
234
+ /\[/.test(assignment[0]) &&
235
+ !/^[A-Za-z_][A-Za-z0-9_]*\[[A-Za-z0-9_]+\]\+?=$/.test(assignment[0])
236
+ )
237
+ fail("unsupported-keyword", this.offset + start);
238
+ buffer = assignment[0];
239
+ this.position += buffer.length;
240
+ }
241
+ }
242
+ const flush = () => {
243
+ if (buffer) {
244
+ parts.push({ kind: "literal", text: buffer, quoted: Boolean(quote) });
245
+ buffer = "";
246
+ }
247
+ };
248
+ while (this.position < this.text.length) {
249
+ const ch = this.text[this.position] ?? "";
250
+ if (
251
+ !whole &&
252
+ !quote &&
253
+ (/\s/.test(ch) ||
254
+ (operators.some((op) => this.text.startsWith(op, this.position)) &&
255
+ !((ch === "<" || ch === ">") && this.text[this.position + 1] === "(")))
256
+ )
257
+ break;
258
+ if (ch === "\\" && quote !== "'") {
259
+ const next = this.text[this.position + 1];
260
+ if (next === undefined) fail("syntax", this.offset + this.position);
261
+ this.position += 2;
262
+ if (next !== "\n") {
263
+ flush();
264
+ parts.push({
265
+ kind: "literal",
266
+ text:
267
+ quote === '"' && !(heredoc ? /[\\$`]/ : /[\\$`"]/).test(next) ? `\\${next}` : next,
268
+ quoted: true,
269
+ });
270
+ }
271
+ continue;
272
+ }
273
+ if (!heredoc && ((ch === "'" && quote !== '"') || (ch === '"' && quote !== "'"))) {
274
+ flush();
275
+ if (quote === ch) quote = "";
276
+ else {
277
+ quote = ch;
278
+ parts.push({ kind: "literal", text: "", quoted: true });
279
+ }
280
+ this.position++;
281
+ continue;
282
+ }
283
+ if (!quote && ch === "$" && this.text[this.position + 1] === "'") {
284
+ flush();
285
+ parts.push(this.ansiQuote());
286
+ continue;
287
+ }
288
+ if (quote !== "'" && ch === "$") {
289
+ if (!quote) split = true;
290
+ flush();
291
+ parts.push(this.parameter(Boolean(quote)));
292
+ continue;
293
+ }
294
+ if (quote !== "'" && ch === "`") {
295
+ if (!quote) split = true;
296
+ flush();
297
+ this.position++;
298
+ parts.push({
299
+ kind: "command-substitution",
300
+ body: this.substitution("`", this.position - 1),
301
+ });
302
+ continue;
303
+ }
304
+ if (!quote && (ch === "<" || ch === ">") && this.text[this.position + 1] === "(") {
305
+ flush();
306
+ this.position += 2;
307
+ parts.push({
308
+ kind: "process-substitution",
309
+ body: this.substitution(")", this.position - 2),
310
+ });
311
+ continue;
312
+ }
313
+ if (
314
+ !quote &&
315
+ /[*?[]/.test(ch) &&
316
+ !(
317
+ ch === "[" &&
318
+ this.position === start &&
319
+ /[ \t\n]/.test(this.text[this.position + 1] ?? "")
320
+ )
321
+ ) {
322
+ flush();
323
+ parts.push({ kind: "glob", text: ch });
324
+ this.position++;
325
+ continue;
326
+ }
327
+ if (
328
+ !heredoc &&
329
+ !quote &&
330
+ ch === "{" &&
331
+ !/\s/.test(this.text[this.position + 1] ?? "") &&
332
+ /[,]|\.\./.test(this.text.slice(this.position, this.text.indexOf("}", this.position) + 1))
333
+ ) {
334
+ const end = this.text.indexOf("}", this.position);
335
+ const body = this.text.slice(this.position + 1, end);
336
+ if (
337
+ end < 0 ||
338
+ !body.includes(",") ||
339
+ !/^[A-Za-z0-9_.,/:-]*$/.test(body) ||
340
+ body.includes("..")
341
+ )
342
+ fail("unsupported-keyword", this.offset + this.position);
343
+ flush();
344
+ parts.push({ kind: "brace", alternatives: body.split(",") });
345
+ this.position = end + 1;
346
+ continue;
347
+ }
348
+ buffer += ch;
349
+ this.position++;
350
+ }
351
+ if (quote && !heredoc)
352
+ fail("unterminated-quote", this.offset + start, this.offset + this.position);
353
+ flush();
354
+ return { split, parts, span: this.span(start) };
355
+ }
356
+ tokens(): Token[] {
357
+ const result: Token[] = [];
358
+ const pending: {
359
+ index: number;
360
+ delimiter: string;
361
+ literal: boolean;
362
+ tabs: boolean;
363
+ start: number;
364
+ }[] = [];
365
+ while (this.position < this.text.length) {
366
+ const ch = this.text[this.position] ?? "";
367
+ if (ch === " " || ch === "\t" || ch === "\r") {
368
+ this.position++;
369
+ continue;
370
+ }
371
+ if (ch === "#") {
372
+ while (this.position < this.text.length && this.text[this.position] !== "\n")
373
+ this.position++;
374
+ continue;
375
+ }
376
+ const start = this.position;
377
+ if (ch === "\n") {
378
+ this.position++;
379
+ result.push({ kind: "op", text: ";", span: this.span(start) });
380
+ for (const doc of pending.splice(0)) {
381
+ let body = "";
382
+ const bodyStart = this.position;
383
+ let found = false;
384
+ while (this.position < this.text.length) {
385
+ const end = this.text.indexOf("\n", this.position);
386
+ const lineEnd = end < 0 ? this.text.length : end;
387
+ let line = this.text.slice(this.position, lineEnd);
388
+ this.position = end < 0 ? this.text.length : end + 1;
389
+ while (
390
+ !doc.literal &&
391
+ (line.match(/\\+$/)?.[0].length ?? 0) % 2 === 1 &&
392
+ this.position < this.text.length
393
+ ) {
394
+ const continuationEnd = this.text.indexOf("\n", this.position);
395
+ line =
396
+ line.slice(0, -1) +
397
+ this.text.slice(
398
+ this.position,
399
+ continuationEnd < 0 ? this.text.length : continuationEnd,
400
+ );
401
+ this.position = continuationEnd < 0 ? this.text.length : continuationEnd + 1;
402
+ }
403
+ if (doc.tabs) line = line.replace(/^\t+/, "");
404
+ if (line === doc.delimiter) {
405
+ found = true;
406
+ break;
407
+ }
408
+ body += `${line}${end < 0 ? "" : "\n"}`;
409
+ }
410
+ if (!found)
411
+ fail("unterminated-heredoc", this.offset + doc.start, this.offset + this.position);
412
+ const value = doc.literal
413
+ ? literalWord(body, this.span(doc.start))
414
+ : new Lexer(body, this.depth, this.offset + bodyStart).word(true, true);
415
+ result[doc.index] = {
416
+ kind: "heredoc",
417
+ body: value,
418
+ literal: doc.literal,
419
+ span: this.span(doc.start),
420
+ };
421
+ }
422
+ continue;
423
+ }
424
+ if (this.text.startsWith("<<", start) && !this.text.startsWith("<<<", start)) {
425
+ this.position += 2;
426
+ const tabs = this.text[this.position] === "-";
427
+ if (tabs) this.position++;
428
+ while (/[ \t]/.test(this.text[this.position] ?? "")) this.position++;
429
+ const delimiterStart = this.position;
430
+ const delimiter = this.word();
431
+ const name = wordText(delimiter);
432
+ if (name === undefined || !name) fail("syntax", this.offset + delimiterStart);
433
+ pending.push({
434
+ index: result.length,
435
+ delimiter: name,
436
+ literal: /['"\\]/.test(this.text.slice(delimiterStart, this.position)),
437
+ tabs,
438
+ start,
439
+ });
440
+ result.push({
441
+ kind: "heredoc",
442
+ body: literalWord(""),
443
+ literal: false,
444
+ span: this.span(start),
445
+ });
446
+ continue;
447
+ }
448
+ const op = operators.find((candidate) => this.text.startsWith(candidate, start));
449
+ if (op && !((ch === "<" || ch === ">") && this.text[start + 1] === "(")) {
450
+ this.position += op.length;
451
+ result.push({ kind: "op", text: op, span: this.span(start) });
452
+ } else {
453
+ const word = this.word();
454
+ if (this.position === start) fail("syntax", this.offset + start);
455
+ result.push({ kind: "word", word });
456
+ }
457
+ }
458
+ if (pending.length) fail("unterminated-heredoc", this.offset + (pending[0]?.start ?? 0));
459
+ return result;
460
+ }
461
+ }
462
+ class Parser {
463
+ position = 0;
464
+ constructor(
465
+ readonly tokens: readonly Token[],
466
+ readonly depth: number,
467
+ readonly end: number,
468
+ ) {}
469
+ current(): Token | undefined {
470
+ return this.tokens[this.position];
471
+ }
472
+ text(): string | undefined {
473
+ const t = this.current();
474
+ return t?.kind === "op"
475
+ ? t.text
476
+ : t?.kind === "word" && t.word.parts.every((part) => part.kind === "literal" && !part.quoted)
477
+ ? wordText(t.word)
478
+ : undefined;
479
+ }
480
+ take(text: string): boolean {
481
+ if (this.text() !== text) return false;
482
+ this.position++;
483
+ return true;
484
+ }
485
+ require(text: string): void {
486
+ if (!this.take(text)) fail("syntax", this.location());
487
+ }
488
+ location(): number {
489
+ const t = this.current();
490
+ return t?.kind === "word" ? t.word.span.start : (t?.span.start ?? this.end);
491
+ }
492
+ separators(): void {
493
+ while (this.take(";") || this.take("&")) {
494
+ /* separators */
495
+ }
496
+ }
497
+ list(stops: readonly string[] = [], depth = this.depth): List {
498
+ if (depth > MAX_PARSE_DEPTH) fail("depth-limit", this.location());
499
+ const items: AndOr[] = [];
500
+ this.separators();
501
+ while (this.current() && !stops.includes(this.text() ?? "")) {
502
+ const first = this.pipeline(depth);
503
+ const rest: { op: "&&" | "||"; pipeline: Pipeline }[] = [];
504
+ while (this.text() === "&&" || this.text() === "||") {
505
+ const op = this.take("&&") ? "&&" : "||";
506
+ if (op === "||") this.require("||");
507
+ while (this.take(";")) {
508
+ /* line continuation */
509
+ }
510
+ rest.push({ op, pipeline: this.pipeline(depth) });
511
+ }
512
+ items.push({ first, rest, background: this.text() === "&" });
513
+ if (!this.current() || stops.includes(this.text() ?? "")) break;
514
+ if (this.text() !== ";" && this.text() !== "&") fail("syntax", this.location());
515
+ this.separators();
516
+ }
517
+ return { items };
518
+ }
519
+ pipeline(depth: number): Pipeline {
520
+ const negated = this.take("!");
521
+ const commands = [this.command(depth)];
522
+ let stderr = false;
523
+ while (true) {
524
+ const stdoutOnly = this.take("|");
525
+ const combined = !stdoutOnly && this.take("|&");
526
+ if (!stdoutOnly && !combined) break;
527
+ stderr ||= combined;
528
+ while (this.take(";")) {
529
+ /* newline */
530
+ }
531
+ commands.push(this.command(depth));
532
+ }
533
+ return { negated, stderr, commands };
534
+ }
535
+ word(): Word {
536
+ const t = this.current();
537
+ if (t?.kind !== "word") fail("syntax", this.location());
538
+ this.position++;
539
+ return t.word;
540
+ }
541
+ redirects(): Redirect[] {
542
+ const result: Redirect[] = [];
543
+ while (true) {
544
+ const token = this.current();
545
+ let fd: number | null = null;
546
+ if (token?.kind === "heredoc") {
547
+ this.position++;
548
+ result.push({ kind: "heredoc", body: token.body, literal: token.literal, fd });
549
+ continue;
550
+ }
551
+ let op = this.text();
552
+ const next = this.tokens[this.position + 1];
553
+ if (
554
+ /^[0-9]+$/.test(op ?? "") &&
555
+ token?.kind === "word" &&
556
+ next?.kind === "heredoc" &&
557
+ token.word.span.end === next.span.start
558
+ ) {
559
+ this.position += 2;
560
+ result.push({ kind: "heredoc", body: next.body, literal: next.literal, fd: Number(op) });
561
+ continue;
562
+ }
563
+ if (
564
+ /^[0-9]+$/.test(op ?? "") &&
565
+ next?.kind === "op" &&
566
+ /^[<>]/.test(next.text) &&
567
+ token?.kind === "word" &&
568
+ token.word.span.end === next.span.start
569
+ ) {
570
+ fd = Number(op);
571
+ this.position++;
572
+ op = this.text();
573
+ }
574
+ if (!op || !["<", ">", ">>", "<>", ">|", "&>", "&>>", "<&", ">&", "<<<"].includes(op)) break;
575
+ this.position++;
576
+ const target = this.word();
577
+ if (op === "<&" || op === ">&") {
578
+ const text = wordText(target);
579
+ if (!text || !/^(?:[0-9]+|-)$/.test(text)) fail("syntax", target.span.start);
580
+ result.push({ kind: "duplicate", target: text, input: op === "<&", fd });
581
+ } else if (op === "<<<") result.push({ kind: "herestring", body: target, fd });
582
+ else
583
+ result.push({
584
+ kind: "file",
585
+ fd,
586
+ op:
587
+ op === "&>" || op === "&>>" || op === "<" || op === "<>" || op === ">|"
588
+ ? op
589
+ : op.includes(">>")
590
+ ? ">>"
591
+ : ">",
592
+ target,
593
+ });
594
+ }
595
+ return result;
596
+ }
597
+ command(depth: number): Command {
598
+ const start = this.location();
599
+ const span = (): Span => ({ start, end: this.location() });
600
+ if (["function", "coproc", "select", "(("].includes(this.text() ?? ""))
601
+ fail("unsupported-keyword", start);
602
+ if (
603
+ this.tokens[this.position + 1]?.kind === "op" &&
604
+ this.tokens[this.position + 1] &&
605
+ this.textAt(this.position + 1) === "(" &&
606
+ this.textAt(this.position + 2) === ")"
607
+ )
608
+ fail("function-definition", start);
609
+ if (this.take("(") || this.take("{")) {
610
+ const subshell = this.textAt(this.position - 1) === "(";
611
+ const body = this.list([subshell ? ")" : "}"], depth + 1);
612
+ this.require(subshell ? ")" : "}");
613
+ return { kind: "group", subshell, body, redirects: this.redirects(), span: span() };
614
+ }
615
+ if (this.take("for")) {
616
+ const name = wordText(this.word());
617
+ if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) fail("syntax", start);
618
+ this.require("in");
619
+ const items: Word[] = [];
620
+ while (this.current() && this.text() !== ";") items.push(this.word());
621
+ this.separators();
622
+ this.require("do");
623
+ const body = this.list(["done"], depth + 1);
624
+ this.require("done");
625
+ return { kind: "for", name, items, body, redirects: this.redirects(), span: span() };
626
+ }
627
+ if (this.text() === "while" || this.text() === "until") {
628
+ const kind = this.take("while") ? "while" : "until";
629
+ if (kind === "until") this.require("until");
630
+ const condition = this.list(["do"], depth + 1);
631
+ this.require("do");
632
+ const body = this.list(["done"], depth + 1);
633
+ this.require("done");
634
+ return { kind, condition, body, redirects: this.redirects(), span: span() };
635
+ }
636
+ if (this.take("if")) {
637
+ const branches: { condition: List; body: List }[] = [];
638
+ do {
639
+ const condition = this.list(["then"], depth + 1);
640
+ this.require("then");
641
+ const body = this.list(["elif", "else", "fi"], depth + 1);
642
+ branches.push({ condition, body });
643
+ } while (this.take("elif"));
644
+ const otherwise = this.take("else") ? this.list(["fi"], depth + 1) : null;
645
+ this.require("fi");
646
+ return { kind: "if", branches, otherwise, redirects: this.redirects(), span: span() };
647
+ }
648
+ if (this.take("case")) {
649
+ const subject = this.word();
650
+ this.require("in");
651
+ this.separators();
652
+ const arms: { patterns: Word[]; body: List }[] = [];
653
+ while (this.current() && this.text() !== "esac") {
654
+ this.take("(");
655
+ const patterns = [this.word()];
656
+ while (this.take("|")) patterns.push(this.word());
657
+ this.require(")");
658
+ const body = this.list([";;", "esac"], depth + 1);
659
+ arms.push({ patterns, body });
660
+ if (!this.take(";;") && this.text() !== "esac") fail("syntax", this.location());
661
+ this.separators();
662
+ }
663
+ this.require("esac");
664
+ return { kind: "case", subject, arms, redirects: this.redirects(), span: span() };
665
+ }
666
+ if (this.take("[[")) {
667
+ const words: Word[] = [];
668
+ while (this.current() && this.text() !== "]]") {
669
+ const t = this.current();
670
+ this.position++;
671
+ if (t?.kind === "word") words.push(t.word);
672
+ else if (t?.kind === "op") words.push(literalWord(t.text, t.span));
673
+ else fail("syntax", this.location());
674
+ }
675
+ this.require("]]");
676
+ return { kind: "test", words, span: span() };
677
+ }
678
+ if (["then", "do", "done", "fi", "else", "elif", "esac", "}", ")"].includes(this.text() ?? ""))
679
+ fail("syntax", start);
680
+ const assignments: Assignment[] = [];
681
+ const argv: Word[] = [];
682
+ const redirects: Redirect[] = [];
683
+ while (this.current()) {
684
+ const redirs = this.redirects();
685
+ if (redirs.length) {
686
+ redirects.push(...redirs);
687
+ continue;
688
+ }
689
+ const token = this.current();
690
+ if (token?.kind !== "word") break;
691
+ this.position++;
692
+ const first = token.word.parts[0];
693
+ const binding =
694
+ argv.length === 0 && first?.kind === "literal" && !first.quoted
695
+ ? /^([A-Za-z_][A-Za-z0-9_]*)(\[[^\]]*\])?(\+)?=/.exec(first.text)
696
+ : null;
697
+ if (binding && first?.kind === "literal")
698
+ assignments.push({
699
+ name: binding[1] ?? "",
700
+ operation: binding[2] || binding[3] ? "opaque" : "replace",
701
+ exported: false,
702
+ value: {
703
+ ...token.word,
704
+ parts: [
705
+ { ...first, text: first.text.slice(binding[0].length) },
706
+ ...token.word.parts.slice(1),
707
+ ],
708
+ },
709
+ });
710
+ else argv.push(token.word);
711
+ }
712
+ if (!assignments.length && !argv.length && !redirects.length) fail("syntax", start);
713
+ return { kind: "simple", assignments, argv, redirects, span: span() };
714
+ }
715
+ textAt(index: number): string | undefined {
716
+ const t = this.tokens[index];
717
+ return t?.kind === "op"
718
+ ? t.text
719
+ : t?.kind === "word" && t.word.parts.every((part) => part.kind === "literal" && !part.quoted)
720
+ ? wordText(t.word)
721
+ : undefined;
722
+ }
723
+ }
724
+ function parse(text: string, depth: number, offset: number): Script | ParseError {
725
+ if (text.includes(String.fromCharCode(0)) || text.includes("\r"))
726
+ return {
727
+ kind: "parse-error",
728
+ code: "syntax",
729
+ span: { start: offset, end: offset + text.length },
730
+ };
731
+ if (depth > MAX_PARSE_DEPTH)
732
+ return {
733
+ kind: "parse-error",
734
+ code: "depth-limit",
735
+ span: { start: offset, end: offset + text.length },
736
+ };
737
+ if (
738
+ text.length > MAX_SCRIPT_BYTES ||
739
+ new TextEncoder().encode(text).byteLength > MAX_SCRIPT_BYTES
740
+ )
741
+ return {
742
+ kind: "parse-error",
743
+ code: "size-limit",
744
+ span: { start: offset, end: offset + text.length },
745
+ };
746
+ try {
747
+ const tokens = new Lexer(text, depth, offset).tokens();
748
+ let units = tokens.length;
749
+ for (const token of tokens) {
750
+ units += token.kind === "word" ? token.word.parts.length : 1;
751
+ if (units > MAX_ANALYSIS_UNITS) fail("work-limit", offset, offset + text.length);
752
+ }
753
+ const parser = new Parser(tokens, depth, offset + text.length);
754
+ const body = parser.list();
755
+ return {
756
+ kind: "script",
757
+ dialect: "bash",
758
+ source: text,
759
+ body,
760
+ span: { start: offset, end: offset + text.length },
761
+ };
762
+ } catch (error) {
763
+ if (error instanceof ParseFailure) return error.problem;
764
+ throw error;
765
+ }
766
+ }
767
+ export function parseBash(text: string): Script | ParseError {
768
+ return parse(text, 0, 0);
769
+ }