@gotgenes/pi-permission-system 34.0.0 → 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 CHANGED
@@ -5,6 +5,20 @@ 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
+
8
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)
9
23
 
10
24
 
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "34.0.0",
3
+ "version": "34.0.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -9,12 +9,14 @@ 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,
@@ -739,8 +741,9 @@ function cdLiteralTarget(commandNode: TSNode): string | null {
739
741
  for (let i = 0; i < commandNode.childCount; i++) {
740
742
  const child = commandNode.child(i);
741
743
  if (!child) continue;
742
- if (child.type === "command_name" || child.type === "variable_assignment")
743
- continue;
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;
744
747
  if (!child.isNamed) continue;
745
748
  // Skip the `--` end-of-flags marker; the next argument is the target.
746
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, type TSNode } from "./parser";
4
- import { redirectMayWriteFile } from "./redirect-analysis";
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 = commandUnitText(node);
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 `redirected_statement`'s children run under: the enclosing one,
432
- * plus a write unless every one of its redirects provably only reads.
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
- * The redirect belongs to the last element of a pipeline, but it hangs off the
435
- * whole statement in the parse tree, so every command beneath it is marked.
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 words — its `command_name` followed by its arguments — each
454
- * carrying its offset into the unit text `commandUnitText` produces.
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
- * A leading `variable_assignment` prefix is skipped (matching
457
- * `commandUnitText`), so offsets are relative to the `command_name`. An empty
458
- * list means a pure assignment with no `command_name`.
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 readCommandWords(node: TSNode): CommandWord[] {
480
+ function readCommandUnit(node: TSNode): {
481
+ text: string;
482
+ words: CommandWord[];
483
+ } {
461
484
  const nodes = commandWordNodes(node);
462
- const unitStart = nodes.at(0)?.startIndex ?? 0;
463
- return nodes.map((child) => ({
464
- text: child.text,
465
- offset: child.startIndex - unitStart,
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 readCommandWords} reports words for, in the same order.
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 — the log's
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
- * The command-pattern text of a `command` node, with any leading
491
- * `variable_assignment` prefix stripped.
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?.isNamed && child.type !== "variable_assignment") {
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 node.text;
551
+ return redirects;
508
552
  }
509
553
 
510
554
  function descendCommandChildren(
@@ -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 this
10
- * module's two `parseUnresolved*` predicates — see their doc comments for why
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
- export const getParser = memoizeAsyncWithRetry(initParser);
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,6 +1,7 @@
1
1
  import { type TokenEffect, UNPROVEN_EFFECT } from "#src/access-intent/effect";
2
2
  import { redirectDestinationEffect } from "./command-effects";
3
- import { parseUnresolvedAt, type TSNode } from "./parser";
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.
@@ -25,6 +26,19 @@ import { parseUnresolvedAt, type TSNode } from "./parser";
25
26
  * (#814).
26
27
  */
27
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
+
28
42
  /**
29
43
  * The effect `redirect` proves for `destination`, or `null` when the redirect
30
44
  * names no file and no token should be collected.
@@ -95,7 +109,8 @@ export function redirectMayWriteFile(redirect: TSNode): boolean {
95
109
 
96
110
  /**
97
111
  * The child index of the node `redirect` reads or writes (its first named
98
- * child after the operator), or `undefined` when it names none (`>&-`).
112
+ * child after the operator), or `undefined` when it names none: nothing
113
+ * follows the operator, or the operator closes a descriptor (`>&-`, `<&-`).
99
114
  *
100
115
  * The operator is the redirect's only unnamed child, and a source descriptor
101
116
  * (`2` in `2>`) precedes it, so the first named child after it is the
@@ -104,19 +119,52 @@ export function redirectMayWriteFile(redirect: TSNode): boolean {
104
119
  * Only the first: tree-sitter-bash 0.25.1 declares the destination
105
120
  * `repeat1`, so the words after it in `grep pat 2>/dev/null f.txt` parse as
106
121
  * further destinations, while bash passes them to the redirected command as
107
- * arguments (#977). An index rather than a node, because a caller iterating
108
- * the children compares positions rather than wrapper identity.
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.
109
127
  */
110
128
  export function redirectTargetIndex(redirect: TSNode): number | undefined {
111
- let seenOperator = false;
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 {
112
158
  for (let i = 0; i < redirect.childCount; i++) {
113
- const child = redirect.child(i);
114
- if (!child) continue;
115
- if (!child.isNamed) {
116
- seenOperator = true;
117
- continue;
118
- }
119
- if (seenOperator) return 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;
120
168
  }
121
169
  return undefined;
122
170
  }
@@ -141,9 +189,6 @@ const DESCRIPTOR_NODE_TYPES: ReadonlySet<string> = new Set([
141
189
  * syntax proof is a lookup on the first one found.
142
190
  */
143
191
  function redirectOperatorOf(node: TSNode): string {
144
- for (let i = 0; i < node.childCount; i++) {
145
- const child = node.child(i);
146
- if (child && !child.isNamed) return child.type;
147
- }
148
- return "";
192
+ const operator = redirectOperatorIndex(node);
193
+ return operator === undefined ? "" : (node.child(operator)?.type ?? "");
149
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
+ }
@@ -128,8 +128,10 @@ export function collectCommandTokens(node: TSNode): PathToken[] {
128
128
  *
129
129
  * The redirect's own target carries the `redirect-destination` role when the
130
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 belongs to the redirected command (#977).
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).
133
135
  *
134
136
  * Reading the redirect node itself belongs to `redirect-analysis.ts`, which
135
137
  * the command enumerator consults for the same fact (#803).
@@ -314,8 +316,7 @@ function commandArgumentWords(node: TSNode): string[] {
314
316
  for (let i = 0; i < node.childCount; i++) {
315
317
  const child = node.child(i);
316
318
  if (!child) continue;
317
- if (child.type === "command_name" || child.type === "variable_assignment")
318
- continue;
319
+ if (COMMAND_PREFIX_TYPES.has(child.type)) continue;
319
320
  if (!ARG_NODE_TYPES.has(child.type)) continue;
320
321
  words.push(resolveNodeText(child));
321
322
  }
@@ -334,7 +335,7 @@ function commandArgumentWords(node: TSNode): string[] {
334
335
  * different state machines and so each carry their own skip, which is why the
335
336
  * question is named here once rather than spelled twice (#742).
336
337
  */
337
- const COMMAND_PREFIX_TYPES: ReadonlySet<string> = new Set([
338
+ export const COMMAND_PREFIX_TYPES: ReadonlySet<string> = new Set([
338
339
  "command_name",
339
340
  "variable_assignment",
340
341
  ]);
@@ -372,8 +373,7 @@ function collectEmbeddedOptionValues(
372
373
  for (let i = 0; i < node.childCount; i++) {
373
374
  const child = node.child(i);
374
375
  if (!child) continue;
375
- if (child.type === "command_name" || child.type === "variable_assignment")
376
- continue;
376
+ if (COMMAND_PREFIX_TYPES.has(child.type)) continue;
377
377
  if (!ARG_NODE_TYPES.has(child.type)) continue;
378
378
 
379
379
  const value = OPTION_VALUE_PATTERN.exec(resolveNodeText(child))?.[1];
@@ -1,8 +1,5 @@
1
- import {
2
- type BashReparser,
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