@gotgenes/pi-permission-system 27.1.0 → 27.1.2

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,30 @@ 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.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v27.1.1...pi-permission-system-v27.1.2) (2026-08-29)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **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))
14
+ * **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))
15
+ * **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))
16
+ * **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))
17
+ * **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))
18
+ * **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))
19
+
20
+
21
+ ### Documentation
22
+
23
+ * **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))
24
+
25
+ ## [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)
26
+
27
+
28
+ ### Bug Fixes
29
+
30
+ * **pi-permission-system:** project glob-bearing bash path tokens to the path surfaces ([706c75d](https://github.com/gotgenes/pi-packages/commit/706c75da10d03c61ef0d53f9571d249b91986db5)), closes [#821](https://github.com/gotgenes/pi-packages/issues/821)
31
+
8
32
  ## [27.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v27.0.1...pi-permission-system-v27.1.0) (2026-08-27)
9
33
 
10
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "27.1.0",
3
+ "version": "27.1.2",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -16,9 +16,17 @@
16
16
  * `docs/decisions/0009-bash-path-projection-completeness-contract.md`.
17
17
  *
18
18
  * All three classifiers share the private `rejectNonPathToken` predicate that
19
- * captures the six rejection cases common to them (the production clone this
19
+ * captures the five rejection cases common to them (the production clone this
20
20
  * module was extracted to eliminate).
21
21
  *
22
+ * None of them reads a token's glob or regex metacharacters. A shell bracket
23
+ * glob and a regex character class are spelled identically, so "contains a
24
+ * metacharacter" cannot decide path-hood — and it silently dropped tokens the
25
+ * shell really does expand into filesystem paths (`/etc/[p]asswd`,
26
+ * `rm -rf /tmp/tmp.*`, #821). What a pattern argument is instead settled by
27
+ * *position*: `PATTERN_FIRST_COMMANDS` in `token-collection.ts` skips a
28
+ * pattern-first command's inline pattern positional at collection time.
29
+ *
22
30
  * Both `classifyTokenAsPathCandidate` and `classifyTokenAsRuleCandidate` recognize
23
31
  * Windows drive-letter absolute paths (`C:/…`, `C:\…`) unconditionally on all
24
32
  * platforms. On POSIX the token resolves as a real in-CWD relative path and is
@@ -106,8 +114,8 @@ export function classifyTokenAsRuleCandidate(
106
114
  * *shape* name a path at all?
107
115
  *
108
116
  * It runs only the shared `rejectNonPathToken` prelude, so a flag,
109
- * env-assignment, URL, `@scope` token, or regex-shaped token is never a
110
- * candidate. Everything else is returned for the caller to probe.
117
+ * env-assignment, URL, or `@scope` token is never a candidate. Everything else
118
+ * is returned for the caller to probe.
111
119
  *
112
120
  * Deliberately consults no policy: candidacy is settled by the filesystem and
113
121
  * the decision by the ruleset, which keeps this module a pure shape function
@@ -137,19 +145,14 @@ const WINDOWS_DRIVE_PATH_PATTERN = /^[a-zA-Z]:[/\\]/;
137
145
  */
138
146
  const URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
139
147
 
140
- /**
141
- * Regex metacharacter sequences that are never found in real filesystem paths.
142
- * If a token contains any of these, it is almost certainly a regex pattern
143
- * (e.g. a grep argument) rather than a path.
144
- */
145
- const REGEX_METACHAR_PATTERN = /\.\*|\.\+|\\\||\\\(|\\\)|\[.*?\]|\^\//;
146
-
147
148
  /**
148
149
  * Shared rejection prelude: returns `true` when a token can never be a
149
150
  * filesystem path, regardless of which classifier is asking.
150
151
  *
151
152
  * Rejects: empty tokens, flags (leading `-`), env assignments (`FOO=/bar`),
152
- * URLs, `@scope/package` patterns, and regex metacharacter sequences.
153
+ * URLs, and `@scope/package` patterns. Each rules a token out by syntax; a
154
+ * glob or regex metacharacter does not, since the two are indistinguishable
155
+ * and the shell expands the former into real paths (#821).
153
156
  *
154
157
  * A bare `/` (or `//`, `///`) is NOT rejected: it denotes the filesystem root,
155
158
  * a deliberate external-directory access (`find /`, `ls /`), so it must reach
@@ -172,7 +175,5 @@ function rejectNonPathToken(token: string): boolean {
172
175
  // since it looks like an absolute-rooted path, not an npm scope.
173
176
  if (token.startsWith("@") && !token.startsWith("@/")) return true;
174
177
 
175
- if (REGEX_METACHAR_PATTERN.test(token)) return true;
176
-
177
178
  return false;
178
179
  }
@@ -79,10 +79,11 @@ export function collectCommandTokens(node: TSNode): PathToken[] {
79
79
  const config = commandName
80
80
  ? PATTERN_FIRST_COMMANDS.get(commandName)
81
81
  : undefined;
82
- const tokens = config
83
- ? collectPatternCommandTokens(node, config, effect)
84
- : collectGenericCommandTokens(node, effect);
85
- return [...tokens, ...collectEmbeddedOptionValues(node, effect)];
82
+ if (config) return collectPatternCommandTokens(node, config, effect);
83
+ return [
84
+ ...collectGenericCommandTokens(node, effect),
85
+ ...collectEmbeddedOptionValues(node, effect),
86
+ ];
86
87
  }
87
88
 
88
89
  /**
@@ -208,11 +209,11 @@ function commandArgumentWords(node: TSNode): string[] {
208
209
  const OPTION_VALUE_PATTERN = /^-{1,2}[^=\s]+=(.+)$/;
209
210
 
210
211
  /**
211
- * The values embedded in this command's `--opt=value` argument tokens.
212
+ * The values embedded in a **generic** command's `--opt=value` argument tokens.
212
213
  *
213
214
  * 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.
215
+ * list, because a collector classifies a flag and never emits it — so
216
+ * `tar --directory=/etc` would otherwise lose the path.
216
217
  *
217
218
  * This is token *preprocessing*, not classification: the extracted value is
218
219
  * handed to the ordinary shape classifiers and existence probe, so
@@ -220,6 +221,10 @@ const OPTION_VALUE_PATTERN = /^-{1,2}[^=\s]+=(.+)$/;
220
221
  * yields a bare `json` that names nothing and is dropped. Keeping the split
221
222
  * here is what lets the projection see option-embedded paths without per-command
222
223
  * option tables (ADR 0009, #645).
224
+ *
225
+ * A pattern-first command runs the same split from inside its own walker
226
+ * instead, because there the flag's *role* is known: splitting blindly emits a
227
+ * pattern flag's value as a path candidate (#823).
223
228
  */
224
229
  function collectEmbeddedOptionValues(
225
230
  node: TSNode,
@@ -239,11 +244,64 @@ function collectEmbeddedOptionValues(
239
244
  return values;
240
245
  }
241
246
 
247
+ /** The value embedded in a single `--opt=value` token, if it carries one. */
248
+ function embeddedOptionValueToken(
249
+ text: string,
250
+ effect: TokenEffect,
251
+ ): PathToken[] {
252
+ const value = OPTION_VALUE_PATTERN.exec(text)?.[1];
253
+ return value === undefined ? [] : [{ token: value, effect }];
254
+ }
255
+
256
+ /**
257
+ * What a recognized flag's argument is, for the pattern-first walker.
258
+ *
259
+ * `script` and `script-file` mark the inline pattern positional as already
260
+ * supplied; `value` and `suffix` do not. Only `script-file` contributes a path
261
+ * candidate — the others name a pattern, a count, or a backup suffix.
262
+ */
263
+ type PatternFlagRole =
264
+ /** Supplies the pattern/script inline (`grep -e`, `sed --expression`). */
265
+ | "script"
266
+ /** Supplies the pattern/script from a file (`grep -f`, `sed --file`). */
267
+ | "script-file"
268
+ /** Consumes a value that is neither pattern nor path (`grep -A`, `rg -g`). */
269
+ | "value"
270
+ /**
271
+ * Consumes the following argument only when it is empty.
272
+ *
273
+ * BSD `sed` requires a separate suffix argument (`sed -i '' 's/a/b/' f`)
274
+ * while GNU `sed` requires it glued (`-i`, `-i.bak`). Consuming
275
+ * unconditionally is right for one and eats the *script* on the other,
276
+ * leaving the file operand to be skipped as the inline pattern — a write
277
+ * target that reaches no path surface. The argument's own emptiness decides
278
+ * it, so the walk needs no knowledge of which sed is installed (#823).
279
+ *
280
+ * BSD also accepts a separate *non-empty* suffix (`sed -i bak 's/a/b/' f`),
281
+ * which this rule declines: the suffix then spends the pattern positional
282
+ * and the script surfaces as a candidate. The file operand still survives,
283
+ * so the residual is on ADR 0009's recoverable side.
284
+ */
285
+ | "suffix"
286
+ /**
287
+ * Recognized, but whose arity depends on which implementation the command's
288
+ * *name* resolves to — so it takes neither the following argument nor the
289
+ * pattern positional.
290
+ *
291
+ * `awk` is GNU awk on Fedora/RHEL, where `--file prog.awk` reads `prog.awk`,
292
+ * and one-true-awk or mawk elsewhere, where the long option is ignored
293
+ * outright and `prog.awk` is the program *text*. Asserting either arity
294
+ * drops a real operand on the other family, and the projection cannot see
295
+ * which binary the name will reach. Claiming neither over-surfaces on both
296
+ * — the recoverable direction — and the extra token names nothing, so the
297
+ * existence probe discards it. Prefer a precise role wherever the name does
298
+ * fix the parser: `gawk` gets the real ones (#823).
299
+ */
300
+ | "unknown-arity";
301
+
242
302
  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>;
303
+ /** Recognized flag spellings, short and long, mapped to their roles. */
304
+ readonly flags: ReadonlyMap<string, PatternFlagRole>;
247
305
  /**
248
306
  * Number of leading positional arguments that are patterns/scripts, not paths.
249
307
  * Default: 1 (covers sed, awk, grep, rg).
@@ -252,130 +310,186 @@ interface PatternCommandConfig {
252
310
  readonly patternPositionals?: number;
253
311
  }
254
312
 
313
+ const GREP_FLAGS = new Map<string, PatternFlagRole>([
314
+ ["-e", "script"],
315
+ ["--regexp", "script"],
316
+ ["-f", "script-file"],
317
+ ["--file", "script-file"],
318
+ ["-A", "value"],
319
+ ["--after-context", "value"],
320
+ ["-B", "value"],
321
+ ["--before-context", "value"],
322
+ ["-C", "value"],
323
+ // `--context` is deliberately absent, though `-C` is present and `rg` lists
324
+ // the long form below. grep parses with getopt, which declares `context`
325
+ // with an *optional* argument (`-C[NUM]`'s history), and a long option
326
+ // declared that way never takes a separate `argv`: `grep --context 2 pat f`
327
+ // searches for `2` in the files `pat` and `f`. Listing it would consume the
328
+ // `2`, leaving `pat` — a real file operand — to be skipped as the inline
329
+ // pattern. Its absence costs only a bare `2` token from `--context=2`, which
330
+ // names nothing and the existence probe drops (#823).
331
+ ["-m", "value"],
332
+ ["--max-count", "value"],
333
+ ]);
334
+
335
+ const SED_CONFIG: PatternCommandConfig = {
336
+ flags: new Map<string, PatternFlagRole>([
337
+ ["-e", "script"],
338
+ ["--expression", "script"],
339
+ ["-f", "script-file"],
340
+ ["--file", "script-file"],
341
+ ["-i", "suffix"],
342
+ ]),
343
+ };
344
+
345
+ /**
346
+ * The short flags are POSIX and consume on every awk; the GNU long forms are
347
+ * `unknown-arity` because the bare name does not fix the parser.
348
+ *
349
+ * `awk` is GNU awk on Fedora/RHEL, where `--file prog.awk` reads `prog.awk`,
350
+ * and one-true-awk or mawk on macOS and Debian/Ubuntu, where the long option
351
+ * is ignored outright (`awk: unknown option --field-separator ignored`) and
352
+ * the following words are the program text and its input files. Asserting
353
+ * either arity drops a real operand on the other family, so the table asserts
354
+ * neither. `nawk` shares this for the same reason (#823).
355
+ */
356
+ const AWK_CONFIG: PatternCommandConfig = {
357
+ flags: new Map<string, PatternFlagRole>([
358
+ ["-e", "script"],
359
+ ["-f", "script-file"],
360
+ ["-F", "value"],
361
+ ["-v", "value"],
362
+ ["--source", "unknown-arity"],
363
+ ["--file", "unknown-arity"],
364
+ ["--field-separator", "unknown-arity"],
365
+ ["--assign", "unknown-arity"],
366
+ ]),
367
+ };
368
+
369
+ /** `gawk` names GNU awk outright, so its long forms carry their real roles. */
370
+ const GAWK_CONFIG: PatternCommandConfig = {
371
+ flags: new Map<string, PatternFlagRole>([
372
+ ...AWK_CONFIG.flags,
373
+ ["--source", "script"],
374
+ ["--file", "script-file"],
375
+ ["--field-separator", "value"],
376
+ ["--assign", "value"],
377
+ ]),
378
+ };
379
+
380
+ const GREP_CONFIG: PatternCommandConfig = { flags: GREP_FLAGS };
381
+
382
+ const RG_CONFIG: PatternCommandConfig = {
383
+ flags: new Map<string, PatternFlagRole>([
384
+ ...GREP_FLAGS,
385
+ // rg parses with clap rather than getopt, where `--context` takes a
386
+ // required argument, so its separated spelling really does consume:
387
+ // `rg --context 2 pat f` searches for `pat` in `f`. Same spelling as
388
+ // grep's, opposite arity — which is why it is listed per tool rather than
389
+ // shared above (#823).
390
+ ["--context", "value"],
391
+ ["-g", "value"],
392
+ ["--glob", "value"],
393
+ ["-t", "value"],
394
+ ["--type", "value"],
395
+ ["-T", "value"],
396
+ ["--type-not", "value"],
397
+ ["-j", "value"],
398
+ ["--threads", "value"],
399
+ ["-M", "value"],
400
+ ["--max-columns", "value"],
401
+ ["-r", "value"],
402
+ ["--replace", "value"],
403
+ ["-E", "value"],
404
+ ["--encoding", "value"],
405
+ ]),
406
+ };
407
+
408
+ const SD_CONFIG: PatternCommandConfig = {
409
+ flags: new Map<string, PatternFlagRole>([
410
+ ["-f", "value"],
411
+ ["--flags", "value"],
412
+ ["-n", "value"],
413
+ ["--max-replacements", "value"],
414
+ ]),
415
+ patternPositionals: 2,
416
+ };
417
+
255
418
  /**
256
419
  * Commands whose first N positional arguments are inline patterns/scripts,
257
420
  * not filesystem paths. The map stores per-command flag configuration so
258
421
  * the walker can correctly identify which arguments are consumed by flags
259
422
  * vs. which are positional.
423
+ *
424
+ * Names share a configuration object only when they share a *parser*, which is
425
+ * narrower than being aliases: `egrep`/`fgrep` are the same binary as `grep`
426
+ * here, and `nawk` is one-true-awk like `awk` — but `gawk` has its own config,
427
+ * because it is the only one of the three that certainly means GNU awk and so
428
+ * the only one whose long options certainly consume (#823).
260
429
  */
261
430
  const PATTERN_FIRST_COMMANDS: ReadonlyMap<string, PatternCommandConfig> =
262
431
  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
- ],
432
+ ["sed", SED_CONFIG],
433
+ ["awk", AWK_CONFIG],
434
+ ["gawk", GAWK_CONFIG],
435
+ ["nawk", AWK_CONFIG],
436
+ ["grep", GREP_CONFIG],
437
+ ["egrep", GREP_CONFIG],
438
+ ["fgrep", GREP_CONFIG],
439
+ ["rg", RG_CONFIG],
440
+ ["sd", SD_CONFIG],
340
441
  ]);
341
442
 
342
443
  /**
343
444
  * Describes what the walker should do when it encounters a flag word inside
344
445
  * a pattern-first command. Using a discriminated union lets the `switch` in
345
- * `collectPatternCommandTokens` narrow `nextArgAction` without a non-null
446
+ * `collectPatternCommandTokens` narrow the flag's role without a non-null
346
447
  * assertion (which would trigger the Biome/ESLint assertion conflict).
347
448
  */
348
449
  type PatternCommandFlagDirective =
349
450
  | { kind: "end-of-flags" }
350
451
  | { kind: "regular-flag" }
351
- | {
352
- kind: "consume-arg";
353
- nextArgAction: "skip" | "extract";
354
- setsExplicitScript: boolean;
355
- };
452
+ /** A recognized flag whose value is the argument that follows it. */
453
+ | { kind: "consume-next"; role: PatternFlagRole }
454
+ /** A recognized flag carrying its value in the same token. */
455
+ | { kind: "inline-value"; role: PatternFlagRole; value: string };
456
+
457
+ /** A long option carrying its value inline: `--name=value`. */
458
+ const LONG_OPTION_VALUE_PATTERN = /^(--[^=\s]+)=(.+)$/;
356
459
 
357
460
  /**
358
461
  * 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.
462
+ * tells the walker how to handle the flag and its value.
463
+ *
464
+ * Matched in the order the tools accept: the exact spelling (short or long),
465
+ * then a long option's `=`-embedded value, then a glued short value. The glued
466
+ * form matches only the **first** short flag, which is getopt's own rule —
467
+ * `grep -ei pattern` really is `-e` with the value `i`. A cluster whose
468
+ * argument-taking flag is not first (`grep -ie pattern`) therefore stays a
469
+ * plain flag, which over-surfaces the pattern rather than dropping the
470
+ * command's operand (ADR 0009's recoverable direction).
360
471
  */
361
472
  function classifyPatternCommandFlag(
362
473
  text: string,
363
474
  config: PatternCommandConfig,
364
475
  ): PatternCommandFlagDirective {
365
476
  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
- };
477
+
478
+ const exact = config.flags.get(text);
479
+ if (exact) return { kind: "consume-next", role: exact };
480
+
481
+ const longOption = LONG_OPTION_VALUE_PATTERN.exec(text);
482
+ if (longOption) {
483
+ const [, name, value] = longOption;
484
+ const role = config.flags.get(name);
485
+ return role === undefined
486
+ ? { kind: "regular-flag" }
487
+ : { kind: "inline-value", role, value };
372
488
  }
373
- if (config.fileConsumingFlags.has(text)) {
374
- return {
375
- kind: "consume-arg",
376
- nextArgAction: "extract",
377
- setsExplicitScript: true,
378
- };
489
+
490
+ if (!text.startsWith("--") && text.length > 2) {
491
+ const role = config.flags.get(text.slice(0, 2));
492
+ if (role) return { kind: "inline-value", role, value: text.slice(2) };
379
493
  }
380
494
  return { kind: "regular-flag" };
381
495
  }
@@ -389,11 +503,13 @@ function classifyPatternCommandFlag(
389
503
  * inline patterns/scripts and are skipped. Remaining positional
390
504
  * arguments are collected as path candidates.
391
505
  *
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.
506
+ * A recognized flag's role (see {@link PatternFlagRole}) decides three things
507
+ * at once: whether the pattern positional is still expected, whether the
508
+ * flag's value is a path candidate, and for `suffix` whether the
509
+ * following argument belongs to the flag at all. The `=`-embedded and glued
510
+ * spellings carry the value in the flag's own token, so the walker splits it
511
+ * here rather than letting {@link collectEmbeddedOptionValues} emit a
512
+ * pattern's text as a path (#823).
397
513
  */
398
514
  function collectPatternCommandTokens(
399
515
  node: TSNode,
@@ -403,7 +519,7 @@ function collectPatternCommandTokens(
403
519
  const patternPositionals = config.patternPositionals ?? 1;
404
520
  let hasExplicitScript = false;
405
521
  let positionalsSeen = 0;
406
- let nextArgAction: "skip" | "extract" | null = null;
522
+ let pendingConsumption: PatternFlagRole | null = null;
407
523
  let pastEndOfFlags = false;
408
524
  const tokens: PathToken[] = [];
409
525
 
@@ -415,23 +531,50 @@ function collectPatternCommandTokens(
415
531
  if (child.type === "command_name" || child.type === "variable_assignment")
416
532
  continue;
417
533
 
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));
422
- continue;
423
- }
424
-
534
+ const isArgNode = ARG_NODE_TYPES.has(child.type);
425
535
  const text = resolveNodeText(child);
426
536
 
427
- // Handle consumed argument from previous flag.
428
- if (nextArgAction === "skip") {
429
- nextArgAction = null;
430
- continue;
537
+ // Handle the argument a previous flag consumed. The consumption discharges
538
+ // on whatever node type follows, not only on an ARG_NODE_TYPES one: a bare
539
+ // number (`-A 3`), an expansion (`-A $N`), and a substitution
540
+ // (`-A $(echo 3)`) are all this flag's argument, and carrying the pending
541
+ // skip past them lands it on the *pattern* — shifting the positional count
542
+ // by one and eating the command's real file operand (#823).
543
+ if (pendingConsumption !== null) {
544
+ const consumption = pendingConsumption;
545
+ pendingConsumption = null;
546
+ if (!isArgNode) {
547
+ // Contributes no operand text of its own, but may host a nested
548
+ // execution whose operands are candidates (#741).
549
+ tokens.push(...collectPathCandidateTokens(child));
550
+ continue;
551
+ }
552
+ const discharge = dischargePendingConsumption(consumption, text, effect);
553
+ if (discharge.token) tokens.push(discharge.token);
554
+ if (discharge.consumed) continue;
431
555
  }
432
- if (nextArgAction === "extract") {
433
- tokens.push({ token: text, effect });
434
- nextArgAction = null;
556
+
557
+ // A node outside ARG_NODE_TYPES is still one word the shell passes as an
558
+ // argument (`grep 42 f`, `grep $PATTERN f`, `grep $(cmd) f`), so it spends
559
+ // a pattern positional even though no reliable operand text can be read
560
+ // from it. Counting only argument nodes left a numeric or computed pattern
561
+ // unseen, so the slot was spent on the command's real operand instead and
562
+ // the operand reached no path surface (#823).
563
+ //
564
+ // A redirect hosted on the command node is not an argument and is excluded;
565
+ // counting it would push the real pattern out as an operand token. The
566
+ // exclusion is the narrow side on purpose: miscounting an argument as a
567
+ // redirect drops an operand, while the reverse only over-surfaces.
568
+ if (!isArgNode) {
569
+ if (
570
+ !EXECUTION_HOST_TYPES.has(child.type) &&
571
+ !hasExplicitScript &&
572
+ positionalsSeen < patternPositionals
573
+ ) {
574
+ positionalsSeen++;
575
+ }
576
+ // Recurse for nested commands (e.g. command_substitution).
577
+ tokens.push(...collectPathCandidateTokens(child));
435
578
  continue;
436
579
  }
437
580
 
@@ -447,11 +590,19 @@ function collectPatternCommandTokens(
447
590
  case "end-of-flags":
448
591
  pastEndOfFlags = true;
449
592
  break;
450
- case "consume-arg":
451
- nextArgAction = directive.nextArgAction;
452
- if (directive.setsExplicitScript) hasExplicitScript = true;
593
+ case "consume-next":
594
+ pendingConsumption = directive.role;
595
+ if (suppliesScript(directive.role)) hasExplicitScript = true;
596
+ break;
597
+ case "inline-value":
598
+ if (directive.role === "script-file")
599
+ tokens.push({ token: directive.value, effect });
600
+ if (suppliesScript(directive.role)) hasExplicitScript = true;
453
601
  break;
454
602
  case "regular-flag":
603
+ // Unrecognized: fall back to the blind `--opt=value` split, which is
604
+ // safe precisely because the flag's role is unknown (#645).
605
+ tokens.push(...embeddedOptionValueToken(text, effect));
455
606
  break;
456
607
  }
457
608
  continue;
@@ -459,17 +610,63 @@ function collectPatternCommandTokens(
459
610
 
460
611
  // Positional argument.
461
612
  if (!hasExplicitScript && positionalsSeen < patternPositionals) {
462
- positionalsSeen++;
463
- continue; // Skip: this is an inline pattern/script.
613
+ positionalsSeen++; // Skip: this is an inline pattern/script.
614
+ } else {
615
+ tokens.push({ token: text, effect });
464
616
  }
465
-
466
- // File argument collect as path candidate.
467
- tokens.push({ token: text, effect });
617
+ // A quoted flag never reaches the flag branch above, so its embedded value
618
+ // is split here instead.
619
+ tokens.push(...embeddedOptionValueToken(text, effect));
468
620
  }
469
621
 
470
622
  return tokens;
471
623
  }
472
624
 
625
+ /**
626
+ * Whether a flag in this role means the inline pattern positional is spent.
627
+ *
628
+ * `unknown-arity` says so for the opposite reason to the others: not because
629
+ * the script was supplied, but because the walker cannot tell which word the
630
+ * script is, and skipping the wrong one drops a real operand.
631
+ */
632
+ function suppliesScript(role: PatternFlagRole): boolean {
633
+ return (
634
+ role === "script" || role === "script-file" || role === "unknown-arity"
635
+ );
636
+ }
637
+
638
+ /**
639
+ * What a pending flag consumption made of the argument node that followed it.
640
+ *
641
+ * `consumed` is the flag's own verdict, not the walker's: a `suffix` flag
642
+ * declines a non-empty argument, which the walker then reads as an ordinary
643
+ * argument.
644
+ */
645
+ interface ConsumptionDischarge {
646
+ readonly consumed: boolean;
647
+ /** The path candidate the consumed argument contributes, if any. */
648
+ readonly token?: PathToken;
649
+ }
650
+
651
+ /** Apply a pending consumption to the argument text that follows its flag. */
652
+ function dischargePendingConsumption(
653
+ role: PatternFlagRole,
654
+ text: string,
655
+ effect: TokenEffect,
656
+ ): ConsumptionDischarge {
657
+ switch (role) {
658
+ case "script-file":
659
+ return { consumed: true, token: { token: text, effect } };
660
+ case "script":
661
+ case "value":
662
+ return { consumed: true };
663
+ case "suffix":
664
+ return { consumed: text === "" };
665
+ case "unknown-arity":
666
+ return { consumed: false };
667
+ }
668
+ }
669
+
473
670
  /**
474
671
  * Collect all argument tokens from a generic (non-pattern-first) command node,
475
672
  * skipping the command name and variable assignments.