@gotgenes/pi-permission-system 27.1.1 → 27.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ 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
+ ## [27.1.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v27.1.2...pi-permission-system-v27.1.3) (2026-08-29)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** gate commands hosted by declarations, test commands, and assignments ([dfde822](https://github.com/gotgenes/pi-packages/commit/dfde8227d3bf11de5258dcbe376dd0b8b75045e0)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
14
+ * **pi-permission-system:** gate commands inside a for loop's body and word list ([6e96464](https://github.com/gotgenes/pi-packages/commit/6e9646486db849afdf80819150a203d293b3847c)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
15
+ * **pi-permission-system:** gate commands inside control-flow bodies and function definitions ([3a2f232](https://github.com/gotgenes/pi-packages/commit/3a2f232d9c12588ea1feacf41cade0e56e66a44b)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
16
+ * **pi-permission-system:** project a command-name substitution's path operands ([9807d44](https://github.com/gotgenes/pi-packages/commit/9807d444b6d81526b01bc542cd4a829a1b2640c3)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
17
+
18
+
19
+ ### Documentation
20
+
21
+ * **pi-permission-system:** bound the path-slice claim on behavior, not diff footprint ([91ef5c9](https://github.com/gotgenes/pi-packages/commit/91ef5c9f2d0578fb64fe317bcddd3ec7d9418d4c)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
22
+ * **pi-permission-system:** commit the instrument behind Step 4's measurement ([e634c2c](https://github.com/gotgenes/pi-packages/commit/e634c2cf068340cc0deae86648330e44c2741f06)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
23
+
24
+ ## [27.1.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v27.1.1...pi-permission-system-v27.1.2) (2026-08-29)
25
+
26
+
27
+ ### Bug Fixes
28
+
29
+ * **pi-permission-system:** claim no arity for awk's long forms, whose parser the bare name does not fix ([#823](https://github.com/gotgenes/pi-packages/issues/823)) ([6c8e4a0](https://github.com/gotgenes/pi-packages/commit/6c8e4a0b813d17750296cbd065aec20377c82d2a))
30
+ * **pi-permission-system:** discharge a flag argument on whatever node type follows ([#823](https://github.com/gotgenes/pi-packages/issues/823)) ([228f6fb](https://github.com/gotgenes/pi-packages/commit/228f6fb235310b304557ec501567373e33d4cfab))
31
+ * **pi-permission-system:** read --context per tool, whose arity differs between grep and rg ([#823](https://github.com/gotgenes/pi-packages/issues/823)) ([bba8317](https://github.com/gotgenes/pi-packages/commit/bba83175d1f9f8079cd9613412c65868e4a2fa09))
32
+ * **pi-permission-system:** read awk's long forms only for gawk ([#823](https://github.com/gotgenes/pi-packages/issues/823)) ([52488f0](https://github.com/gotgenes/pi-packages/commit/52488f02cc902b8669f893cc1cfee090482f2e72))
33
+ * **pi-permission-system:** recognize the long, embedded, and glued spellings of a pattern-first flag ([#823](https://github.com/gotgenes/pi-packages/issues/823)) ([ce7b65e](https://github.com/gotgenes/pi-packages/commit/ce7b65eb607cc2e6f61a6eceda1d1a9bb22aea85))
34
+ * **pi-permission-system:** spend a pattern positional on a computed or numeric pattern ([#823](https://github.com/gotgenes/pi-packages/issues/823)) ([dce4d3f](https://github.com/gotgenes/pi-packages/commit/dce4d3f06b164f6f45df3c5550ec624f3acba896))
35
+
36
+
37
+ ### Documentation
38
+
39
+ * **pi-permission-system:** record the optional-argument flag residuals ([#823](https://github.com/gotgenes/pi-packages/issues/823)) ([94e51fc](https://github.com/gotgenes/pi-packages/commit/94e51fc5a05acf3b2c4bc49479b45b0b7797b9ac))
40
+
8
41
  ## [27.1.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v27.1.0...pi-permission-system-v27.1.1) (2026-08-28)
9
42
 
10
43
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "27.1.1",
3
+ "version": "27.1.3",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -90,6 +90,7 @@
90
90
  "test:watch": "vitest",
91
91
  "verify:public-types": "bash scripts/verify-public-types.sh",
92
92
  "measure:core-coverage": "node scripts/measure-core-coverage.mjs",
93
+ "measure:statement-descent": "node scripts/measure-statement-descent.mjs",
93
94
  "measure:wrapper-transparency": "node scripts/measure-wrapper-transparency.mjs",
94
95
  "lint:md": "rumdl check *.md docs/**/*.md",
95
96
  "lint": "biome check . && eslint . && pnpm run lint:md"
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  EXECUTION_HOST_TYPES,
3
- forEachNestedExecution,
3
+ forEachExecutionIn,
4
4
  } from "#src/access-intent/bash/nested-execution";
5
5
  import type { TSNode } from "#src/access-intent/bash/parser";
6
6
  import { redirectMayWriteFile } from "#src/access-intent/bash/redirect-analysis";
@@ -79,7 +79,7 @@ interface UnitScope {
79
79
  /** A top-level command in the current shell, writing no file. */
80
80
  const TOP_LEVEL_SCOPE: UnitScope = { writesViaRedirect: false };
81
81
 
82
- // ── Command enumeration ──────────────────────────────────────────────────────
82
+ // ── Node-type vocabulary ─────────────────────────────────────────────────────
83
83
 
84
84
  /**
85
85
  * Container node types descended into with the enclosing scope unchanged.
@@ -89,6 +89,39 @@ const TOP_LEVEL_SCOPE: UnitScope = { writesViaRedirect: false };
89
89
  */
90
90
  const COMMAND_ENUM_DESCEND = new Set(["program", "list", "pipeline"]);
91
91
 
92
+ /**
93
+ * Compound statements: emitted whole, then descended for their statements.
94
+ *
95
+ * The whole emit is what keeps the #306 never-weaker invariant — the commands
96
+ * found inside are additional units, never a replacement.
97
+ *
98
+ * `select` parses as `for_statement` and `until` as `while_statement`, so each
99
+ * pair is one entry.
100
+ */
101
+ const COMPOUND_STATEMENT_TYPES = new Set([
102
+ "if_statement",
103
+ "while_statement",
104
+ "for_statement",
105
+ "c_style_for_statement",
106
+ "case_statement",
107
+ "function_definition",
108
+ "compound_statement",
109
+ "negated_command",
110
+ ]);
111
+
112
+ /**
113
+ * Syntactic groupings inside a compound statement: descended, never emitted.
114
+ *
115
+ * None of these is something anybody runs — a `do_group` is the loop body's
116
+ * punctuation — so emitting one would produce a `do rm $f; done` unit.
117
+ */
118
+ const STATEMENT_GROUP_TYPES = new Set([
119
+ "do_group",
120
+ "case_item",
121
+ "elif_clause",
122
+ "else_clause",
123
+ ]);
124
+
92
125
  /**
93
126
  * Named node types abandoned during command enumeration: they are neither
94
127
  * commands nor able to host one, so nothing in their subtree ever runs.
@@ -104,6 +137,33 @@ const COMMAND_ENUM_DESCEND = new Set(["program", "list", "pipeline"]);
104
137
  */
105
138
  const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
106
139
 
140
+ /**
141
+ * Every node type the enumerator recognizes as a statement.
142
+ *
143
+ * This is the enumerator's third question, beside "is this a command?" and
144
+ * "can this host one?": "is this a *statement*, so that descending an enclosing
145
+ * compound reaches it?" A compound statement's named children are a mix —
146
+ * `for_statement` carries its loop variable and word list, `case_statement` its
147
+ * subject, `function_definition` its name — and descending all of them emits
148
+ * operand words as bash command units, naming `a` as the offending *command* in
149
+ * a prompt. Membership is what {@link descendStatementChildren} filters on.
150
+ */
151
+ const STATEMENT_TYPES = new Set([
152
+ "command",
153
+ "redirected_statement",
154
+ "subshell",
155
+ "declaration_command",
156
+ "variable_assignment",
157
+ "test_command",
158
+ "unset_command",
159
+ "ERROR",
160
+ ...COMMAND_ENUM_DESCEND,
161
+ ...COMPOUND_STATEMENT_TYPES,
162
+ ...STATEMENT_GROUP_TYPES,
163
+ ]);
164
+
165
+ // ── Command enumeration ──────────────────────────────────────────────
166
+
107
167
  /**
108
168
  * Enumerate the command units of a bash program, in source order.
109
169
  *
@@ -114,10 +174,14 @@ const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
114
174
  * subshells (`( … )`) — emitting each inner command as its own unit *in
115
175
  * addition to* the enclosing command, since those inner commands really execute
116
176
  * (#306).
117
- * Control-flow bodies and `{ … }` brace groups are emitted whole without
118
- * descending (deferred).
177
+ * A compound statement (control flow, a function definition, a `{ … }` brace
178
+ * group) is emitted whole and then descended for the statements it contains,
179
+ * while its operand words — a loop variable, a word list, a `case` subject, a
180
+ * function's own name — are not commands and are left unemitted. An `ERROR`
181
+ * node is the one exception: its recovered structure is invented rather than
182
+ * observed, so the unparsed blob is emitted whole and never descended (#742).
119
183
  *
120
- * The enclosing command/subshell is always still emitted whole, so adding the
184
+ * The enclosing command/statement is always still emitted whole, so adding the
121
185
  * nested units can only ever produce a more-restrictive decision, never weaker.
122
186
  *
123
187
  * Each emitted command unit has any leading `variable_assignment` prefix
@@ -172,9 +236,33 @@ function collectCommandsInto(
172
236
  return;
173
237
  }
174
238
 
239
+ if (COMPOUND_STATEMENT_TYPES.has(node.type)) {
240
+ out.push(makeUnit(node.text, scope)); // never-weaker whole emit
241
+ descendStatementChildren(node, scope, out);
242
+ return;
243
+ }
244
+
245
+ if (STATEMENT_GROUP_TYPES.has(node.type)) {
246
+ descendStatementChildren(node, scope, out);
247
+ return;
248
+ }
249
+
250
+ if (node.type === "ERROR") {
251
+ // Tree-sitter's error recovery *invents* structure, so the node types
252
+ // inside an ERROR subtree are not evidence that anything runs: descending
253
+ // one turns backtick-quoted prose in an unterminated heredoc into command
254
+ // units. Emit the unparsed blob whole and stop (#742).
255
+ out.push(makeUnit(node.text, scope));
256
+ return;
257
+ }
258
+
175
259
  // Any other named statement (compound_statement `{ … }`, if/while/for/case,
176
260
  // function_definition): emit whole, do not descend — deferred (#306).
261
+ // A declaration, assignment, test, or `unset` still hosts executions that
262
+ // really run (`local x=$(rm y)`, `[[ $(rm x) ]]`), so those are enumerated
263
+ // in addition to the statement (#742).
177
264
  out.push(makeUnit(node.text, scope));
265
+ collectHostedCommands(node, out);
178
266
  }
179
267
 
180
268
  /** The wrapper facts a `command` node's words establish about its unit. */
@@ -291,6 +379,35 @@ function descendCommandChildren(
291
379
  }
292
380
  }
293
381
 
382
+ /**
383
+ * Descend a compound statement's children, enumerating only the ones that are
384
+ * themselves statements.
385
+ *
386
+ * The filter is the whole difference from {@link descendCommandChildren}, whose
387
+ * container types (`program` / `list` / `pipeline` / `redirected_statement` /
388
+ * `subshell`) have nothing but statement children. Here the children are a mix,
389
+ * and a non-statement one is an operand word rather than something that runs.
390
+ *
391
+ * A non-statement child is not abandoned, though: `for f in $(rm x)` hosts a
392
+ * real execution in its word list, which is what the second branch reaches.
393
+ *
394
+ * The scope is relayed unchanged — a compound statement's body runs in the
395
+ * current shell, so a write established by an enclosing `redirected_statement`
396
+ * covers every unit beneath it (#803).
397
+ */
398
+ function descendStatementChildren(
399
+ node: TSNode,
400
+ scope: UnitScope,
401
+ out: BashCommand[],
402
+ ): void {
403
+ for (let i = 0; i < node.childCount; i++) {
404
+ const child = node.child(i);
405
+ if (!child?.isNamed) continue;
406
+ if (STATEMENT_TYPES.has(child.type)) collectCommandsInto(child, scope, out);
407
+ else collectHostedCommands(child, out);
408
+ }
409
+ }
410
+
294
411
  /**
295
412
  * Enumerate the commands of every nested execution context in a subtree, each
296
413
  * tagged with the context it was found in.
@@ -298,9 +415,12 @@ function descendCommandChildren(
298
415
  * The traversal itself lives in `nested-execution.ts` so the bash path surface
299
416
  * shares one definition of what counts as a nested execution (#741); this
300
417
  * function supplies the command-surface interpretation of each one found.
418
+ *
419
+ * `node` may be a context outright or merely host one, so the traversal is the
420
+ * root-inclusive `forEachExecutionIn`.
301
421
  */
302
422
  function collectHostedCommands(node: TSNode, out: BashCommand[]): void {
303
- forEachNestedExecution(node, (contextNode, context) => {
423
+ forEachExecutionIn(node, (contextNode, context) => {
304
424
  // A nested execution starts fresh: an enclosing statement's redirect is
305
425
  // that statement's, not the substitution's, exactly as #807 attributes a
306
426
  // nested command's path tokens to its own command.
@@ -47,6 +47,24 @@ export const EXECUTION_HOST_TYPES: ReadonlySet<string> = new Set([
47
47
  "heredoc_body",
48
48
  ]);
49
49
 
50
+ /**
51
+ * Visit every execution context `node` *is or contains*, in source order.
52
+ *
53
+ * The root-inclusive question, and the one nearly every consumer asks: a node
54
+ * handed in can be a substitution outright (`> $(cmd)`) or merely host one
55
+ * (`> ${DIR}/$(cmd)`), and both really execute. {@link forEachNestedExecution}
56
+ * answers the strictly-within question instead, which is what a visitor needs
57
+ * once it has already decided to treat a context's interior itself.
58
+ */
59
+ export function forEachExecutionIn(
60
+ node: TSNode,
61
+ visit: (contextNode: TSNode, context: BashCommandContext) => void,
62
+ ): void {
63
+ const context = NESTED_EXECUTION_CONTEXTS.get(node.type);
64
+ if (context) visit(node, context);
65
+ else forEachNestedExecution(node, visit);
66
+ }
67
+
50
68
  /**
51
69
  * Visit every nested execution context in `node`'s subtree, in source order.
52
70
  *
@@ -58,6 +76,10 @@ export const EXECUTION_HOST_TYPES: ReadonlySet<string> = new Set([
58
76
  * A substitution can nest under `command_name` (when the whole command is
59
77
  * `$(…)`), under an argument, inside a redirect destination, or inside an
60
78
  * interpolating heredoc body, so the entire subtree is searched.
79
+ *
80
+ * `node` itself is never visited, however it is typed — use
81
+ * {@link forEachExecutionIn} when it may *be* a context rather than merely
82
+ * contain one.
61
83
  */
62
84
  export function forEachNestedExecution(
63
85
  node: TSNode,
@@ -2,8 +2,7 @@ import { basename } from "node:path";
2
2
  import { proveCommandEffect } from "#src/access-intent/bash/command-effects";
3
3
  import {
4
4
  EXECUTION_HOST_TYPES,
5
- forEachNestedExecution,
6
- NESTED_EXECUTION_CONTEXTS,
5
+ forEachExecutionIn,
7
6
  } from "#src/access-intent/bash/nested-execution";
8
7
  import {
9
8
  ARG_NODE_TYPES,
@@ -79,10 +78,11 @@ export function collectCommandTokens(node: TSNode): PathToken[] {
79
78
  const config = commandName
80
79
  ? PATTERN_FIRST_COMMANDS.get(commandName)
81
80
  : undefined;
82
- const tokens = config
83
- ? collectPatternCommandTokens(node, config, effect)
84
- : collectGenericCommandTokens(node, effect);
85
- return [...tokens, ...collectEmbeddedOptionValues(node, effect)];
81
+ if (config) return collectPatternCommandTokens(node, config, effect);
82
+ return [
83
+ ...collectGenericCommandTokens(node, effect),
84
+ ...collectEmbeddedOptionValues(node, effect),
85
+ ];
86
86
  }
87
87
 
88
88
  /**
@@ -128,15 +128,12 @@ export function collectRedirectTokens(node: TSNode): PathToken[] {
128
128
  * its prose stays out of the path surface entirely.
129
129
  *
130
130
  * `node` may be a context outright (`> $(cmd)`) or merely contain one
131
- * (`> ${DIR}/$(cmd)`); `forEachNestedExecution` searches strictly within a
132
- * subtree, so the first case is checked here.
131
+ * (`> ${DIR}/$(cmd)`), so the traversal is the root-inclusive
132
+ * `forEachExecutionIn`.
133
133
  */
134
134
  function collectHostedExecutionTokens(node: TSNode): PathToken[] {
135
- if (NESTED_EXECUTION_CONTEXTS.has(node.type)) {
136
- return collectPathCandidateTokens(node);
137
- }
138
135
  const tokens: PathToken[] = [];
139
- forEachNestedExecution(node, (contextNode) => {
136
+ forEachExecutionIn(node, (contextNode) => {
140
137
  tokens.push(...collectPathCandidateTokens(contextNode));
141
138
  });
142
139
  return tokens;
@@ -200,6 +197,23 @@ function commandArgumentWords(node: TSNode): string[] {
200
197
  return words;
201
198
  }
202
199
 
200
+ /**
201
+ * The children of a `command` node that supply no operand text of their own:
202
+ * its head word, and any env-var prefix assignment.
203
+ *
204
+ * Both are skipped as operands — the head word is not an argument, and a prefix
205
+ * assignment's value is assigned rather than accessed — but either can *host* a
206
+ * substitution that really runs (`$(cat /etc/shadow)`,
207
+ * `FOO=$(cat /etc/shadow) echo hi`), whose own operands are candidates like any
208
+ * other position (ADR 0009's positional invariance). The two walkers below are
209
+ * different state machines and so each carry their own skip, which is why the
210
+ * question is named here once rather than spelled twice (#742).
211
+ */
212
+ const COMMAND_PREFIX_TYPES: ReadonlySet<string> = new Set([
213
+ "command_name",
214
+ "variable_assignment",
215
+ ]);
216
+
203
217
  /**
204
218
  * A long or short option carrying its value inline: one or two leading dashes,
205
219
  * a name containing no `=` or whitespace, then `=` and a non-empty value.
@@ -208,11 +222,11 @@ function commandArgumentWords(node: TSNode): string[] {
208
222
  const OPTION_VALUE_PATTERN = /^-{1,2}[^=\s]+=(.+)$/;
209
223
 
210
224
  /**
211
- * The values embedded in this command's `--opt=value` argument tokens.
225
+ * The values embedded in a **generic** command's `--opt=value` argument tokens.
212
226
  *
213
227
  * Read straight from the argument nodes rather than from the collected token
214
- * list, because a pattern-first command's collector classifies a flag and never
215
- * emits it — so `grep --file=/tmp/patterns` would otherwise lose the path.
228
+ * list, because a collector classifies a flag and never emits it — so
229
+ * `tar --directory=/etc` would otherwise lose the path.
216
230
  *
217
231
  * This is token *preprocessing*, not classification: the extracted value is
218
232
  * handed to the ordinary shape classifiers and existence probe, so
@@ -220,6 +234,10 @@ const OPTION_VALUE_PATTERN = /^-{1,2}[^=\s]+=(.+)$/;
220
234
  * yields a bare `json` that names nothing and is dropped. Keeping the split
221
235
  * here is what lets the projection see option-embedded paths without per-command
222
236
  * option tables (ADR 0009, #645).
237
+ *
238
+ * A pattern-first command runs the same split from inside its own walker
239
+ * instead, because there the flag's *role* is known: splitting blindly emits a
240
+ * pattern flag's value as a path candidate (#823).
223
241
  */
224
242
  function collectEmbeddedOptionValues(
225
243
  node: TSNode,
@@ -239,11 +257,64 @@ function collectEmbeddedOptionValues(
239
257
  return values;
240
258
  }
241
259
 
260
+ /** The value embedded in a single `--opt=value` token, if it carries one. */
261
+ function embeddedOptionValueToken(
262
+ text: string,
263
+ effect: TokenEffect,
264
+ ): PathToken[] {
265
+ const value = OPTION_VALUE_PATTERN.exec(text)?.[1];
266
+ return value === undefined ? [] : [{ token: value, effect }];
267
+ }
268
+
269
+ /**
270
+ * What a recognized flag's argument is, for the pattern-first walker.
271
+ *
272
+ * `script` and `script-file` mark the inline pattern positional as already
273
+ * supplied; `value` and `suffix` do not. Only `script-file` contributes a path
274
+ * candidate — the others name a pattern, a count, or a backup suffix.
275
+ */
276
+ type PatternFlagRole =
277
+ /** Supplies the pattern/script inline (`grep -e`, `sed --expression`). */
278
+ | "script"
279
+ /** Supplies the pattern/script from a file (`grep -f`, `sed --file`). */
280
+ | "script-file"
281
+ /** Consumes a value that is neither pattern nor path (`grep -A`, `rg -g`). */
282
+ | "value"
283
+ /**
284
+ * Consumes the following argument only when it is empty.
285
+ *
286
+ * BSD `sed` requires a separate suffix argument (`sed -i '' 's/a/b/' f`)
287
+ * while GNU `sed` requires it glued (`-i`, `-i.bak`). Consuming
288
+ * unconditionally is right for one and eats the *script* on the other,
289
+ * leaving the file operand to be skipped as the inline pattern — a write
290
+ * target that reaches no path surface. The argument's own emptiness decides
291
+ * it, so the walk needs no knowledge of which sed is installed (#823).
292
+ *
293
+ * BSD also accepts a separate *non-empty* suffix (`sed -i bak 's/a/b/' f`),
294
+ * which this rule declines: the suffix then spends the pattern positional
295
+ * and the script surfaces as a candidate. The file operand still survives,
296
+ * so the residual is on ADR 0009's recoverable side.
297
+ */
298
+ | "suffix"
299
+ /**
300
+ * Recognized, but whose arity depends on which implementation the command's
301
+ * *name* resolves to — so it takes neither the following argument nor the
302
+ * pattern positional.
303
+ *
304
+ * `awk` is GNU awk on Fedora/RHEL, where `--file prog.awk` reads `prog.awk`,
305
+ * and one-true-awk or mawk elsewhere, where the long option is ignored
306
+ * outright and `prog.awk` is the program *text*. Asserting either arity
307
+ * drops a real operand on the other family, and the projection cannot see
308
+ * which binary the name will reach. Claiming neither over-surfaces on both
309
+ * — the recoverable direction — and the extra token names nothing, so the
310
+ * existence probe discards it. Prefer a precise role wherever the name does
311
+ * fix the parser: `gawk` gets the real ones (#823).
312
+ */
313
+ | "unknown-arity";
314
+
242
315
  interface PatternCommandConfig {
243
- /** Flags that consume the next argument as a non-path value (pattern, separator, etc.) */
244
- readonly argConsumingFlags: ReadonlySet<string>;
245
- /** Flags that consume the next argument as a file path */
246
- readonly fileConsumingFlags: ReadonlySet<string>;
316
+ /** Recognized flag spellings, short and long, mapped to their roles. */
317
+ readonly flags: ReadonlyMap<string, PatternFlagRole>;
247
318
  /**
248
319
  * Number of leading positional arguments that are patterns/scripts, not paths.
249
320
  * Default: 1 (covers sed, awk, grep, rg).
@@ -252,130 +323,186 @@ interface PatternCommandConfig {
252
323
  readonly patternPositionals?: number;
253
324
  }
254
325
 
326
+ const GREP_FLAGS = new Map<string, PatternFlagRole>([
327
+ ["-e", "script"],
328
+ ["--regexp", "script"],
329
+ ["-f", "script-file"],
330
+ ["--file", "script-file"],
331
+ ["-A", "value"],
332
+ ["--after-context", "value"],
333
+ ["-B", "value"],
334
+ ["--before-context", "value"],
335
+ ["-C", "value"],
336
+ // `--context` is deliberately absent, though `-C` is present and `rg` lists
337
+ // the long form below. grep parses with getopt, which declares `context`
338
+ // with an *optional* argument (`-C[NUM]`'s history), and a long option
339
+ // declared that way never takes a separate `argv`: `grep --context 2 pat f`
340
+ // searches for `2` in the files `pat` and `f`. Listing it would consume the
341
+ // `2`, leaving `pat` — a real file operand — to be skipped as the inline
342
+ // pattern. Its absence costs only a bare `2` token from `--context=2`, which
343
+ // names nothing and the existence probe drops (#823).
344
+ ["-m", "value"],
345
+ ["--max-count", "value"],
346
+ ]);
347
+
348
+ const SED_CONFIG: PatternCommandConfig = {
349
+ flags: new Map<string, PatternFlagRole>([
350
+ ["-e", "script"],
351
+ ["--expression", "script"],
352
+ ["-f", "script-file"],
353
+ ["--file", "script-file"],
354
+ ["-i", "suffix"],
355
+ ]),
356
+ };
357
+
358
+ /**
359
+ * The short flags are POSIX and consume on every awk; the GNU long forms are
360
+ * `unknown-arity` because the bare name does not fix the parser.
361
+ *
362
+ * `awk` is GNU awk on Fedora/RHEL, where `--file prog.awk` reads `prog.awk`,
363
+ * and one-true-awk or mawk on macOS and Debian/Ubuntu, where the long option
364
+ * is ignored outright (`awk: unknown option --field-separator ignored`) and
365
+ * the following words are the program text and its input files. Asserting
366
+ * either arity drops a real operand on the other family, so the table asserts
367
+ * neither. `nawk` shares this for the same reason (#823).
368
+ */
369
+ const AWK_CONFIG: PatternCommandConfig = {
370
+ flags: new Map<string, PatternFlagRole>([
371
+ ["-e", "script"],
372
+ ["-f", "script-file"],
373
+ ["-F", "value"],
374
+ ["-v", "value"],
375
+ ["--source", "unknown-arity"],
376
+ ["--file", "unknown-arity"],
377
+ ["--field-separator", "unknown-arity"],
378
+ ["--assign", "unknown-arity"],
379
+ ]),
380
+ };
381
+
382
+ /** `gawk` names GNU awk outright, so its long forms carry their real roles. */
383
+ const GAWK_CONFIG: PatternCommandConfig = {
384
+ flags: new Map<string, PatternFlagRole>([
385
+ ...AWK_CONFIG.flags,
386
+ ["--source", "script"],
387
+ ["--file", "script-file"],
388
+ ["--field-separator", "value"],
389
+ ["--assign", "value"],
390
+ ]),
391
+ };
392
+
393
+ const GREP_CONFIG: PatternCommandConfig = { flags: GREP_FLAGS };
394
+
395
+ const RG_CONFIG: PatternCommandConfig = {
396
+ flags: new Map<string, PatternFlagRole>([
397
+ ...GREP_FLAGS,
398
+ // rg parses with clap rather than getopt, where `--context` takes a
399
+ // required argument, so its separated spelling really does consume:
400
+ // `rg --context 2 pat f` searches for `pat` in `f`. Same spelling as
401
+ // grep's, opposite arity — which is why it is listed per tool rather than
402
+ // shared above (#823).
403
+ ["--context", "value"],
404
+ ["-g", "value"],
405
+ ["--glob", "value"],
406
+ ["-t", "value"],
407
+ ["--type", "value"],
408
+ ["-T", "value"],
409
+ ["--type-not", "value"],
410
+ ["-j", "value"],
411
+ ["--threads", "value"],
412
+ ["-M", "value"],
413
+ ["--max-columns", "value"],
414
+ ["-r", "value"],
415
+ ["--replace", "value"],
416
+ ["-E", "value"],
417
+ ["--encoding", "value"],
418
+ ]),
419
+ };
420
+
421
+ const SD_CONFIG: PatternCommandConfig = {
422
+ flags: new Map<string, PatternFlagRole>([
423
+ ["-f", "value"],
424
+ ["--flags", "value"],
425
+ ["-n", "value"],
426
+ ["--max-replacements", "value"],
427
+ ]),
428
+ patternPositionals: 2,
429
+ };
430
+
255
431
  /**
256
432
  * Commands whose first N positional arguments are inline patterns/scripts,
257
433
  * not filesystem paths. The map stores per-command flag configuration so
258
434
  * the walker can correctly identify which arguments are consumed by flags
259
435
  * vs. which are positional.
436
+ *
437
+ * Names share a configuration object only when they share a *parser*, which is
438
+ * narrower than being aliases: `egrep`/`fgrep` are the same binary as `grep`
439
+ * here, and `nawk` is one-true-awk like `awk` — but `gawk` has its own config,
440
+ * because it is the only one of the three that certainly means GNU awk and so
441
+ * the only one whose long options certainly consume (#823).
260
442
  */
261
443
  const PATTERN_FIRST_COMMANDS: ReadonlyMap<string, PatternCommandConfig> =
262
444
  new Map([
263
- [
264
- "sed",
265
- {
266
- argConsumingFlags: new Set(["-e", "-i"]),
267
- fileConsumingFlags: new Set(["-f"]),
268
- },
269
- ],
270
- [
271
- "awk",
272
- {
273
- argConsumingFlags: new Set(["-e", "-F", "-v"]),
274
- fileConsumingFlags: new Set(["-f"]),
275
- },
276
- ],
277
- [
278
- "gawk",
279
- {
280
- argConsumingFlags: new Set(["-e", "-F", "-v"]),
281
- fileConsumingFlags: new Set(["-f"]),
282
- },
283
- ],
284
- [
285
- "nawk",
286
- {
287
- argConsumingFlags: new Set(["-e", "-F", "-v"]),
288
- fileConsumingFlags: new Set(["-f"]),
289
- },
290
- ],
291
- [
292
- "grep",
293
- {
294
- argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
295
- fileConsumingFlags: new Set(["-f"]),
296
- },
297
- ],
298
- [
299
- "egrep",
300
- {
301
- argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
302
- fileConsumingFlags: new Set(["-f"]),
303
- },
304
- ],
305
- [
306
- "fgrep",
307
- {
308
- argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
309
- fileConsumingFlags: new Set(["-f"]),
310
- },
311
- ],
312
- [
313
- "rg",
314
- {
315
- argConsumingFlags: new Set([
316
- "-e",
317
- "-A",
318
- "-B",
319
- "-C",
320
- "-m",
321
- "-g",
322
- "-t",
323
- "-T",
324
- "-j",
325
- "-M",
326
- "-r",
327
- "-E",
328
- ]),
329
- fileConsumingFlags: new Set(["-f"]),
330
- },
331
- ],
332
- [
333
- "sd",
334
- {
335
- argConsumingFlags: new Set(["-n", "-f"]),
336
- fileConsumingFlags: new Set([]),
337
- patternPositionals: 2,
338
- },
339
- ],
445
+ ["sed", SED_CONFIG],
446
+ ["awk", AWK_CONFIG],
447
+ ["gawk", GAWK_CONFIG],
448
+ ["nawk", AWK_CONFIG],
449
+ ["grep", GREP_CONFIG],
450
+ ["egrep", GREP_CONFIG],
451
+ ["fgrep", GREP_CONFIG],
452
+ ["rg", RG_CONFIG],
453
+ ["sd", SD_CONFIG],
340
454
  ]);
341
455
 
342
456
  /**
343
457
  * Describes what the walker should do when it encounters a flag word inside
344
458
  * a pattern-first command. Using a discriminated union lets the `switch` in
345
- * `collectPatternCommandTokens` narrow `nextArgAction` without a non-null
459
+ * `collectPatternCommandTokens` narrow the flag's role without a non-null
346
460
  * assertion (which would trigger the Biome/ESLint assertion conflict).
347
461
  */
348
462
  type PatternCommandFlagDirective =
349
463
  | { kind: "end-of-flags" }
350
464
  | { kind: "regular-flag" }
351
- | {
352
- kind: "consume-arg";
353
- nextArgAction: "skip" | "extract";
354
- setsExplicitScript: boolean;
355
- };
465
+ /** A recognized flag whose value is the argument that follows it. */
466
+ | { kind: "consume-next"; role: PatternFlagRole }
467
+ /** A recognized flag carrying its value in the same token. */
468
+ | { kind: "inline-value"; role: PatternFlagRole; value: string };
469
+
470
+ /** A long option carrying its value inline: `--name=value`. */
471
+ const LONG_OPTION_VALUE_PATTERN = /^(--[^=\s]+)=(.+)$/;
356
472
 
357
473
  /**
358
474
  * Classify a flag word from a pattern-first command into a directive that
359
- * tells the walker how to handle the flag and its following argument.
475
+ * tells the walker how to handle the flag and its value.
476
+ *
477
+ * Matched in the order the tools accept: the exact spelling (short or long),
478
+ * then a long option's `=`-embedded value, then a glued short value. The glued
479
+ * form matches only the **first** short flag, which is getopt's own rule —
480
+ * `grep -ei pattern` really is `-e` with the value `i`. A cluster whose
481
+ * argument-taking flag is not first (`grep -ie pattern`) therefore stays a
482
+ * plain flag, which over-surfaces the pattern rather than dropping the
483
+ * command's operand (ADR 0009's recoverable direction).
360
484
  */
361
485
  function classifyPatternCommandFlag(
362
486
  text: string,
363
487
  config: PatternCommandConfig,
364
488
  ): PatternCommandFlagDirective {
365
489
  if (text === "--") return { kind: "end-of-flags" };
366
- if (config.argConsumingFlags.has(text)) {
367
- return {
368
- kind: "consume-arg",
369
- nextArgAction: "skip",
370
- setsExplicitScript: text === "-e" || text === "-f",
371
- };
490
+
491
+ const exact = config.flags.get(text);
492
+ if (exact) return { kind: "consume-next", role: exact };
493
+
494
+ const longOption = LONG_OPTION_VALUE_PATTERN.exec(text);
495
+ if (longOption) {
496
+ const [, name, value] = longOption;
497
+ const role = config.flags.get(name);
498
+ return role === undefined
499
+ ? { kind: "regular-flag" }
500
+ : { kind: "inline-value", role, value };
372
501
  }
373
- if (config.fileConsumingFlags.has(text)) {
374
- return {
375
- kind: "consume-arg",
376
- nextArgAction: "extract",
377
- setsExplicitScript: true,
378
- };
502
+
503
+ if (!text.startsWith("--") && text.length > 2) {
504
+ const role = config.flags.get(text.slice(0, 2));
505
+ if (role) return { kind: "inline-value", role, value: text.slice(2) };
379
506
  }
380
507
  return { kind: "regular-flag" };
381
508
  }
@@ -389,11 +516,13 @@ function classifyPatternCommandFlag(
389
516
  * inline patterns/scripts and are skipped. Remaining positional
390
517
  * arguments are collected as path candidates.
391
518
  *
392
- * Flags listed in `argConsumingFlags` consume the next argument
393
- * (skipped). Flags in `fileConsumingFlags` consume the next
394
- * argument as a file path (collected). The flags `-e` and `-f`
395
- * additionally signal that an explicit script was provided via
396
- * flag, so no inline positional script is expected.
519
+ * A recognized flag's role (see {@link PatternFlagRole}) decides three things
520
+ * at once: whether the pattern positional is still expected, whether the
521
+ * flag's value is a path candidate, and for `suffix` whether the
522
+ * following argument belongs to the flag at all. The `=`-embedded and glued
523
+ * spellings carry the value in the flag's own token, so the walker splits it
524
+ * here rather than letting {@link collectEmbeddedOptionValues} emit a
525
+ * pattern's text as a path (#823).
397
526
  */
398
527
  function collectPatternCommandTokens(
399
528
  node: TSNode,
@@ -403,7 +532,7 @@ function collectPatternCommandTokens(
403
532
  const patternPositionals = config.patternPositionals ?? 1;
404
533
  let hasExplicitScript = false;
405
534
  let positionalsSeen = 0;
406
- let nextArgAction: "skip" | "extract" | null = null;
535
+ let pendingConsumption: PatternFlagRole | null = null;
407
536
  let pastEndOfFlags = false;
408
537
  const tokens: PathToken[] = [];
409
538
 
@@ -411,27 +540,56 @@ function collectPatternCommandTokens(
411
540
  const child = node.child(i);
412
541
  if (!child) continue;
413
542
 
414
- // Skip command_name and variable_assignment nodes.
415
- if (child.type === "command_name" || child.type === "variable_assignment")
416
- continue;
417
-
418
- // Only process argument-like nodes; recurse into others
419
- // (e.g. command_substitution) for nested commands.
420
- if (!ARG_NODE_TYPES.has(child.type)) {
421
- tokens.push(...collectPathCandidateTokens(child));
543
+ if (COMMAND_PREFIX_TYPES.has(child.type)) {
544
+ // Supplies no operand of its own, but may host one that really runs.
545
+ tokens.push(...collectHostedExecutionTokens(child));
422
546
  continue;
423
547
  }
424
548
 
549
+ const isArgNode = ARG_NODE_TYPES.has(child.type);
425
550
  const text = resolveNodeText(child);
426
551
 
427
- // Handle consumed argument from previous flag.
428
- if (nextArgAction === "skip") {
429
- nextArgAction = null;
430
- continue;
552
+ // Handle the argument a previous flag consumed. The consumption discharges
553
+ // on whatever node type follows, not only on an ARG_NODE_TYPES one: a bare
554
+ // number (`-A 3`), an expansion (`-A $N`), and a substitution
555
+ // (`-A $(echo 3)`) are all this flag's argument, and carrying the pending
556
+ // skip past them lands it on the *pattern* — shifting the positional count
557
+ // by one and eating the command's real file operand (#823).
558
+ if (pendingConsumption !== null) {
559
+ const consumption = pendingConsumption;
560
+ pendingConsumption = null;
561
+ if (!isArgNode) {
562
+ // Contributes no operand text of its own, but may host a nested
563
+ // execution whose operands are candidates (#741).
564
+ tokens.push(...collectPathCandidateTokens(child));
565
+ continue;
566
+ }
567
+ const discharge = dischargePendingConsumption(consumption, text, effect);
568
+ if (discharge.token) tokens.push(discharge.token);
569
+ if (discharge.consumed) continue;
431
570
  }
432
- if (nextArgAction === "extract") {
433
- tokens.push({ token: text, effect });
434
- nextArgAction = null;
571
+
572
+ // A node outside ARG_NODE_TYPES is still one word the shell passes as an
573
+ // argument (`grep 42 f`, `grep $PATTERN f`, `grep $(cmd) f`), so it spends
574
+ // a pattern positional even though no reliable operand text can be read
575
+ // from it. Counting only argument nodes left a numeric or computed pattern
576
+ // unseen, so the slot was spent on the command's real operand instead and
577
+ // the operand reached no path surface (#823).
578
+ //
579
+ // A redirect hosted on the command node is not an argument and is excluded;
580
+ // counting it would push the real pattern out as an operand token. The
581
+ // exclusion is the narrow side on purpose: miscounting an argument as a
582
+ // redirect drops an operand, while the reverse only over-surfaces.
583
+ if (!isArgNode) {
584
+ if (
585
+ !EXECUTION_HOST_TYPES.has(child.type) &&
586
+ !hasExplicitScript &&
587
+ positionalsSeen < patternPositionals
588
+ ) {
589
+ positionalsSeen++;
590
+ }
591
+ // Recurse for nested commands (e.g. command_substitution).
592
+ tokens.push(...collectPathCandidateTokens(child));
435
593
  continue;
436
594
  }
437
595
 
@@ -447,11 +605,19 @@ function collectPatternCommandTokens(
447
605
  case "end-of-flags":
448
606
  pastEndOfFlags = true;
449
607
  break;
450
- case "consume-arg":
451
- nextArgAction = directive.nextArgAction;
452
- if (directive.setsExplicitScript) hasExplicitScript = true;
608
+ case "consume-next":
609
+ pendingConsumption = directive.role;
610
+ if (suppliesScript(directive.role)) hasExplicitScript = true;
611
+ break;
612
+ case "inline-value":
613
+ if (directive.role === "script-file")
614
+ tokens.push({ token: directive.value, effect });
615
+ if (suppliesScript(directive.role)) hasExplicitScript = true;
453
616
  break;
454
617
  case "regular-flag":
618
+ // Unrecognized: fall back to the blind `--opt=value` split, which is
619
+ // safe precisely because the flag's role is unknown (#645).
620
+ tokens.push(...embeddedOptionValueToken(text, effect));
455
621
  break;
456
622
  }
457
623
  continue;
@@ -459,17 +625,63 @@ function collectPatternCommandTokens(
459
625
 
460
626
  // Positional argument.
461
627
  if (!hasExplicitScript && positionalsSeen < patternPositionals) {
462
- positionalsSeen++;
463
- continue; // Skip: this is an inline pattern/script.
628
+ positionalsSeen++; // Skip: this is an inline pattern/script.
629
+ } else {
630
+ tokens.push({ token: text, effect });
464
631
  }
465
-
466
- // File argument collect as path candidate.
467
- tokens.push({ token: text, effect });
632
+ // A quoted flag never reaches the flag branch above, so its embedded value
633
+ // is split here instead.
634
+ tokens.push(...embeddedOptionValueToken(text, effect));
468
635
  }
469
636
 
470
637
  return tokens;
471
638
  }
472
639
 
640
+ /**
641
+ * Whether a flag in this role means the inline pattern positional is spent.
642
+ *
643
+ * `unknown-arity` says so for the opposite reason to the others: not because
644
+ * the script was supplied, but because the walker cannot tell which word the
645
+ * script is, and skipping the wrong one drops a real operand.
646
+ */
647
+ function suppliesScript(role: PatternFlagRole): boolean {
648
+ return (
649
+ role === "script" || role === "script-file" || role === "unknown-arity"
650
+ );
651
+ }
652
+
653
+ /**
654
+ * What a pending flag consumption made of the argument node that followed it.
655
+ *
656
+ * `consumed` is the flag's own verdict, not the walker's: a `suffix` flag
657
+ * declines a non-empty argument, which the walker then reads as an ordinary
658
+ * argument.
659
+ */
660
+ interface ConsumptionDischarge {
661
+ readonly consumed: boolean;
662
+ /** The path candidate the consumed argument contributes, if any. */
663
+ readonly token?: PathToken;
664
+ }
665
+
666
+ /** Apply a pending consumption to the argument text that follows its flag. */
667
+ function dischargePendingConsumption(
668
+ role: PatternFlagRole,
669
+ text: string,
670
+ effect: TokenEffect,
671
+ ): ConsumptionDischarge {
672
+ switch (role) {
673
+ case "script-file":
674
+ return { consumed: true, token: { token: text, effect } };
675
+ case "script":
676
+ case "value":
677
+ return { consumed: true };
678
+ case "suffix":
679
+ return { consumed: text === "" };
680
+ case "unknown-arity":
681
+ return { consumed: false };
682
+ }
683
+ }
684
+
473
685
  /**
474
686
  * Collect all argument tokens from a generic (non-pattern-first) command node,
475
687
  * skipping the command name and variable assignments.
@@ -485,12 +697,12 @@ function collectGenericCommandTokens(
485
697
  const child = node.child(i);
486
698
  if (!child) continue;
487
699
 
488
- if (child.type === "command_name") {
489
- seenCommandName = true;
700
+ if (COMMAND_PREFIX_TYPES.has(child.type)) {
701
+ // Supplies no operand of its own, but may host one that really runs.
702
+ if (child.type === "command_name") seenCommandName = true;
703
+ tokens.push(...collectHostedExecutionTokens(child));
490
704
  continue;
491
705
  }
492
- // Skip variable_assignment nodes (FOO=/bar)
493
- if (child.type === "variable_assignment") continue;
494
706
 
495
707
  // If there was no explicit command_name node, the first word-like
496
708
  // child is the command name itself — skip it.