@ibartel74/pi-automode-ext 1.0.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.
@@ -0,0 +1,692 @@
1
+ import { basename } from "node:path";
2
+ import {
3
+ parse,
4
+ type ArithmeticExpression,
5
+ type Node,
6
+ type ParseError,
7
+ type ParsedScript,
8
+ type Redirect,
9
+ type TestExpression,
10
+ type Word,
11
+ type WordPart,
12
+ } from "unbash";
13
+
14
+ export type BashAnalysisError = {
15
+ message: string;
16
+ pos?: number;
17
+ };
18
+
19
+ export type BashRedirectAnalysis = {
20
+ operator: Redirect["operator"];
21
+ target?: string;
22
+ targetDynamic: boolean;
23
+ fileDescriptor?: number;
24
+ variableName?: string;
25
+ heredoc: boolean;
26
+ };
27
+
28
+ export type EffectiveCommand = {
29
+ name?: string;
30
+ args: string[];
31
+ argTexts: string[];
32
+ argTildeExpansions: boolean[];
33
+ unresolvedTransparentDispatch: boolean;
34
+ };
35
+
36
+ export type BashCommandAnalysis = {
37
+ raw: string;
38
+ text: string;
39
+ name?: string;
40
+ words: string[];
41
+ args: string[];
42
+ argTexts: string[];
43
+ effectiveCommand: EffectiveCommand;
44
+ redirects: BashRedirectAnalysis[];
45
+ redirectTargets: string[];
46
+ dynamic: boolean;
47
+ dynamicName: boolean;
48
+ dynamicShellScript: boolean;
49
+ pos: number;
50
+ end: number;
51
+ };
52
+
53
+ export type BashAnalysis = {
54
+ source: string;
55
+ commands: BashCommandAnalysis[];
56
+ redirects: BashRedirectAnalysis[];
57
+ redirectTargets: string[];
58
+ structure: string[];
59
+ allowStructureSafe: boolean;
60
+ errors: BashAnalysisError[];
61
+ };
62
+
63
+ export type BashParser = (source: string) => ParsedScript;
64
+
65
+ export const MAX_BASH_SOURCE_LENGTH = 1024 * 1024;
66
+ const MAX_NESTED_SHELL_DEPTH = 16;
67
+
68
+ function parseError(error: ParseError): BashAnalysisError {
69
+ return { message: error.message, pos: error.pos };
70
+ }
71
+
72
+ function parserException(error: unknown): BashAnalysisError {
73
+ return {
74
+ message: `Bash parser failed: ${
75
+ error instanceof Error ? error.message : String(error)
76
+ }`,
77
+ };
78
+ }
79
+
80
+ function wordParts(word: Word): WordPart[] {
81
+ return word.parts ?? [];
82
+ }
83
+
84
+ function wordIsStatic(word: Word): boolean {
85
+ const parts = wordParts(word);
86
+ if (parts.length === 0) return true;
87
+ return parts.every(partIsStatic);
88
+ }
89
+
90
+ function wordHasTildeExpansion(word: Word): boolean {
91
+ if (!word.text.startsWith("~")) return false;
92
+ for (const character of word.text.slice(1)) {
93
+ if (character === "/") return true;
94
+ if (["\\", "'", '"', "$", "`"].includes(character)) return false;
95
+ }
96
+ return true;
97
+ }
98
+
99
+ function partIsStatic(part: WordPart): boolean {
100
+ switch (part.type) {
101
+ case "Literal":
102
+ case "SingleQuoted":
103
+ case "AnsiCQuoted":
104
+ return true;
105
+ case "DoubleQuoted":
106
+ return part.parts.every((child) => child.type === "Literal");
107
+ case "LocaleString":
108
+ case "SimpleExpansion":
109
+ case "ParameterExpansion":
110
+ case "CommandExpansion":
111
+ case "ArithmeticExpansion":
112
+ case "ProcessSubstitution":
113
+ case "ExtendedGlob":
114
+ case "BraceExpansion":
115
+ return false;
116
+ }
117
+ }
118
+
119
+ function commandName(value: string | undefined): string | undefined {
120
+ if (!value) return undefined;
121
+ return basename(value).toLowerCase();
122
+ }
123
+
124
+ type CommandInvocation = {
125
+ name?: string;
126
+ argumentWords: Word[];
127
+ unresolvedTransparentDispatch: boolean;
128
+ };
129
+
130
+ function unwrapTransparentCommandOnce(
131
+ name: string | undefined,
132
+ argumentWords: Word[],
133
+ ): CommandInvocation {
134
+ if (name !== "command" && name !== "exec" && name !== "env") {
135
+ return { name, argumentWords, unresolvedTransparentDispatch: false };
136
+ }
137
+
138
+ let index = 0;
139
+ let optionsEnded = false;
140
+ while (index < argumentWords.length) {
141
+ const word = argumentWords[index];
142
+ if (!word || !wordIsStatic(word)) {
143
+ return {
144
+ argumentWords: [],
145
+ unresolvedTransparentDispatch: true,
146
+ };
147
+ }
148
+ const value = word.value;
149
+ if (!optionsEnded && value === "--") {
150
+ optionsEnded = true;
151
+ index += 1;
152
+ continue;
153
+ }
154
+
155
+ if (name === "command" && !optionsEnded) {
156
+ if (value === "-p") {
157
+ index += 1;
158
+ continue;
159
+ }
160
+ if (value === "-v" || value === "-V" || value.startsWith("-")) {
161
+ return { argumentWords: [], unresolvedTransparentDispatch: true };
162
+ }
163
+ }
164
+
165
+ if (name === "exec" && !optionsEnded) {
166
+ if (value === "-a") {
167
+ const optionValue = argumentWords[index + 1];
168
+ if (!optionValue || !wordIsStatic(optionValue)) {
169
+ return { argumentWords: [], unresolvedTransparentDispatch: true };
170
+ }
171
+ index += 2;
172
+ continue;
173
+ }
174
+ if (/^-[cl]+$/.test(value)) {
175
+ index += 1;
176
+ continue;
177
+ }
178
+ if (value.startsWith("-")) {
179
+ return { argumentWords: [], unresolvedTransparentDispatch: true };
180
+ }
181
+ }
182
+
183
+ if (name === "env") {
184
+ if (!optionsEnded && value.startsWith("-")) {
185
+ if (
186
+ value === "-i" ||
187
+ value === "--ignore-environment" ||
188
+ value === "-0" ||
189
+ value === "--null" ||
190
+ value === "-v" ||
191
+ value === "--debug"
192
+ ) {
193
+ index += 1;
194
+ continue;
195
+ }
196
+ if (["-u", "--unset", "-C", "--chdir"].includes(value)) {
197
+ const optionValue = argumentWords[index + 1];
198
+ if (!optionValue || !wordIsStatic(optionValue)) {
199
+ return { argumentWords: [], unresolvedTransparentDispatch: true };
200
+ }
201
+ index += 2;
202
+ continue;
203
+ }
204
+ if (value.startsWith("--unset=") || value.startsWith("--chdir=")) {
205
+ index += 1;
206
+ continue;
207
+ }
208
+ return { argumentWords: [], unresolvedTransparentDispatch: true };
209
+ }
210
+ if (/^[^=]+=/.test(value)) {
211
+ index += 1;
212
+ continue;
213
+ }
214
+ }
215
+
216
+ return {
217
+ name: commandName(value),
218
+ argumentWords: argumentWords.slice(index + 1),
219
+ unresolvedTransparentDispatch: false,
220
+ };
221
+ }
222
+
223
+ return { argumentWords: [], unresolvedTransparentDispatch: true };
224
+ }
225
+
226
+ function effectiveCommandInvocation(
227
+ name: string | undefined,
228
+ argumentWords: Word[],
229
+ ): CommandInvocation {
230
+ let invocation: CommandInvocation = {
231
+ name,
232
+ argumentWords,
233
+ unresolvedTransparentDispatch: false,
234
+ };
235
+ for (let depth = 0; depth < MAX_NESTED_SHELL_DEPTH; depth += 1) {
236
+ const next = unwrapTransparentCommandOnce(
237
+ invocation.name,
238
+ invocation.argumentWords,
239
+ );
240
+ if (next.unresolvedTransparentDispatch) return next;
241
+ if (
242
+ next.name === invocation.name &&
243
+ next.argumentWords === invocation.argumentWords
244
+ ) {
245
+ return next;
246
+ }
247
+ invocation = next;
248
+ }
249
+ return { argumentWords: [], unresolvedTransparentDispatch: true };
250
+ }
251
+
252
+ type NestedShell = {
253
+ name?: string;
254
+ source?: string;
255
+ hasScriptArgument: boolean;
256
+ };
257
+
258
+ function nestedShell(invocation: CommandInvocation): NestedShell | undefined {
259
+ if (invocation.unresolvedTransparentDispatch) {
260
+ return { hasScriptArgument: true };
261
+ }
262
+ if (invocation.name === "eval") {
263
+ return {
264
+ name: "eval",
265
+ source:
266
+ invocation.argumentWords.length > 0 &&
267
+ invocation.argumentWords.every(wordIsStatic)
268
+ ? invocation.argumentWords.map((word) => word.value).join(" ")
269
+ : undefined,
270
+ hasScriptArgument: invocation.argumentWords.length > 0,
271
+ };
272
+ }
273
+ if (invocation.name !== "bash" && invocation.name !== "sh") {
274
+ return undefined;
275
+ }
276
+ const commandOptionIndex = invocation.argumentWords.findIndex((word) =>
277
+ /^-[^-]*c/.test(word.value)
278
+ );
279
+ if (commandOptionIndex < 0) return undefined;
280
+ const scriptWord = invocation.argumentWords[commandOptionIndex + 1];
281
+ return {
282
+ name: invocation.name,
283
+ source: scriptWord && wordIsStatic(scriptWord) ? scriptWord.value : undefined,
284
+ hasScriptArgument: true,
285
+ };
286
+ }
287
+
288
+ function exhaustiveNode(_node: never): never {
289
+ throw new Error(`Unsupported unbash AST node: ${JSON.stringify(_node)}`);
290
+ }
291
+
292
+ /** Parse one Pi `bash` tool input into normalized executable command views. */
293
+ export function analyzeBash(
294
+ source: string,
295
+ parser: BashParser = parse,
296
+ ): BashAnalysis {
297
+ const analysis: BashAnalysis = {
298
+ source,
299
+ commands: [],
300
+ redirects: [],
301
+ redirectTargets: [],
302
+ structure: [],
303
+ allowStructureSafe: true,
304
+ errors: [],
305
+ };
306
+ if (source.length > MAX_BASH_SOURCE_LENGTH) {
307
+ analysis.errors.push({
308
+ message:
309
+ `Bash input length ${source.length} exceeds ${MAX_BASH_SOURCE_LENGTH}`,
310
+ });
311
+ return analysis;
312
+ }
313
+
314
+ function analyzeRedirect(redirect: Redirect): BashRedirectAnalysis {
315
+ return {
316
+ operator: redirect.operator,
317
+ target: redirect.target?.value,
318
+ targetDynamic: !!redirect.target && !wordIsStatic(redirect.target),
319
+ fileDescriptor: redirect.fileDescriptor,
320
+ variableName: redirect.variableName,
321
+ heredoc: redirect.operator === "<<" || redirect.operator === "<<-",
322
+ };
323
+ }
324
+
325
+ function visitRedirect(
326
+ redirect: Redirect,
327
+ currentSource: string,
328
+ depth: number,
329
+ ): string | undefined {
330
+ const redirectAnalysis = analyzeRedirect(redirect);
331
+ analysis.redirects.push(redirectAnalysis);
332
+ analysis.structure.push(
333
+ `redirect:${redirect.fileDescriptor ?? ""}:${redirect.variableName ?? ""}:${redirect.operator}:${redirectAnalysis.heredoc ? "heredoc" : "file"}`,
334
+ );
335
+ if (redirect.target) visitWord(redirect.target, currentSource, depth);
336
+ if (redirect.body) visitWord(redirect.body, currentSource, depth);
337
+ const target = redirect.target?.value;
338
+ if (target) analysis.redirectTargets.push(target);
339
+ return target;
340
+ }
341
+
342
+ function visitPart(
343
+ part: WordPart,
344
+ currentSource: string,
345
+ depth: number,
346
+ ): void {
347
+ switch (part.type) {
348
+ case "Literal":
349
+ case "SingleQuoted":
350
+ case "AnsiCQuoted":
351
+ case "SimpleExpansion":
352
+ return;
353
+ case "DoubleQuoted":
354
+ case "LocaleString":
355
+ for (const child of part.parts) visitPart(child, currentSource, depth);
356
+ return;
357
+ case "ParameterExpansion":
358
+ for (const indexPart of part.indexParts ?? []) {
359
+ visitPart(indexPart, currentSource, depth);
360
+ }
361
+ if (part.operand) visitWord(part.operand, currentSource, depth);
362
+ if (part.slice) {
363
+ visitWord(part.slice.offset, currentSource, depth);
364
+ if (part.slice.length) {
365
+ visitWord(part.slice.length, currentSource, depth);
366
+ }
367
+ }
368
+ if (part.replace) {
369
+ visitWord(part.replace.pattern, currentSource, depth);
370
+ visitWord(part.replace.replacement, currentSource, depth);
371
+ }
372
+ return;
373
+ case "CommandExpansion":
374
+ case "ProcessSubstitution":
375
+ analysis.structure.push(
376
+ `part:${part.type}:${part.type === "ProcessSubstitution" ? part.operator : ""}`,
377
+ );
378
+ if (part.script) {
379
+ visitScript(part.script, part.script.source ?? currentSource, depth);
380
+ }
381
+ return;
382
+ case "ArithmeticExpansion":
383
+ if (part.expression) {
384
+ visitArithmetic(part.expression, currentSource, depth);
385
+ }
386
+ return;
387
+ case "ExtendedGlob":
388
+ case "BraceExpansion":
389
+ for (const nestedPart of part.parts ?? []) {
390
+ visitPart(nestedPart, currentSource, depth);
391
+ }
392
+ return;
393
+ }
394
+ }
395
+
396
+ function visitWord(word: Word, currentSource: string, depth: number): void {
397
+ for (const part of wordParts(word)) {
398
+ visitPart(part, currentSource, depth);
399
+ }
400
+ }
401
+
402
+ function visitArithmetic(
403
+ expression: ArithmeticExpression,
404
+ currentSource: string,
405
+ depth: number,
406
+ ): void {
407
+ switch (expression.type) {
408
+ case "ArithmeticBinary":
409
+ visitArithmetic(expression.left, currentSource, depth);
410
+ visitArithmetic(expression.right, currentSource, depth);
411
+ return;
412
+ case "ArithmeticUnary":
413
+ visitArithmetic(expression.operand, currentSource, depth);
414
+ return;
415
+ case "ArithmeticTernary":
416
+ visitArithmetic(expression.test, currentSource, depth);
417
+ visitArithmetic(expression.consequent, currentSource, depth);
418
+ visitArithmetic(expression.alternate, currentSource, depth);
419
+ return;
420
+ case "ArithmeticGroup":
421
+ visitArithmetic(expression.expression, currentSource, depth);
422
+ return;
423
+ case "ArithmeticWord":
424
+ for (const part of expression.parts ?? []) {
425
+ visitPart(part, currentSource, depth);
426
+ }
427
+ return;
428
+ case "ArithmeticCommandExpansion":
429
+ analysis.structure.push("part:ArithmeticCommandExpansion");
430
+ if (expression.script) {
431
+ visitScript(
432
+ expression.script,
433
+ expression.script.source ?? currentSource,
434
+ depth,
435
+ );
436
+ }
437
+ return;
438
+ }
439
+ }
440
+
441
+ function visitTest(
442
+ expression: TestExpression,
443
+ currentSource: string,
444
+ depth: number,
445
+ ): void {
446
+ switch (expression.type) {
447
+ case "TestUnary":
448
+ visitWord(expression.operand, currentSource, depth);
449
+ return;
450
+ case "TestBinary":
451
+ visitWord(expression.left, currentSource, depth);
452
+ visitWord(expression.right, currentSource, depth);
453
+ return;
454
+ case "TestLogical":
455
+ visitTest(expression.left, currentSource, depth);
456
+ visitTest(expression.right, currentSource, depth);
457
+ return;
458
+ case "TestNot":
459
+ visitTest(expression.operand, currentSource, depth);
460
+ return;
461
+ case "TestGroup":
462
+ visitTest(expression.expression, currentSource, depth);
463
+ return;
464
+ }
465
+ }
466
+
467
+ function nodeStructureToken(node: Node): string {
468
+ switch (node.type) {
469
+ case "Command":
470
+ return `node:Command:${node.prefix.length}:${node.redirects.length}`;
471
+ case "Pipeline":
472
+ return `node:Pipeline:${node.negated ? "negated" : "plain"}:${node.time ? "time" : "plain"}:${node.operators.join(",")}`;
473
+ case "AndOr":
474
+ return `node:AndOr:${node.operators.join(",")}`;
475
+ case "If":
476
+ return `node:If:${node.else?.type ?? "none"}`;
477
+ case "For":
478
+ case "Select":
479
+ return `node:${node.type}:${node.wordlist.length}`;
480
+ case "ArithmeticFor":
481
+ return `node:ArithmeticFor:${node.initialize ? 1 : 0}:${node.test ? 1 : 0}:${node.update ? 1 : 0}`;
482
+ case "While":
483
+ return `node:While:${node.kind}`;
484
+ case "Function":
485
+ return `node:Function:${node.redirects.length}`;
486
+ case "Subshell":
487
+ case "BraceGroup":
488
+ case "TestCommand":
489
+ case "ArithmeticCommand":
490
+ return `node:${node.type}`;
491
+ case "CompoundList":
492
+ return `node:CompoundList:${node.commands.length}`;
493
+ case "Case":
494
+ return `node:Case:${node.items.map((item) => item.terminator ?? "none").join(",")}`;
495
+ case "Coproc":
496
+ return `node:Coproc:${node.redirects.length}`;
497
+ case "Statement":
498
+ return `node:Statement:${node.background ? "background" : "foreground"}:${node.redirects.length}`;
499
+ default:
500
+ return exhaustiveNode(node);
501
+ }
502
+ }
503
+
504
+ function visitNode(node: Node, nodeSource: string, depth: number): void {
505
+ analysis.structure.push(nodeStructureToken(node));
506
+ if (
507
+ node.type === "For" ||
508
+ node.type === "Select" ||
509
+ node.type === "ArithmeticFor" ||
510
+ node.type === "Function" ||
511
+ node.type === "Case" ||
512
+ node.type === "Coproc" ||
513
+ node.type === "TestCommand" ||
514
+ node.type === "ArithmeticCommand"
515
+ ) {
516
+ analysis.allowStructureSafe = false;
517
+ }
518
+ switch (node.type) {
519
+ case "Command": {
520
+ const prefixWords = node.prefix.map((prefix) => prefix.text);
521
+ const commandWords = node.name ? [node.name, ...node.suffix] : node.suffix;
522
+ const renderedWords = [
523
+ ...prefixWords,
524
+ ...commandWords.map((word) => word.text),
525
+ ];
526
+ const values = [
527
+ ...prefixWords,
528
+ ...commandWords.map((word) => word.value),
529
+ ];
530
+ const redirects = node.redirects.map(analyzeRedirect);
531
+ const redirectTargets = redirects
532
+ .map((redirect) => redirect.target)
533
+ .filter((target): target is string => !!target);
534
+ const normalizedName = commandName(node.name?.value);
535
+ const invocation = effectiveCommandInvocation(normalizedName, node.suffix);
536
+ const wrapper = nestedShell(invocation);
537
+ const wrapperSource = wrapper?.source;
538
+ analysis.commands.push({
539
+ raw: nodeSource.slice(node.pos, node.end),
540
+ text: renderedWords.join(" "),
541
+ name: normalizedName,
542
+ words: values,
543
+ args: node.suffix.map((word) => word.value),
544
+ argTexts: node.suffix.map((word) => word.text),
545
+ effectiveCommand: {
546
+ name: invocation.name,
547
+ args: invocation.argumentWords.map((word) => word.value),
548
+ argTexts: invocation.argumentWords.map((word) => word.text),
549
+ argTildeExpansions:
550
+ invocation.argumentWords.map(wordHasTildeExpansion),
551
+ unresolvedTransparentDispatch:
552
+ invocation.unresolvedTransparentDispatch,
553
+ },
554
+ redirects,
555
+ redirectTargets,
556
+ dynamic: commandWords.some((word) => !wordIsStatic(word)),
557
+ dynamicName: !!node.name && !wordIsStatic(node.name),
558
+ dynamicShellScript:
559
+ !!wrapper?.hasScriptArgument && wrapperSource === undefined,
560
+ pos: node.pos,
561
+ end: node.end,
562
+ });
563
+
564
+ for (const redirect of node.redirects) {
565
+ visitRedirect(redirect, nodeSource, depth);
566
+ }
567
+ if (node.name) visitWord(node.name, nodeSource, depth);
568
+ for (const prefix of node.prefix) {
569
+ if (prefix.value) visitWord(prefix.value, nodeSource, depth);
570
+ for (const word of prefix.array ?? []) {
571
+ visitWord(word, nodeSource, depth);
572
+ }
573
+ for (const part of prefix.indexParts ?? []) {
574
+ visitPart(part, nodeSource, depth);
575
+ }
576
+ }
577
+ for (const word of node.suffix) visitWord(word, nodeSource, depth);
578
+
579
+ if (wrapperSource !== undefined) {
580
+ analysis.structure.push(`wrapper:${wrapper?.name ?? normalizedName}`);
581
+ if (depth >= MAX_NESTED_SHELL_DEPTH) {
582
+ analysis.errors.push({
583
+ message: `Nested shell depth exceeds ${MAX_NESTED_SHELL_DEPTH}`,
584
+ });
585
+ } else {
586
+ try {
587
+ visitScript(parser(wrapperSource), wrapperSource, depth + 1);
588
+ } catch (error) {
589
+ analysis.errors.push(parserException(error));
590
+ }
591
+ }
592
+ }
593
+ return;
594
+ }
595
+ case "Pipeline":
596
+ case "AndOr":
597
+ for (const command of node.commands) visitNode(command, nodeSource, depth);
598
+ return;
599
+ case "If":
600
+ visitNode(node.clause, nodeSource, depth);
601
+ visitNode(node.then, nodeSource, depth);
602
+ if (node.else) visitNode(node.else, nodeSource, depth);
603
+ return;
604
+ case "For":
605
+ case "Select":
606
+ visitWord(node.name, nodeSource, depth);
607
+ for (const word of node.wordlist) {
608
+ visitWord(word, nodeSource, depth);
609
+ }
610
+ visitNode(node.body, nodeSource, depth);
611
+ return;
612
+ case "ArithmeticFor":
613
+ if (node.initialize) visitArithmetic(node.initialize, nodeSource, depth);
614
+ if (node.test) visitArithmetic(node.test, nodeSource, depth);
615
+ if (node.update) visitArithmetic(node.update, nodeSource, depth);
616
+ visitNode(node.body, nodeSource, depth);
617
+ return;
618
+ case "While":
619
+ visitNode(node.clause, nodeSource, depth);
620
+ visitNode(node.body, nodeSource, depth);
621
+ return;
622
+ case "Function":
623
+ visitWord(node.name, nodeSource, depth);
624
+ for (const redirect of node.redirects) {
625
+ visitRedirect(redirect, nodeSource, depth);
626
+ }
627
+ visitNode(node.body, nodeSource, depth);
628
+ return;
629
+ case "Subshell":
630
+ case "BraceGroup":
631
+ visitNode(node.body, nodeSource, depth);
632
+ return;
633
+ case "CompoundList":
634
+ for (const statement of node.commands) {
635
+ visitNode(statement, nodeSource, depth);
636
+ }
637
+ return;
638
+ case "Case":
639
+ visitWord(node.word, nodeSource, depth);
640
+ for (const item of node.items) {
641
+ for (const pattern of item.pattern) {
642
+ visitWord(pattern, nodeSource, depth);
643
+ }
644
+ visitNode(item.body, nodeSource, depth);
645
+ }
646
+ return;
647
+ case "Coproc":
648
+ if (node.name) visitWord(node.name, nodeSource, depth);
649
+ for (const redirect of node.redirects) {
650
+ visitRedirect(redirect, nodeSource, depth);
651
+ }
652
+ visitNode(node.body, nodeSource, depth);
653
+ return;
654
+ case "TestCommand":
655
+ visitTest(node.expression, nodeSource, depth);
656
+ return;
657
+ case "ArithmeticCommand":
658
+ if (node.expression) {
659
+ visitArithmetic(node.expression, nodeSource, depth);
660
+ }
661
+ return;
662
+ case "Statement":
663
+ for (const redirect of node.redirects) {
664
+ visitRedirect(redirect, nodeSource, depth);
665
+ }
666
+ visitNode(node.command, nodeSource, depth);
667
+ return;
668
+ default:
669
+ exhaustiveNode(node);
670
+ }
671
+ }
672
+
673
+ function visitScript(
674
+ script: ParsedScript,
675
+ scriptSource: string,
676
+ depth: number,
677
+ ): void {
678
+ analysis.structure.push(`script:${script.commands.length}`);
679
+ for (const error of script.errors ?? []) analysis.errors.push(parseError(error));
680
+ for (const statement of script.commands) {
681
+ visitNode(statement, scriptSource, depth);
682
+ }
683
+ }
684
+
685
+ try {
686
+ visitScript(parser(source), source, 0);
687
+ } catch (error) {
688
+ analysis.errors.push(parserException(error));
689
+ }
690
+
691
+ return analysis;
692
+ }