@gotgenes/pi-permission-system 33.1.1 → 34.0.1
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.
- package/CHANGELOG.md +25 -0
- package/README.md +10 -0
- package/docs/configuration.md +4 -1
- package/package.json +1 -1
- package/src/access-intent/bash/bash-path-resolver.ts +39 -17
- package/src/access-intent/bash/command-enumeration.ts +83 -39
- package/src/access-intent/bash/node-text.ts +36 -0
- package/src/access-intent/bash/parse-health.ts +72 -0
- package/src/access-intent/bash/parser.ts +43 -64
- package/src/access-intent/bash/redirect-analysis.ts +82 -8
- package/src/access-intent/bash/redirect-arguments.ts +285 -0
- package/src/access-intent/bash/token-collection.ts +78 -15
- package/src/access-intent/bash/unresolved-salvage.ts +2 -5
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [34.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v34.0.0...pi-permission-system-v34.0.1) (2026-09-26)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** gate a command whose redirect comes before or inside its words ([cdfea17](https://github.com/gotgenes/pi-packages/commit/cdfea17f9169890d33a50eec447d26bb1d001ebf)), closes [#977](https://github.com/gotgenes/pi-packages/issues/977)
|
|
14
|
+
* **pi-permission-system:** resolve paths after a cd whose redirect precedes its target ([3f62169](https://github.com/gotgenes/pi-packages/commit/3f6216967e4e44dfabe68de18382298c8915a274)), closes [#977](https://github.com/gotgenes/pi-packages/issues/977)
|
|
15
|
+
* **pi-permission-system:** check the words after a mid-command redirect against the command's own rules ([73c41a5](https://github.com/gotgenes/pi-packages/commit/73c41a5ccb6b62b17451a197612983af5a3eb8e7)), closes [#977](https://github.com/gotgenes/pi-packages/issues/977)
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* **pi-permission-system:** list downstream packages in README ([629a174](https://github.com/gotgenes/pi-packages/commit/629a174d6d8f2aab9025f1780ae9b24c7557d7eb)), closes [#472](https://github.com/gotgenes/pi-packages/issues/472)
|
|
20
|
+
* **pi-permission-system:** record that a redirect's trailing words are reattached at the parser boundary ([2f72602](https://github.com/gotgenes/pi-packages/commit/2f72602260deb7a84d21eeb50b15fa4d0c38e8e2)), closes [#977](https://github.com/gotgenes/pi-packages/issues/977)
|
|
21
|
+
|
|
22
|
+
## [34.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v33.1.1...pi-permission-system-v34.0.0) (2026-09-25)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Bug Fixes
|
|
26
|
+
|
|
27
|
+
* **pi-permission-system:** **breaking:** check a redirect's target against path rules even when the file does not exist yet ([aa88e23](https://github.com/gotgenes/pi-packages/commit/aa88e23be9d6d994728855000eae95e00e0ca03e)), closes [#609](https://github.com/gotgenes/pi-packages/issues/609)
|
|
28
|
+
|
|
29
|
+
### Documentation
|
|
30
|
+
|
|
31
|
+
* **pi-permission-system:** record that a redirect's target is projected by its role ([c709401](https://github.com/gotgenes/pi-packages/commit/c70940154c63bb8d1c0b92ca1ef262ec22215102)), closes [#609](https://github.com/gotgenes/pi-packages/issues/609)
|
|
32
|
+
|
|
8
33
|
## [33.1.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v33.1.0...pi-permission-system-v33.1.1) (2026-09-24)
|
|
9
34
|
|
|
10
35
|
|
package/README.md
CHANGED
|
@@ -142,6 +142,16 @@ A subagent's ask is reviewed by the chain of the session serving it, one hop up,
|
|
|
142
142
|
|
|
143
143
|
For the full reference — all surfaces, runtime knobs, per-agent overrides, merge semantics, and common recipes — see [docs/configuration.md](docs/configuration.md).
|
|
144
144
|
|
|
145
|
+
## Downstream packages
|
|
146
|
+
|
|
147
|
+
These packages build on this extension's seams.
|
|
148
|
+
Each one that registers an authorizer link decides nothing until you name it in `authorizerChain`.
|
|
149
|
+
|
|
150
|
+
- [`@gotgenes/pi-permission-model-judge`](https://www.npmjs.com/package/@gotgenes/pi-permission-model-judge) (first-party): a deny-first model reviewer that auto-denies mistyped out-of-directory paths.
|
|
151
|
+
- [`pi-permission-classifier`](https://github.com/TacoTakumi/pi-permission-classifier) by [@TacoTakumi](https://github.com/TacoTakumi): an auto-approve mode in which a light model reviews each `ask` and returns allow, deny with a short reason, or defer to you; every failure path defers.
|
|
152
|
+
|
|
153
|
+
Third-party packages are maintained by their authors; review one before granting it a place in your chain.
|
|
154
|
+
|
|
145
155
|
## Upgrading
|
|
146
156
|
|
|
147
157
|
### 22.0.0 — project config requires project trust
|
package/docs/configuration.md
CHANGED
|
@@ -739,7 +739,7 @@ Quoting is understood, so `ls "$HOME/x"` and `ls $HOME/x` are treated alike.
|
|
|
739
739
|
|
|
740
740
|
What the bash projection resolves:
|
|
741
741
|
|
|
742
|
-
- Absolute, home-relative (`~/`), parent-traversal (`../`), and separator-bearing tokens, plus redirect targets (`> out.txt
|
|
742
|
+
- Absolute, home-relative (`~/`), parent-traversal (`../`), and separator-bearing tokens, plus redirect targets (`> out.txt`, including a file the redirect creates) and values embedded in long options (`--file=/tmp/patterns`).
|
|
743
743
|
- The plain shell variables `$HOME` / `${HOME}` and `$PWD` / `${PWD}`, so `$HOME/x` is gated exactly as `~/x` and the literal absolute spelling, whether or not the target exists.
|
|
744
744
|
- Relative tokens, against the working directory produced by folding literal current-shell `cd` commands.
|
|
745
745
|
- A bare token (`cat id_rsa`) when it names an existing filesystem entry.
|
|
@@ -860,6 +860,9 @@ A tool's identity establishes its direction, and on the bash surface a redirect
|
|
|
860
860
|
An access whose direction cannot be established consults **both** surfaces and takes the more restrictive answer.
|
|
861
861
|
That is deliberate: an unproven access is never treated as the narrower one.
|
|
862
862
|
|
|
863
|
+
A redirect's target reaches its surface whether or not the file exists yet, so a `path_write` pattern governs what a command may create through `>`, not only what it may overwrite.
|
|
864
|
+
Only the redirect's literal target counts: a computed one (`> "$OUT"`) is not resolved, as for any other computed path.
|
|
865
|
+
|
|
863
866
|
A redirect the parser could not make sense of is unproven for the same reason.
|
|
864
867
|
The read-write open `<>` is the clearest case: `tree-sitter-bash` has no node for it, so neither half of the operator can be trusted to describe the whole, and its destination consults both surfaces rather than the one the surviving half would name.
|
|
865
868
|
The rule is about the parse rather than about `<>`, so it also covers a redirect that is itself well-formed but sits beside something the parser could not read: in `cat $(( > out.txt`, the `> out.txt` consults both surfaces too.
|
package/package.json
CHANGED
|
@@ -9,17 +9,20 @@ import type { PathNormalizer } from "#src/path/path-normalizer";
|
|
|
9
9
|
import { isSafeSystemPath } from "#src/path/safe-system-paths";
|
|
10
10
|
import { ARG_NODE_TYPES, SKIP_SUBTREE_TYPES } from "./node-text";
|
|
11
11
|
import type { TSNode } from "./parser";
|
|
12
|
+
import { REDIRECT_NODE_TYPES } from "./redirect-analysis";
|
|
12
13
|
import {
|
|
13
14
|
classifyBareTokenCandidate,
|
|
14
15
|
classifyTokenAsPathCandidate,
|
|
15
16
|
classifyTokenAsRuleCandidate,
|
|
16
17
|
} from "./token-classification";
|
|
17
18
|
import {
|
|
19
|
+
COMMAND_PREFIX_TYPES,
|
|
18
20
|
collectCommandTokens,
|
|
19
21
|
collectPathCandidateTokens,
|
|
20
22
|
collectRedirectTokens,
|
|
21
23
|
extractCommandName,
|
|
22
24
|
type PathToken,
|
|
25
|
+
type TokenRole,
|
|
23
26
|
} from "./token-collection";
|
|
24
27
|
|
|
25
28
|
// ── Internal types ───────────────────────────────────────────────────────────
|
|
@@ -40,13 +43,14 @@ type EffectiveBase =
|
|
|
40
43
|
|
|
41
44
|
/**
|
|
42
45
|
* A path-candidate token paired with the effective working directory projected
|
|
43
|
-
* onto the point in the command stream where it appears,
|
|
44
|
-
* position proved.
|
|
46
|
+
* onto the point in the command stream where it appears, the effect its
|
|
47
|
+
* position proved, and the role its collector gave it.
|
|
45
48
|
*/
|
|
46
49
|
interface PathCandidate {
|
|
47
50
|
readonly token: string;
|
|
48
51
|
readonly base: EffectiveBase;
|
|
49
52
|
readonly effect: TokenEffect;
|
|
53
|
+
readonly role: TokenRole;
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
/** A promoted bare token and its resolved path, before an effect is attached. */
|
|
@@ -101,9 +105,12 @@ const UNKNOWN_BASE: EffectiveBase = { kind: "unknown" };
|
|
|
101
105
|
* decision — so no walk step re-reads the platform or threads the cwd.
|
|
102
106
|
*
|
|
103
107
|
* A bare token that fails both shape gates is admitted when the normalizer's
|
|
104
|
-
* existence probe says it names a real filesystem entry (ADR 0009, #645).
|
|
105
|
-
*
|
|
106
|
-
*
|
|
108
|
+
* existence probe says it names a real filesystem entry (ADR 0009, #645). A
|
|
109
|
+
* redirect's own target needs neither: its collector proved it names a file,
|
|
110
|
+
* so its `redirect-destination` role admits it whether or not the file exists
|
|
111
|
+
* yet (#609). The resolver consults no ruleset: candidacy is a syntax and
|
|
112
|
+
* filesystem question, and the policy decision belongs to the gates
|
|
113
|
+
* downstream.
|
|
107
114
|
*
|
|
108
115
|
* Tell-don't-ask: callers hand it a parsed tree and receive the resolved
|
|
109
116
|
* {@link ResolvedBashPaths} slices in one {@link resolve} call; the AST walk,
|
|
@@ -448,7 +455,8 @@ export class BashPathResolver {
|
|
|
448
455
|
* Project the collected candidates into deduplicated external paths.
|
|
449
456
|
*
|
|
450
457
|
* Filters candidates through the strict path classifier
|
|
451
|
-
* (`classifyTokenAsPathCandidate`)
|
|
458
|
+
* (`classifyTokenAsPathCandidate`) unless their role already admits them,
|
|
459
|
+
* resolves each against its effective working
|
|
452
460
|
* directory base, and returns only paths that resolve outside the baked cwd in
|
|
453
461
|
* their lexical (as-typed, normalized but not symlink-resolved) form.
|
|
454
462
|
*
|
|
@@ -462,8 +470,10 @@ export class BashPathResolver {
|
|
|
462
470
|
const seen = new Map<string, number>();
|
|
463
471
|
const externalPaths: BashExternalPath[] = [];
|
|
464
472
|
|
|
465
|
-
for (const { token, base, effect } of candidates) {
|
|
466
|
-
const candidate =
|
|
473
|
+
for (const { token, base, effect, role } of candidates) {
|
|
474
|
+
const candidate = admittedByRole(role)
|
|
475
|
+
? token
|
|
476
|
+
: classifyTokenAsPathCandidate(token);
|
|
467
477
|
if (!candidate) {
|
|
468
478
|
// A bare token the strict shape gate rejects can still escape the tree
|
|
469
479
|
// through a symlink, so probe it and apply the ordinary boundary
|
|
@@ -542,7 +552,8 @@ export class BashPathResolver {
|
|
|
542
552
|
* Filters candidates through the broad path classifier
|
|
543
553
|
* (`classifyTokenAsRuleCandidate`), falling back to {@link probeBareToken}
|
|
544
554
|
* for a bare token the broad classifier rejects for shape — admitted only
|
|
545
|
-
* when it names an existing filesystem entry (#645).
|
|
555
|
+
* when it names an existing filesystem entry (#645). A token its role
|
|
556
|
+
* admits skips both (#609).
|
|
546
557
|
* On win32 the broad classifier is told to treat a backslash as a path
|
|
547
558
|
* separator, so a backslash-relative token (`dir\file`) is recognized as a
|
|
548
559
|
* rule candidate the same as its forward-slash equivalent (#520); on POSIX
|
|
@@ -558,11 +569,10 @@ export class BashPathResolver {
|
|
|
558
569
|
const seen = new Map<string, number>();
|
|
559
570
|
const result: BashPathRuleCandidate[] = [];
|
|
560
571
|
|
|
561
|
-
for (const { token, base, effect } of candidates) {
|
|
562
|
-
const shaped =
|
|
563
|
-
token
|
|
564
|
-
this.normalizer.flavor
|
|
565
|
-
);
|
|
572
|
+
for (const { token, base, effect, role } of candidates) {
|
|
573
|
+
const shaped = admittedByRole(role)
|
|
574
|
+
? token
|
|
575
|
+
: classifyTokenAsRuleCandidate(token, this.normalizer.flavor);
|
|
566
576
|
const probed =
|
|
567
577
|
shaped === null
|
|
568
578
|
? this.probeBareToken(token, base)
|
|
@@ -677,7 +687,18 @@ function tagTokens(
|
|
|
677
687
|
base: EffectiveBase,
|
|
678
688
|
out: PathCandidate[],
|
|
679
689
|
): void {
|
|
680
|
-
for (const { token, effect } of tokens)
|
|
690
|
+
for (const { token, effect, role } of tokens) {
|
|
691
|
+
out.push({ token, base, effect, role });
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Whether a candidate's role settles its candidacy, so neither shape gate nor
|
|
697
|
+
* the existence probe is consulted: a redirect's own target is proven by
|
|
698
|
+
* syntax to name a file, including one the command is about to create.
|
|
699
|
+
*/
|
|
700
|
+
function admittedByRole(role: TokenRole): boolean {
|
|
701
|
+
return role === "redirect-destination";
|
|
681
702
|
}
|
|
682
703
|
|
|
683
704
|
/**
|
|
@@ -720,8 +741,9 @@ function cdLiteralTarget(commandNode: TSNode): string | null {
|
|
|
720
741
|
for (let i = 0; i < commandNode.childCount; i++) {
|
|
721
742
|
const child = commandNode.child(i);
|
|
722
743
|
if (!child) continue;
|
|
723
|
-
if (
|
|
724
|
-
|
|
744
|
+
if (COMMAND_PREFIX_TYPES.has(child.type)) continue;
|
|
745
|
+
// A redirect is not cd's operand, wherever it sits (`2>/dev/null cd a`).
|
|
746
|
+
if (REDIRECT_NODE_TYPES.has(child.type)) continue;
|
|
725
747
|
if (!child.isNamed) continue;
|
|
726
748
|
// Skip the `--` end-of-flags marker; the next argument is the target.
|
|
727
749
|
if (child.type === "word" && child.text === "--") continue;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { BashCommandContext, FloorExemption } from "#src/types";
|
|
2
2
|
import { EXECUTION_HOST_TYPES, forEachExecutionIn } from "./nested-execution";
|
|
3
|
-
import { parseUnresolvedWithin
|
|
4
|
-
import {
|
|
3
|
+
import { parseUnresolvedWithin } from "./parse-health";
|
|
4
|
+
import type { TSNode } from "./parser";
|
|
5
|
+
import { REDIRECT_NODE_TYPES, redirectMayWriteFile } from "./redirect-analysis";
|
|
5
6
|
import {
|
|
6
7
|
type CommandWord,
|
|
7
8
|
classifyWrapperWords,
|
|
@@ -414,25 +415,30 @@ function makeUnit(
|
|
|
414
415
|
* Build the unit for a `command` node, reading its words once to answer all
|
|
415
416
|
* three wrapper questions: whether the unit is floored, what it actually runs,
|
|
416
417
|
* and whether the floor still has a reason to hold.
|
|
418
|
+
*
|
|
419
|
+
* The floor question also reads the command's own redirects: one written
|
|
420
|
+
* before or between the words (`>/tmp/o xargs grep foo`) writes a file as
|
|
421
|
+
* surely as one on the enclosing statement.
|
|
417
422
|
*/
|
|
418
423
|
function makeCommandUnit(node: TSNode, scope: UnitScope): BashCommand {
|
|
419
|
-
const text =
|
|
420
|
-
const words = readCommandWords(node);
|
|
424
|
+
const { text, words } = readCommandUnit(node);
|
|
421
425
|
return makeUnit(text, scope, {
|
|
422
426
|
wrapperKind: classifyWrapperWords(words),
|
|
423
427
|
executedUnit: executedUnitOf(text, words) ?? undefined,
|
|
424
|
-
floorExemption: isTransparentWrapper(words, scope)
|
|
428
|
+
floorExemption: isTransparentWrapper(words, redirectedScope(node, scope))
|
|
425
429
|
? "core-reader"
|
|
426
430
|
: undefined,
|
|
427
431
|
});
|
|
428
432
|
}
|
|
429
433
|
|
|
430
434
|
/**
|
|
431
|
-
* The scope a
|
|
432
|
-
*
|
|
435
|
+
* The scope a node's own children run under: the enclosing one, plus a write
|
|
436
|
+
* unless every `file_redirect` among its children provably only reads.
|
|
433
437
|
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
438
|
+
* Asked of a `redirected_statement` and of a `command`, since a redirect may
|
|
439
|
+
* hang off either. On a statement, the redirect belongs to the last element of
|
|
440
|
+
* a pipeline, but it hangs off the whole statement in the parse tree, so every
|
|
441
|
+
* command beneath it is marked.
|
|
436
442
|
* Over-attributing is the fail-closed direction — the flag can only withhold an
|
|
437
443
|
* exemption, never grant one — which is also why the question asked of each
|
|
438
444
|
* redirect is a refusal rather than a proof.
|
|
@@ -450,27 +456,75 @@ function redirectedScope(node: TSNode, scope: UnitScope): UnitScope {
|
|
|
450
456
|
}
|
|
451
457
|
|
|
452
458
|
/**
|
|
453
|
-
* A `command` node's
|
|
454
|
-
*
|
|
459
|
+
* A `command` node's unit: the command-pattern text a bash rule is matched
|
|
460
|
+
* against, and its words (the `command_name` followed by its arguments), each
|
|
461
|
+
* carrying its offset into that text.
|
|
462
|
+
*
|
|
463
|
+
* The text runs from the first word to the last, so it leaves out two kinds of
|
|
464
|
+
* child that are not words of the command:
|
|
455
465
|
*
|
|
456
|
-
*
|
|
457
|
-
* `
|
|
458
|
-
*
|
|
466
|
+
* - An env-var prefix (`AWS_PROFILE=prod aws …`, `PGPASSWORD=…`), which is part
|
|
467
|
+
* of the `command` node's text but must not defeat a rule that gates the
|
|
468
|
+
* underlying command.
|
|
469
|
+
* - A redirect, wherever it sits (`2>/dev/null git push`, `git <<< x push`).
|
|
470
|
+
* Bash accepts one anywhere in a simple command, and its position does not
|
|
471
|
+
* change which command runs, so it must not change which rule applies either
|
|
472
|
+
* (#977).
|
|
473
|
+
*
|
|
474
|
+
* The source between two consecutive words is kept verbatim, so a command with
|
|
475
|
+
* no hosted redirect keeps its exact spacing and line continuations; where a
|
|
476
|
+
* redirect sat between two words, one space joins them instead.
|
|
477
|
+
* A pure assignment (`FOO=bar`, no `command_name`) runs no command, has no
|
|
478
|
+
* words, and keeps its whole text.
|
|
459
479
|
*/
|
|
460
|
-
function
|
|
480
|
+
function readCommandUnit(node: TSNode): {
|
|
481
|
+
text: string;
|
|
482
|
+
words: CommandWord[];
|
|
483
|
+
} {
|
|
461
484
|
const nodes = commandWordNodes(node);
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
485
|
+
if (nodes.length === 0) return { text: node.text, words: [] };
|
|
486
|
+
|
|
487
|
+
const redirects = hostedRedirects(node);
|
|
488
|
+
const words: CommandWord[] = [];
|
|
489
|
+
let text = "";
|
|
490
|
+
let previous: TSNode | undefined;
|
|
491
|
+
for (const word of nodes) {
|
|
492
|
+
if (previous) text += gapBetween(node, previous, word, redirects);
|
|
493
|
+
words.push({ text: word.text, offset: text.length });
|
|
494
|
+
text += word.text;
|
|
495
|
+
previous = word;
|
|
496
|
+
}
|
|
497
|
+
return { text, words };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* The text that joins two consecutive words of a unit: the command's own
|
|
502
|
+
* source between them, or one space where a hosted redirect sat there.
|
|
503
|
+
*/
|
|
504
|
+
function gapBetween(
|
|
505
|
+
command: TSNode,
|
|
506
|
+
before: TSNode,
|
|
507
|
+
after: TSNode,
|
|
508
|
+
redirects: readonly TSNode[],
|
|
509
|
+
): string {
|
|
510
|
+
const hostsRedirect = redirects.some(
|
|
511
|
+
(redirect) =>
|
|
512
|
+
redirect.startIndex >= before.endIndex &&
|
|
513
|
+
redirect.startIndex < after.startIndex,
|
|
514
|
+
);
|
|
515
|
+
if (hostsRedirect) return " ";
|
|
516
|
+
return command.text.slice(
|
|
517
|
+
before.endIndex - command.startIndex,
|
|
518
|
+
after.startIndex - command.startIndex,
|
|
519
|
+
);
|
|
467
520
|
}
|
|
468
521
|
|
|
469
522
|
/**
|
|
470
|
-
* The nodes {@link
|
|
523
|
+
* The nodes {@link readCommandUnit} reports words for, in the same order: every
|
|
524
|
+
* named child except a prefix assignment and a hosted redirect.
|
|
471
525
|
*
|
|
472
|
-
* Split out so a consumer that needs a *node* rather than a word
|
|
473
|
-
* command masker, which offsets a re-parse by the payload node's `startIndex`
|
|
526
|
+
* Split out so a consumer that needs a *node* rather than a word (the log's
|
|
527
|
+
* command masker, which offsets a re-parse by the payload node's `startIndex`)
|
|
474
528
|
* walks the identical filtered list. Two walks over the same children with the
|
|
475
529
|
* same filter, written twice, is how the two come to disagree about which word
|
|
476
530
|
* is at which index.
|
|
@@ -481,30 +535,20 @@ function commandWordNodes(node: TSNode): TSNode[] {
|
|
|
481
535
|
const child = node.child(i);
|
|
482
536
|
if (!child?.isNamed) continue;
|
|
483
537
|
if (child.type === "variable_assignment") continue;
|
|
538
|
+
if (REDIRECT_NODE_TYPES.has(child.type)) continue;
|
|
484
539
|
nodes.push(child);
|
|
485
540
|
}
|
|
486
541
|
return nodes;
|
|
487
542
|
}
|
|
488
543
|
|
|
489
|
-
/**
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
*
|
|
493
|
-
* An env-var prefix (`AWS_PROFILE=prod aws …`, `PGPASSWORD=…`) is part of the
|
|
494
|
-
* `command` node's text but must not defeat a rule that gates the underlying
|
|
495
|
-
* command, so matching targets the text from the first non-assignment child
|
|
496
|
-
* (the `command_name`) onward, sliced verbatim to preserve spacing. A pure
|
|
497
|
-
* assignment (`FOO=bar`, no `command_name`) runs no command and is returned
|
|
498
|
-
* unchanged.
|
|
499
|
-
*/
|
|
500
|
-
function commandUnitText(node: TSNode): string {
|
|
544
|
+
/** The redirects a `command` node hosts among its own children. */
|
|
545
|
+
function hostedRedirects(node: TSNode): TSNode[] {
|
|
546
|
+
const redirects: TSNode[] = [];
|
|
501
547
|
for (let i = 0; i < node.childCount; i++) {
|
|
502
548
|
const child = node.child(i);
|
|
503
|
-
if (child
|
|
504
|
-
return node.text.slice(child.startIndex - node.startIndex);
|
|
505
|
-
}
|
|
549
|
+
if (child && REDIRECT_NODE_TYPES.has(child.type)) redirects.push(child);
|
|
506
550
|
}
|
|
507
|
-
return
|
|
551
|
+
return redirects;
|
|
508
552
|
}
|
|
509
553
|
|
|
510
554
|
function descendCommandChildren(
|
|
@@ -27,6 +27,42 @@ export const ARG_NODE_TYPES = new Set([
|
|
|
27
27
|
"raw_string",
|
|
28
28
|
]);
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Whether an argument node's value is decided at run time: it contains a
|
|
32
|
+
* command or process substitution, an arithmetic expansion, or a variable
|
|
33
|
+
* expansion {@link resolvePlainVariableExpansion} cannot resolve.
|
|
34
|
+
*
|
|
35
|
+
* The complement of what {@link resolveNodeText} can spell exactly. A plain
|
|
36
|
+
* `$HOME` / `$PWD` reference resolves, so `"$HOME/out"` is not computed; any
|
|
37
|
+
* other expansion falls back to its own source text there, which names a file
|
|
38
|
+
* that is not the one the shell will touch (ADR 0009's computed-path residual).
|
|
39
|
+
* A single-quoted `'$x'` is a literal.
|
|
40
|
+
*/
|
|
41
|
+
export function hasComputedPart(node: TSNode): boolean {
|
|
42
|
+
if (COMPUTED_NODE_TYPES.has(node.type)) return true;
|
|
43
|
+
if (VARIABLE_EXPANSION_TYPES.has(node.type)) {
|
|
44
|
+
return resolvePlainVariableExpansion(node) === null;
|
|
45
|
+
}
|
|
46
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
47
|
+
const child = node.child(i);
|
|
48
|
+
if (child && hasComputedPart(child)) return true;
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Node types whose value only running the command can produce. */
|
|
54
|
+
const COMPUTED_NODE_TYPES: ReadonlySet<string> = new Set([
|
|
55
|
+
"command_substitution",
|
|
56
|
+
"process_substitution",
|
|
57
|
+
"arithmetic_expansion",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/** Variable references, computed unless they resolve as a plain reference. */
|
|
61
|
+
const VARIABLE_EXPANSION_TYPES: ReadonlySet<string> = new Set([
|
|
62
|
+
"simple_expansion",
|
|
63
|
+
"expansion",
|
|
64
|
+
]);
|
|
65
|
+
|
|
30
66
|
/**
|
|
31
67
|
* Resolve the "shell value" of an argument node — the string the shell
|
|
32
68
|
* would pass to the command after quote removal.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { TSNode } from "./parser";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether `tree-sitter-bash` resolved the syntax it was given: the parse's own
|
|
5
|
+
* health, as opposed to the structure of a successful parse.
|
|
6
|
+
*
|
|
7
|
+
* Kept apart from `parser.ts`, which says where a tree comes from, so that a
|
|
8
|
+
* module the parser depends on (the redirect-argument correction reads a
|
|
9
|
+
* redirect through `redirect-analysis.ts`) can still ask these questions
|
|
10
|
+
* without an import cycle.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Whether tree-sitter failed to resolve the syntax at `node`.
|
|
15
|
+
*
|
|
16
|
+
* Error recovery disposes of text it cannot attach in one of two places, and
|
|
17
|
+
* which one it picks depends on what follows. The read-write open `<>`, which
|
|
18
|
+
* `tree-sitter-bash` 0.25.1 has no node for, shows both: `cat <> rw.txt` keeps
|
|
19
|
+
* the discarded `>` as an `ERROR` *child* of the redirect, while
|
|
20
|
+
* `cat <> ~/rw.txt` strands the `<` as an `ERROR` *sibling* ahead of a redirect
|
|
21
|
+
* that is otherwise indistinguishable from a genuine `> ~/rw.txt`. A reader
|
|
22
|
+
* that consults only the node's own subtree sees the first and not the second.
|
|
23
|
+
*
|
|
24
|
+
* The immediate predecessor, rather than the enclosing statement, is what makes
|
|
25
|
+
* the answer per-redirect: in `cat a > out.txt <> ~/rw.txt` the statement has
|
|
26
|
+
* an error but its first redirect is a fully resolved write, and condemning it
|
|
27
|
+
* would forfeit a proof the parse really did establish.
|
|
28
|
+
*
|
|
29
|
+
* The question is about the parse, not about `<>`, so the population is wider
|
|
30
|
+
* than the form that exposed it: `cat $(( > out.txt` and `echo ) > out.txt`
|
|
31
|
+
* both carry a perfectly good `> out.txt` whose predecessor failed for an
|
|
32
|
+
* unrelated reason, and both go unproven. That is the accepted cost, and it is
|
|
33
|
+
* the same shape as the only real occurrence measured across 5000+ logged
|
|
34
|
+
* commands — `git commit -F - <<'MSG' 2>&1 | tail -4`, valid bash the grammar
|
|
35
|
+
* cannot parse (ADR 0013's 2026-08-29 amendment), where the demoted token
|
|
36
|
+
* belongs to no `<>` either. Over-refusing costs a prompt; under-refusing hands
|
|
37
|
+
* a write to a read grant.
|
|
38
|
+
*
|
|
39
|
+
* This module is the one place {@link TSNode.hasError} and
|
|
40
|
+
* {@link TSNode.previousSibling} are read. Keeping the lateral navigation here
|
|
41
|
+
* is deliberate: recovering-parser behavior is a fact about tree-sitter rather
|
|
42
|
+
* than about any construct, so a caller asks this question instead of
|
|
43
|
+
* hand-rolling a sibling walk of its own.
|
|
44
|
+
*/
|
|
45
|
+
export function parseUnresolvedAt(node: TSNode): boolean {
|
|
46
|
+
return node.hasError || (node.previousSibling?.hasError ?? false);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether tree-sitter failed to resolve the syntax anywhere within `node`.
|
|
51
|
+
*
|
|
52
|
+
* The subtree-only question, and the one a walker descending statements asks:
|
|
53
|
+
* a statement holding an unresolved region is one whose recovered shape is
|
|
54
|
+
* invented rather than observed, so nothing beneath it is evidence of what
|
|
55
|
+
* runs. The failure can sit well below the statement that exposes it —
|
|
56
|
+
* `git commit -F - <<'MSG' 2>&1 | tail -4` strands its `ERROR` under
|
|
57
|
+
* `heredoc_redirect → file_redirect`, where no command node sees it.
|
|
58
|
+
*
|
|
59
|
+
* {@link parseUnresolvedAt} answers the redirect-shaped question instead,
|
|
60
|
+
* widening to the immediate predecessor because error recovery strands a
|
|
61
|
+
* discarded operator ahead of the redirect it belonged to. That widening is a
|
|
62
|
+
* fact about redirects, not about statements: a statement whose *predecessor*
|
|
63
|
+
* failed is not itself unparsed, and borrowing the wider predicate here would
|
|
64
|
+
* condemn every statement following a failed one.
|
|
65
|
+
*
|
|
66
|
+
* `unresolved-salvage.ts` asks the same question twice over: to locate the
|
|
67
|
+
* innermost region worth re-parsing, and to refuse the re-parse's own result
|
|
68
|
+
* when it failed too (#875).
|
|
69
|
+
*/
|
|
70
|
+
export function parseUnresolvedWithin(node: TSNode): boolean {
|
|
71
|
+
return node.hasError;
|
|
72
|
+
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { memoizeAsyncWithRetry } from "./async-cache";
|
|
3
|
+
import { reattachRedirectArguments } from "./redirect-arguments";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Minimal subset of web-tree-sitter's SyntaxNode used by the AST walker.
|
|
6
7
|
* Defined locally so callers do not need to import web-tree-sitter types.
|
|
7
8
|
*
|
|
8
9
|
* The last two members are the parse's own health, where every other member
|
|
9
|
-
* describes a *successful* parse's structure. They are read only by
|
|
10
|
-
*
|
|
10
|
+
* describes a *successful* parse's structure. They are read only by
|
|
11
|
+
* `parse-health.ts`'s two `parseUnresolved*` predicates — see their doc comments for why
|
|
11
12
|
* that boundary matters.
|
|
12
13
|
*/
|
|
13
14
|
export interface TSNode {
|
|
@@ -27,67 +28,6 @@ export interface TSNode {
|
|
|
27
28
|
child(index: number): TSNode | null;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
/**
|
|
31
|
-
* Whether tree-sitter failed to resolve the syntax at `node`.
|
|
32
|
-
*
|
|
33
|
-
* Error recovery disposes of text it cannot attach in one of two places, and
|
|
34
|
-
* which one it picks depends on what follows. The read-write open `<>`, which
|
|
35
|
-
* `tree-sitter-bash` 0.25.1 has no node for, shows both: `cat <> rw.txt` keeps
|
|
36
|
-
* the discarded `>` as an `ERROR` *child* of the redirect, while
|
|
37
|
-
* `cat <> ~/rw.txt` strands the `<` as an `ERROR` *sibling* ahead of a redirect
|
|
38
|
-
* that is otherwise indistinguishable from a genuine `> ~/rw.txt`. A reader
|
|
39
|
-
* that consults only the node's own subtree sees the first and not the second.
|
|
40
|
-
*
|
|
41
|
-
* The immediate predecessor, rather than the enclosing statement, is what makes
|
|
42
|
-
* the answer per-redirect: in `cat a > out.txt <> ~/rw.txt` the statement has
|
|
43
|
-
* an error but its first redirect is a fully resolved write, and condemning it
|
|
44
|
-
* would forfeit a proof the parse really did establish.
|
|
45
|
-
*
|
|
46
|
-
* The question is about the parse, not about `<>`, so the population is wider
|
|
47
|
-
* than the form that exposed it: `cat $(( > out.txt` and `echo ) > out.txt`
|
|
48
|
-
* both carry a perfectly good `> out.txt` whose predecessor failed for an
|
|
49
|
-
* unrelated reason, and both go unproven. That is the accepted cost, and it is
|
|
50
|
-
* the same shape as the only real occurrence measured across 5000+ logged
|
|
51
|
-
* commands — `git commit -F - <<'MSG' 2>&1 | tail -4`, valid bash the grammar
|
|
52
|
-
* cannot parse (ADR 0013's 2026-08-29 amendment), where the demoted token
|
|
53
|
-
* belongs to no `<>` either. Over-refusing costs a prompt; under-refusing hands
|
|
54
|
-
* a write to a read grant.
|
|
55
|
-
*
|
|
56
|
-
* This module is the one place {@link TSNode.hasError} and
|
|
57
|
-
* {@link TSNode.previousSibling} are read. Keeping the lateral navigation here
|
|
58
|
-
* is deliberate: recovering-parser behavior is a fact about tree-sitter rather
|
|
59
|
-
* than about any construct, so a caller asks this question instead of
|
|
60
|
-
* hand-rolling a sibling walk of its own.
|
|
61
|
-
*/
|
|
62
|
-
export function parseUnresolvedAt(node: TSNode): boolean {
|
|
63
|
-
return node.hasError || (node.previousSibling?.hasError ?? false);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Whether tree-sitter failed to resolve the syntax anywhere within `node`.
|
|
68
|
-
*
|
|
69
|
-
* The subtree-only question, and the one a walker descending statements asks:
|
|
70
|
-
* a statement holding an unresolved region is one whose recovered shape is
|
|
71
|
-
* invented rather than observed, so nothing beneath it is evidence of what
|
|
72
|
-
* runs. The failure can sit well below the statement that exposes it —
|
|
73
|
-
* `git commit -F - <<'MSG' 2>&1 | tail -4` strands its `ERROR` under
|
|
74
|
-
* `heredoc_redirect → file_redirect`, where no command node sees it.
|
|
75
|
-
*
|
|
76
|
-
* {@link parseUnresolvedAt} answers the redirect-shaped question instead,
|
|
77
|
-
* widening to the immediate predecessor because error recovery strands a
|
|
78
|
-
* discarded operator ahead of the redirect it belonged to. That widening is a
|
|
79
|
-
* fact about redirects, not about statements: a statement whose *predecessor*
|
|
80
|
-
* failed is not itself unparsed, and borrowing the wider predicate here would
|
|
81
|
-
* condemn every statement following a failed one.
|
|
82
|
-
*
|
|
83
|
-
* `unresolved-salvage.ts` asks the same question twice over: to locate the
|
|
84
|
-
* innermost region worth re-parsing, and to refuse the re-parse's own result
|
|
85
|
-
* when it failed too (#875).
|
|
86
|
-
*/
|
|
87
|
-
export function parseUnresolvedWithin(node: TSNode): boolean {
|
|
88
|
-
return node.hasError;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
31
|
/**
|
|
92
32
|
* The one parse capability a consumer needs to re-parse a fragment of a
|
|
93
33
|
* command on its own.
|
|
@@ -125,7 +65,46 @@ async function initParser(): Promise<TSParser> {
|
|
|
125
65
|
// Memoize on success but drop a rejected result so a transient init failure
|
|
126
66
|
// (e.g. a slow WASM load) is retried on the next tool call instead of poisoning
|
|
127
67
|
// the parser for the process lifetime.
|
|
128
|
-
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The parser every consumer reads the bash grammar through.
|
|
71
|
+
*
|
|
72
|
+
* Its trees are the grammar's with one correction applied where they enter the
|
|
73
|
+
* package: a word `tree-sitter-bash` hung on a redirect is handed back to the
|
|
74
|
+
* command it belongs to (`reattachRedirectArguments`, #977). Every walker, the
|
|
75
|
+
* salvage re-parse, and the log masker read that corrected tree, so none of
|
|
76
|
+
* them has to learn the grammar's quirk on its own.
|
|
77
|
+
*/
|
|
78
|
+
export const getParser = memoizeAsyncWithRetry(async () =>
|
|
79
|
+
correctingParser(await getGrammarParser()),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
function correctingParser(grammar: TSParser): TSParser {
|
|
83
|
+
return {
|
|
84
|
+
parse: (input) => {
|
|
85
|
+
const tree = grammar.parse(input);
|
|
86
|
+
if (!tree) return null;
|
|
87
|
+
return {
|
|
88
|
+
rootNode: reattachRedirectArguments(tree.rootNode),
|
|
89
|
+
delete: () => {
|
|
90
|
+
tree.delete();
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
delete: () => {
|
|
95
|
+
grammar.delete();
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `tree-sitter-bash`'s own parser, whose trees are exactly what the grammar
|
|
102
|
+
* produced.
|
|
103
|
+
*
|
|
104
|
+
* Production code reads {@link getParser}; this one exists so a test whose
|
|
105
|
+
* subject is the grammar's own shape can still see it.
|
|
106
|
+
*/
|
|
107
|
+
export const getGrammarParser = memoizeAsyncWithRetry(initParser);
|
|
129
108
|
|
|
130
109
|
// Resolved parser cached for synchronous access after warm-up. The tree-sitter
|
|
131
110
|
// parser is stateless (parse is a pure function of its input), so caching it at
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { type TokenEffect, UNPROVEN_EFFECT } from "#src/access-intent/effect";
|
|
2
2
|
import { redirectDestinationEffect } from "./command-effects";
|
|
3
|
-
import { parseUnresolvedAt
|
|
3
|
+
import { parseUnresolvedAt } from "./parse-health";
|
|
4
|
+
import type { TSNode } from "./parser";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* What a redirect node in the parse tree proves.
|
|
7
8
|
*
|
|
8
9
|
* `command-effects.ts` owns the operator *table* — which spelling means read,
|
|
9
10
|
* which means write — and this module owns reading a `file_redirect` node well
|
|
10
|
-
* enough to consult it: finding the operator among the node's children,
|
|
11
|
-
* telling a destination that names a file from one that names a descriptor
|
|
11
|
+
* enough to consult it: finding the operator among the node's children,
|
|
12
|
+
* telling a destination that names a file from one that names a descriptor,
|
|
13
|
+
* and naming which destination is the redirect's own target.
|
|
12
14
|
*
|
|
13
15
|
* The split exists because two callers need different answers from the same
|
|
14
16
|
* read, and — importantly — they need them under different burdens of proof.
|
|
@@ -24,6 +26,19 @@ import { parseUnresolvedAt, type TSNode } from "./parser";
|
|
|
24
26
|
* (#814).
|
|
25
27
|
*/
|
|
26
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The redirect node types a `command` or a statement can host.
|
|
31
|
+
*
|
|
32
|
+
* A redirect is not a word of the command it sits in, wherever it sits: bash
|
|
33
|
+
* accepts one before, between, or after the words (`2>/dev/null git push`), and
|
|
34
|
+
* none of them changes which command runs (#977).
|
|
35
|
+
*/
|
|
36
|
+
export const REDIRECT_NODE_TYPES: ReadonlySet<string> = new Set([
|
|
37
|
+
"file_redirect",
|
|
38
|
+
"herestring_redirect",
|
|
39
|
+
"heredoc_redirect",
|
|
40
|
+
]);
|
|
41
|
+
|
|
27
42
|
/**
|
|
28
43
|
* The effect `redirect` proves for `destination`, or `null` when the redirect
|
|
29
44
|
* names no file and no token should be collected.
|
|
@@ -92,6 +107,68 @@ export function redirectMayWriteFile(redirect: TSNode): boolean {
|
|
|
92
107
|
return false;
|
|
93
108
|
}
|
|
94
109
|
|
|
110
|
+
/**
|
|
111
|
+
* The child index of the node `redirect` reads or writes (its first named
|
|
112
|
+
* child after the operator), or `undefined` when it names none: nothing
|
|
113
|
+
* follows the operator, or the operator closes a descriptor (`>&-`, `<&-`).
|
|
114
|
+
*
|
|
115
|
+
* The operator is the redirect's only unnamed child, and a source descriptor
|
|
116
|
+
* (`2` in `2>`) precedes it, so the first named child after it is the
|
|
117
|
+
* target without asking its type.
|
|
118
|
+
*
|
|
119
|
+
* Only the first: tree-sitter-bash 0.25.1 declares the destination
|
|
120
|
+
* `repeat1`, so the words after it in `grep pat 2>/dev/null f.txt` parse as
|
|
121
|
+
* further destinations, while bash passes them to the redirected command as
|
|
122
|
+
* arguments ({@link trailingArgumentIndex} names where they begin, #977). A
|
|
123
|
+
* close operator takes an optional destination in the grammar, but it closes a
|
|
124
|
+
* descriptor and names no file, so a word after it is the command's too. An
|
|
125
|
+
* index rather than a node, because a caller iterating the children compares
|
|
126
|
+
* positions rather than wrapper identity.
|
|
127
|
+
*/
|
|
128
|
+
export function redirectTargetIndex(redirect: TSNode): number | undefined {
|
|
129
|
+
const operator = redirectOperatorIndex(redirect);
|
|
130
|
+
if (operator === undefined) return undefined;
|
|
131
|
+
if (CLOSE_OPERATORS.has(redirect.child(operator)?.type ?? "")) {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
return namedChildIndexAfter(redirect, operator);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The child index of the first word the grammar appended after `redirect`'s
|
|
139
|
+
* own target, or `undefined` when none follows.
|
|
140
|
+
*
|
|
141
|
+
* Every named child from there on is a word bash passes to the redirected
|
|
142
|
+
* command rather than a destination of the redirect: `f.txt` in
|
|
143
|
+
* `grep pat 2>/dev/null f.txt`, and `arg` in `cmd >&- arg`, where the close
|
|
144
|
+
* operator has no target at all (#977).
|
|
145
|
+
*/
|
|
146
|
+
export function trailingArgumentIndex(redirect: TSNode): number | undefined {
|
|
147
|
+
const operator = redirectOperatorIndex(redirect);
|
|
148
|
+
if (operator === undefined) return undefined;
|
|
149
|
+
const target = redirectTargetIndex(redirect);
|
|
150
|
+
return namedChildIndexAfter(redirect, target ?? operator);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Operators that close a descriptor, naming no file (`>&-`, `<&-`). */
|
|
154
|
+
const CLOSE_OPERATORS: ReadonlySet<string> = new Set([">&-", "<&-"]);
|
|
155
|
+
|
|
156
|
+
/** The index of `redirect`'s operator, its only unnamed child. */
|
|
157
|
+
function redirectOperatorIndex(redirect: TSNode): number | undefined {
|
|
158
|
+
for (let i = 0; i < redirect.childCount; i++) {
|
|
159
|
+
if (redirect.child(i)?.isNamed === false) return i;
|
|
160
|
+
}
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The index of the first named child of `node` after index `after`. */
|
|
165
|
+
function namedChildIndexAfter(node: TSNode, after: number): number | undefined {
|
|
166
|
+
for (let i = after + 1; i < node.childCount; i++) {
|
|
167
|
+
if (node.child(i)?.isNamed) return i;
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
95
172
|
/**
|
|
96
173
|
* Destination node types that name a file descriptor rather than a file, so
|
|
97
174
|
* `>&` / `<&` duplicate a stream instead of touching the filesystem.
|
|
@@ -112,9 +189,6 @@ const DESCRIPTOR_NODE_TYPES: ReadonlySet<string> = new Set([
|
|
|
112
189
|
* syntax proof is a lookup on the first one found.
|
|
113
190
|
*/
|
|
114
191
|
function redirectOperatorOf(node: TSNode): string {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (child && !child.isNamed) return child.type;
|
|
118
|
-
}
|
|
119
|
-
return "";
|
|
192
|
+
const operator = redirectOperatorIndex(node);
|
|
193
|
+
return operator === undefined ? "" : (node.child(operator)?.type ?? "");
|
|
120
194
|
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { parseUnresolvedWithin } from "./parse-health";
|
|
2
|
+
import type { TSNode } from "./parser";
|
|
3
|
+
import { trailingArgumentIndex } from "./redirect-analysis";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `tree-sitter-bash`'s parse with each word it hung on a redirect handed back
|
|
7
|
+
* to the command it belongs to.
|
|
8
|
+
*
|
|
9
|
+
* The grammar (0.25.1) declares a file redirect's destination `repeat1`, so in
|
|
10
|
+
* `git 2>/dev/null push --force` the words `push --force` parse as further
|
|
11
|
+
* destinations of the statement's redirect, where bash passes them to `git`
|
|
12
|
+
* (tree-sitter/tree-sitter-bash#233). Every consumer of the parse (the command
|
|
13
|
+
* enumerator, the path walkers, the effect proofs, the log masker) would
|
|
14
|
+
* otherwise have to learn that quirk on its own, so it is corrected once, here,
|
|
15
|
+
* where the tree enters the package (#977).
|
|
16
|
+
*
|
|
17
|
+
* The corrected shape is the one the grammar already produces for a redirect
|
|
18
|
+
* written before the command: the redirect becomes a child of the `command`,
|
|
19
|
+
* between its words. Every redirect from the body up to the last one carrying
|
|
20
|
+
* words moves into the command, each truncated after its own target and
|
|
21
|
+
* followed by its words; a redirect after the last word stays at the
|
|
22
|
+
* statement, and a statement left with no redirect gives way to its body.
|
|
23
|
+
*
|
|
24
|
+
* Three kinds of statement are left exactly as the grammar produced them:
|
|
25
|
+
*
|
|
26
|
+
* - One whose parse failed. Its units are floored already, and moving a word
|
|
27
|
+
* out of an unresolvable redirect would hand it the command's effect proof in
|
|
28
|
+
* place of the redirect's refusal to prove one (#814).
|
|
29
|
+
* - One whose body is not a command (`{ a; } 2>/dev/null b`), which bash
|
|
30
|
+
* rejects as a syntax error, so nothing runs.
|
|
31
|
+
* - One with no words after any redirect's target, which is almost every one.
|
|
32
|
+
*
|
|
33
|
+
* Returns `root` itself when nothing in the tree needs correcting. A corrected
|
|
34
|
+
* node reads its offsets and text from the source, so `startIndex`/`endIndex`
|
|
35
|
+
* stay positions in the command string every caller already slices.
|
|
36
|
+
*/
|
|
37
|
+
export function reattachRedirectArguments(root: TSNode): TSNode {
|
|
38
|
+
return correct(root) ?? root;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The corrected node, or `undefined` when nothing beneath `node` changed. */
|
|
42
|
+
function correct(node: TSNode): TSNode | undefined {
|
|
43
|
+
const children: TSNode[] = [];
|
|
44
|
+
let changed = false;
|
|
45
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
46
|
+
const child = node.child(i);
|
|
47
|
+
if (!child) continue;
|
|
48
|
+
const corrected = correct(child);
|
|
49
|
+
if (corrected) changed = true;
|
|
50
|
+
children.push(corrected ?? child);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (node.type === "redirected_statement" && !parseUnresolvedWithin(node)) {
|
|
54
|
+
const reattached = reattachStatement(node, children);
|
|
55
|
+
if (reattached) return reattached;
|
|
56
|
+
}
|
|
57
|
+
return changed
|
|
58
|
+
? adoptingView(node, children, parseUnresolvedWithin(node))
|
|
59
|
+
: undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Move the words the grammar hung on `statement`'s redirects into the command
|
|
64
|
+
* they belong to, or `undefined` when there are none or no command to take
|
|
65
|
+
* them. `children` are the statement's children, already corrected.
|
|
66
|
+
*/
|
|
67
|
+
function reattachStatement(
|
|
68
|
+
statement: TSNode,
|
|
69
|
+
children: readonly TSNode[],
|
|
70
|
+
): TSNode | undefined {
|
|
71
|
+
const bodyIndex = children.findIndex((child) => child.isNamed);
|
|
72
|
+
const body = children.at(bodyIndex);
|
|
73
|
+
if (!body || !reachesCommand(body)) return undefined;
|
|
74
|
+
|
|
75
|
+
const lastCarrier = children.findLastIndex(
|
|
76
|
+
(child) =>
|
|
77
|
+
child.type === "file_redirect" &&
|
|
78
|
+
trailingArgumentIndex(child) !== undefined,
|
|
79
|
+
);
|
|
80
|
+
if (lastCarrier === -1) return undefined;
|
|
81
|
+
|
|
82
|
+
const source = sourceOf(statement);
|
|
83
|
+
const moved = children
|
|
84
|
+
.slice(bodyIndex + 1, lastCarrier + 1)
|
|
85
|
+
.flatMap((child) => splitRedirect(child, source));
|
|
86
|
+
const newBody = appendToRightmostCommand(body, moved, source);
|
|
87
|
+
const rest = children.slice(lastCarrier + 1);
|
|
88
|
+
if (!rest.some((child) => child.isNamed)) return newBody;
|
|
89
|
+
return rewrittenNode(
|
|
90
|
+
statement,
|
|
91
|
+
[...children.slice(0, bodyIndex), newBody, ...rest],
|
|
92
|
+
statement.startIndex,
|
|
93
|
+
statement.endIndex,
|
|
94
|
+
source,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether the words after a redirect on `body` belong to a command: `body` is
|
|
100
|
+
* one, or is a `list` or `pipeline` whose last element reaches one. The grammar
|
|
101
|
+
* hangs a redirect on the last command of `cd a && git 2>/dev/null push` or
|
|
102
|
+
* `rg x | xargs ls 2>&1 ~/x` off the whole list or pipeline, while bash gives
|
|
103
|
+
* it, and its words, to that last command.
|
|
104
|
+
*/
|
|
105
|
+
function reachesCommand(body: TSNode): boolean {
|
|
106
|
+
if (body.type === "command") return true;
|
|
107
|
+
if (!GROUPING_TYPES.has(body.type)) return false;
|
|
108
|
+
const last = lastNamedChild(body);
|
|
109
|
+
return last !== undefined && reachesCommand(last);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The bodies whose last element a statement-level redirect belongs to. */
|
|
113
|
+
const GROUPING_TYPES: ReadonlySet<string> = new Set(["list", "pipeline"]);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `redirect` as it belongs in the command: truncated after its own target and
|
|
117
|
+
* followed by the words the grammar appended to it. A redirect carrying no
|
|
118
|
+
* words, or a node that is not a file redirect, moves as it is.
|
|
119
|
+
*/
|
|
120
|
+
function splitRedirect(redirect: TSNode, source: Source): TSNode[] {
|
|
121
|
+
const trailing =
|
|
122
|
+
redirect.type === "file_redirect"
|
|
123
|
+
? trailingArgumentIndex(redirect)
|
|
124
|
+
: undefined;
|
|
125
|
+
if (trailing === undefined) return [redirect];
|
|
126
|
+
|
|
127
|
+
const kept: TSNode[] = [];
|
|
128
|
+
const words: TSNode[] = [];
|
|
129
|
+
for (let i = 0; i < redirect.childCount; i++) {
|
|
130
|
+
const child = redirect.child(i);
|
|
131
|
+
if (child) (i < trailing ? kept : words).push(child);
|
|
132
|
+
}
|
|
133
|
+
const end = kept.at(-1)?.endIndex ?? redirect.startIndex;
|
|
134
|
+
return [
|
|
135
|
+
rewrittenNode(redirect, kept, redirect.startIndex, end, source),
|
|
136
|
+
...words,
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* `body` with `moved` appended to its rightmost command: the command itself,
|
|
142
|
+
* or the last element of a `list` or `pipeline`, recursively. Each node on the way grows to
|
|
143
|
+
* the end of the last moved node.
|
|
144
|
+
*/
|
|
145
|
+
function appendToRightmostCommand(
|
|
146
|
+
body: TSNode,
|
|
147
|
+
moved: readonly TSNode[],
|
|
148
|
+
source: Source,
|
|
149
|
+
): TSNode {
|
|
150
|
+
const children = childrenOf(body);
|
|
151
|
+
const end = moved.at(-1)?.endIndex ?? body.endIndex;
|
|
152
|
+
if (body.type === "command") {
|
|
153
|
+
return rewrittenNode(
|
|
154
|
+
body,
|
|
155
|
+
[...children, ...moved],
|
|
156
|
+
body.startIndex,
|
|
157
|
+
end,
|
|
158
|
+
source,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const lastIndex = children.findLastIndex((child) => child.isNamed);
|
|
162
|
+
const last = children[lastIndex];
|
|
163
|
+
const replaced = children.with(
|
|
164
|
+
lastIndex,
|
|
165
|
+
appendToRightmostCommand(last, moved, source),
|
|
166
|
+
);
|
|
167
|
+
return rewrittenNode(body, replaced, body.startIndex, end, source);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A node the correction built, reading its text from the statement's source. */
|
|
171
|
+
function rewrittenNode(
|
|
172
|
+
original: TSNode,
|
|
173
|
+
children: readonly TSNode[],
|
|
174
|
+
startIndex: number,
|
|
175
|
+
endIndex: number,
|
|
176
|
+
source: Source,
|
|
177
|
+
): TSNode {
|
|
178
|
+
return adoptingView(
|
|
179
|
+
{
|
|
180
|
+
type: original.type,
|
|
181
|
+
isNamed: original.isNamed,
|
|
182
|
+
startIndex,
|
|
183
|
+
endIndex,
|
|
184
|
+
text: source(startIndex, endIndex),
|
|
185
|
+
},
|
|
186
|
+
children,
|
|
187
|
+
false,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A view whose children are views too, so each one's `previousSibling` is its
|
|
193
|
+
* neighbor in the corrected tree rather than the one the grammar gave it.
|
|
194
|
+
*/
|
|
195
|
+
function adoptingView(
|
|
196
|
+
fields: NodeFields,
|
|
197
|
+
children: readonly TSNode[],
|
|
198
|
+
hasError: boolean,
|
|
199
|
+
): TSNode {
|
|
200
|
+
return new NodeView(fields, children.map(asView), hasError);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** A slice of the command by absolute offsets. */
|
|
204
|
+
type Source = (startIndex: number, endIndex: number) => string;
|
|
205
|
+
|
|
206
|
+
/** The source slicer for every node within `statement`. */
|
|
207
|
+
function sourceOf(statement: TSNode): Source {
|
|
208
|
+
return (startIndex, endIndex) =>
|
|
209
|
+
statement.text.slice(
|
|
210
|
+
startIndex - statement.startIndex,
|
|
211
|
+
endIndex - statement.startIndex,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function childrenOf(node: TSNode): TSNode[] {
|
|
216
|
+
const children: TSNode[] = [];
|
|
217
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
218
|
+
const child = node.child(i);
|
|
219
|
+
if (child) children.push(child);
|
|
220
|
+
}
|
|
221
|
+
return children;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function lastNamedChild(node: TSNode): TSNode | undefined {
|
|
225
|
+
return childrenOf(node).findLast((child) => child.isNamed);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* `node` as a view, so a new parent can give it a new previous sibling.
|
|
230
|
+
*
|
|
231
|
+
* Its own children stay the grammar's nodes: nothing beneath it moved, so their
|
|
232
|
+
* siblings are still the grammar's too.
|
|
233
|
+
*/
|
|
234
|
+
function asView(node: TSNode): TSNode {
|
|
235
|
+
return node instanceof NodeView
|
|
236
|
+
? node
|
|
237
|
+
: new NodeView(node, childrenOf(node), parseUnresolvedWithin(node));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The fields a {@link NodeView} copies from the node it stands for. */
|
|
241
|
+
interface NodeFields {
|
|
242
|
+
readonly type: string;
|
|
243
|
+
readonly isNamed: boolean;
|
|
244
|
+
readonly startIndex: number;
|
|
245
|
+
readonly endIndex: number;
|
|
246
|
+
readonly text: string;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* A parse-tree node the correction presents in place of the grammar's.
|
|
251
|
+
*
|
|
252
|
+
* Adopting its children sets each view child's `previousSibling` to the child
|
|
253
|
+
* before it, which is what lets a redirect moved into a command ask
|
|
254
|
+
* `parseUnresolvedAt` about its new neighbor.
|
|
255
|
+
*/
|
|
256
|
+
class NodeView implements TSNode {
|
|
257
|
+
readonly type: string;
|
|
258
|
+
readonly isNamed: boolean;
|
|
259
|
+
readonly startIndex: number;
|
|
260
|
+
readonly endIndex: number;
|
|
261
|
+
readonly text: string;
|
|
262
|
+
readonly childCount: number;
|
|
263
|
+
previousSibling: TSNode | null = null;
|
|
264
|
+
|
|
265
|
+
constructor(
|
|
266
|
+
fields: NodeFields,
|
|
267
|
+
private readonly children: readonly TSNode[],
|
|
268
|
+
readonly hasError: boolean,
|
|
269
|
+
) {
|
|
270
|
+
this.type = fields.type;
|
|
271
|
+
this.isNamed = fields.isNamed;
|
|
272
|
+
this.startIndex = fields.startIndex;
|
|
273
|
+
this.endIndex = fields.endIndex;
|
|
274
|
+
this.text = fields.text;
|
|
275
|
+
this.childCount = children.length;
|
|
276
|
+
children.forEach((child, i) => {
|
|
277
|
+
if (child instanceof NodeView)
|
|
278
|
+
child.previousSibling = children[i - 1] ?? null;
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
child(index: number): TSNode | null {
|
|
283
|
+
return this.children[index] ?? null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -4,14 +4,31 @@ import { proveCommandEffect } from "./command-effects";
|
|
|
4
4
|
import { EXECUTION_HOST_TYPES, forEachExecutionIn } from "./nested-execution";
|
|
5
5
|
import {
|
|
6
6
|
ARG_NODE_TYPES,
|
|
7
|
+
hasComputedPart,
|
|
7
8
|
resolveNodeText,
|
|
8
9
|
SKIP_SUBTREE_TYPES,
|
|
9
10
|
} from "./node-text";
|
|
10
11
|
import type { TSNode } from "./parser";
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
redirectEffectForDestination,
|
|
14
|
+
redirectTargetIndex,
|
|
15
|
+
} from "./redirect-analysis";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What a collected token is, as the collector that produced it established.
|
|
19
|
+
*
|
|
20
|
+
* The role decides candidacy and the effect decides direction; both are
|
|
21
|
+
* stamped at the same site and never re-derived downstream.
|
|
22
|
+
*/
|
|
23
|
+
export type TokenRole =
|
|
24
|
+
/** An operand of unknown path-hood: shape and the existence probe decide. */
|
|
25
|
+
| "operand"
|
|
26
|
+
/** A redirect's own target, which the syntax proves names a file. */
|
|
27
|
+
| "redirect-destination";
|
|
12
28
|
|
|
13
29
|
/**
|
|
14
|
-
* A collected path-candidate token paired with the effect its position proved
|
|
30
|
+
* A collected path-candidate token paired with the effect its position proved
|
|
31
|
+
* and the role its collector gave it.
|
|
15
32
|
*
|
|
16
33
|
* The pairing is made where the token is *produced*, never by mapping a whole
|
|
17
34
|
* result: a nested execution's tokens carry their own command's attribution
|
|
@@ -20,6 +37,7 @@ import { redirectEffectForDestination } from "./redirect-analysis";
|
|
|
20
37
|
export interface PathToken {
|
|
21
38
|
readonly token: string;
|
|
22
39
|
readonly effect: TokenEffect;
|
|
40
|
+
readonly role: TokenRole;
|
|
23
41
|
}
|
|
24
42
|
|
|
25
43
|
// ── Public surface ─────────────────────────────────────────────────────────
|
|
@@ -108,23 +126,57 @@ export function collectCommandTokens(node: TSNode): PathToken[] {
|
|
|
108
126
|
* in front of it is. A destination the operator names as a file descriptor
|
|
109
127
|
* (`2>&1`) contributes no token at all.
|
|
110
128
|
*
|
|
129
|
+
* The redirect's own target carries the `redirect-destination` role when the
|
|
130
|
+
* syntax proves it names a file, so the projection admits it whether or not
|
|
131
|
+
* the file exists yet (#609). Every other child is an `operand`. A word the
|
|
132
|
+
* grammar appends after the target reaches here only in a statement whose parse
|
|
133
|
+
* failed: everywhere else `getParser` has already handed it back to the command
|
|
134
|
+
* it belongs to (#977).
|
|
135
|
+
*
|
|
111
136
|
* Reading the redirect node itself belongs to `redirect-analysis.ts`, which
|
|
112
137
|
* the command enumerator consults for the same fact (#803).
|
|
113
138
|
*/
|
|
114
139
|
export function collectRedirectTokens(node: TSNode): PathToken[] {
|
|
140
|
+
const target = redirectTargetIndex(node);
|
|
115
141
|
const tokens: PathToken[] = [];
|
|
116
142
|
for (let i = 0; i < node.childCount; i++) {
|
|
117
143
|
const child = node.child(i);
|
|
118
144
|
if (!child) continue;
|
|
119
145
|
if (ARG_NODE_TYPES.has(child.type)) {
|
|
120
146
|
const effect = redirectEffectForDestination(node, child);
|
|
121
|
-
if (effect)
|
|
147
|
+
if (effect) {
|
|
148
|
+
const token = resolveNodeText(child);
|
|
149
|
+
const role: TokenRole =
|
|
150
|
+
i === target && provesTarget(effect, child, token)
|
|
151
|
+
? "redirect-destination"
|
|
152
|
+
: "operand";
|
|
153
|
+
tokens.push({ token, effect, role });
|
|
154
|
+
}
|
|
122
155
|
}
|
|
123
156
|
tokens.push(...collectHostedExecutionTokens(child));
|
|
124
157
|
}
|
|
125
158
|
return tokens;
|
|
126
159
|
}
|
|
127
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Whether a redirect's target is proven to name this literal file.
|
|
163
|
+
*
|
|
164
|
+
* The operator must have proved an effect: a redirect the parse could not
|
|
165
|
+
* resolve proves nothing (#814). The value must be literal: a computed one
|
|
166
|
+
* names a file only running the command decides (ADR 0009's computed-path
|
|
167
|
+
* residual). And it must be non-empty, since bash refuses `> ""` rather than
|
|
168
|
+
* writing anything.
|
|
169
|
+
*/
|
|
170
|
+
function provesTarget(
|
|
171
|
+
effect: TokenEffect,
|
|
172
|
+
destination: TSNode,
|
|
173
|
+
token: string,
|
|
174
|
+
): boolean {
|
|
175
|
+
return (
|
|
176
|
+
effect.source === "syntax" && !hasComputedPart(destination) && token !== ""
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
128
180
|
/**
|
|
129
181
|
* Collect the path-candidate tokens of every command nested inside `node`'s
|
|
130
182
|
* execution contexts, reading none of the host subtree's own text.
|
|
@@ -204,7 +256,11 @@ function collectStatementOperandTokens(
|
|
|
204
256
|
tokens.push(...collectPathCandidateTokens(child));
|
|
205
257
|
continue;
|
|
206
258
|
}
|
|
207
|
-
tokens.push({
|
|
259
|
+
tokens.push({
|
|
260
|
+
token: resolveNodeText(child),
|
|
261
|
+
effect: UNPROVEN_EFFECT,
|
|
262
|
+
role: "operand",
|
|
263
|
+
});
|
|
208
264
|
tokens.push(...collectHostedExecutionTokens(child));
|
|
209
265
|
}
|
|
210
266
|
return tokens;
|
|
@@ -260,8 +316,7 @@ function commandArgumentWords(node: TSNode): string[] {
|
|
|
260
316
|
for (let i = 0; i < node.childCount; i++) {
|
|
261
317
|
const child = node.child(i);
|
|
262
318
|
if (!child) continue;
|
|
263
|
-
if (
|
|
264
|
-
continue;
|
|
319
|
+
if (COMMAND_PREFIX_TYPES.has(child.type)) continue;
|
|
265
320
|
if (!ARG_NODE_TYPES.has(child.type)) continue;
|
|
266
321
|
words.push(resolveNodeText(child));
|
|
267
322
|
}
|
|
@@ -280,7 +335,7 @@ function commandArgumentWords(node: TSNode): string[] {
|
|
|
280
335
|
* different state machines and so each carry their own skip, which is why the
|
|
281
336
|
* question is named here once rather than spelled twice (#742).
|
|
282
337
|
*/
|
|
283
|
-
const COMMAND_PREFIX_TYPES: ReadonlySet<string> = new Set([
|
|
338
|
+
export const COMMAND_PREFIX_TYPES: ReadonlySet<string> = new Set([
|
|
284
339
|
"command_name",
|
|
285
340
|
"variable_assignment",
|
|
286
341
|
]);
|
|
@@ -318,12 +373,13 @@ function collectEmbeddedOptionValues(
|
|
|
318
373
|
for (let i = 0; i < node.childCount; i++) {
|
|
319
374
|
const child = node.child(i);
|
|
320
375
|
if (!child) continue;
|
|
321
|
-
if (
|
|
322
|
-
continue;
|
|
376
|
+
if (COMMAND_PREFIX_TYPES.has(child.type)) continue;
|
|
323
377
|
if (!ARG_NODE_TYPES.has(child.type)) continue;
|
|
324
378
|
|
|
325
379
|
const value = OPTION_VALUE_PATTERN.exec(resolveNodeText(child))?.[1];
|
|
326
|
-
if (value !== undefined)
|
|
380
|
+
if (value !== undefined) {
|
|
381
|
+
values.push({ token: value, effect, role: "operand" });
|
|
382
|
+
}
|
|
327
383
|
}
|
|
328
384
|
return values;
|
|
329
385
|
}
|
|
@@ -334,7 +390,7 @@ function embeddedOptionValueToken(
|
|
|
334
390
|
effect: TokenEffect,
|
|
335
391
|
): PathToken[] {
|
|
336
392
|
const value = OPTION_VALUE_PATTERN.exec(text)?.[1];
|
|
337
|
-
return value === undefined ? [] : [{ token: value, effect }];
|
|
393
|
+
return value === undefined ? [] : [{ token: value, effect, role: "operand" }];
|
|
338
394
|
}
|
|
339
395
|
|
|
340
396
|
/**
|
|
@@ -802,7 +858,11 @@ function collectPatternCommandTokens(
|
|
|
802
858
|
break;
|
|
803
859
|
case "inline-value":
|
|
804
860
|
if (directive.role === "script-file")
|
|
805
|
-
tokens.push({
|
|
861
|
+
tokens.push({
|
|
862
|
+
token: directive.value,
|
|
863
|
+
effect,
|
|
864
|
+
role: "operand",
|
|
865
|
+
});
|
|
806
866
|
if (suppliesScript(directive.role)) hasExplicitScript = true;
|
|
807
867
|
break;
|
|
808
868
|
case "regular-flag":
|
|
@@ -819,7 +879,7 @@ function collectPatternCommandTokens(
|
|
|
819
879
|
if (!hasExplicitScript && positionalsSeen < patternPositionals) {
|
|
820
880
|
positionalsSeen++; // Skip: this is an inline pattern/script.
|
|
821
881
|
} else {
|
|
822
|
-
tokens.push({ token: text, effect });
|
|
882
|
+
tokens.push({ token: text, effect, role: "operand" });
|
|
823
883
|
}
|
|
824
884
|
// A quoted token that did not act as a flag above has its embedded value
|
|
825
885
|
// split here instead.
|
|
@@ -874,7 +934,10 @@ function dischargePendingConsumption(
|
|
|
874
934
|
): ConsumptionDischarge {
|
|
875
935
|
switch (role) {
|
|
876
936
|
case "script-file":
|
|
877
|
-
return {
|
|
937
|
+
return {
|
|
938
|
+
consumed: true,
|
|
939
|
+
token: { token: text, effect, role: "operand" },
|
|
940
|
+
};
|
|
878
941
|
case "script":
|
|
879
942
|
case "value":
|
|
880
943
|
return { consumed: true };
|
|
@@ -923,7 +986,7 @@ function collectGenericCommandTokens(
|
|
|
923
986
|
|
|
924
987
|
// Argument nodes: resolve their text and collect.
|
|
925
988
|
if (ARG_NODE_TYPES.has(child.type)) {
|
|
926
|
-
tokens.push({ token: resolveNodeText(child), effect });
|
|
989
|
+
tokens.push({ token: resolveNodeText(child), effect, role: "operand" });
|
|
927
990
|
continue;
|
|
928
991
|
}
|
|
929
992
|
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
parseUnresolvedWithin,
|
|
4
|
-
type TSNode,
|
|
5
|
-
} from "./parser";
|
|
1
|
+
import { parseUnresolvedWithin } from "./parse-health";
|
|
2
|
+
import type { BashReparser, TSNode } from "./parser";
|
|
6
3
|
|
|
7
4
|
/**
|
|
8
5
|
* Run `use` over the roots of every region the primary parse could not resolve
|