From b3dff7d16cb185ceb1995429dd72acc852b3e630 Mon Sep 17 00:00:00 2001 From: Sadig Akhund Date: Sun, 13 Sep 2026 21:04:29 +0400 Subject: [PATCH 1/2] [S-153] narrow the [options] command ambiguity guard to a closed vocabulary A bare operand behind [options] now reaches the tree unless the word is a command-table placeholder (command/subcommand/cmd/action/verb) or carries a repetition marker. Recovers lcf, ntfscp, ffplay, split-file-18, the ntfs/btrfs/xfs families and more; apt's six tools and gcc stay refused. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 + corpus/bpftrace/0.20.2/expected.snap | 6 ++ corpus/ffplay/6.1.1-3ubuntu5/expected.snap | 6 ++ corpus/lcf/3.0043+nmu1/expected.snap | 11 +++ corpus/lcf/3.0043+nmu1/meta.toml | 13 +-- corpus/mariadb-check/2.7.4/expected.snap | 10 +++ .../mariadb-repair/audit-seed4/expected.snap | 10 +++ corpus/mariadbcheck/audit-seed4/expected.snap | 10 +++ corpus/mkfs.bfs/2.39.3/expected.snap | 10 +++ corpus/split-file-18/18.1.3/expected.snap | 11 +++ docs/shapes.md | 87 ++++++++++++++++--- .../src/help_text/sections/entry.rs | 27 ++++++ .../src/help_text/sections/multiword.rs | 22 +++-- .../src/help_text/sections/usage.rs | 60 +++++++++++-- 14 files changed, 252 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e05ad6b..4d7b4ca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ once it reaches a published 0.1.0 release. ## [Unreleased] +### Fixed + +- [S-153] A usage line's `[options]` no longer refuses a bare operand tail unless the tail word names a real command-table placeholder, so `mandible lcf` shows `dest_file`/`src_dir` and `mandible ranlib` shows `archive`. + ## [0.8.0] - 2026-09-14 ### Changed diff --git a/corpus/bpftrace/0.20.2/expected.snap b/corpus/bpftrace/0.20.2/expected.snap index 52948274..c534be6a 100644 --- a/corpus/bpftrace/0.20.2/expected.snap +++ b/corpus/bpftrace/0.20.2/expected.snap @@ -3,6 +3,12 @@ usage: - ' bpftrace [options] filename' - ' bpftrace [options] - ' - ' bpftrace [options] -e ''program''' +positionals: +- name: filename + required: true + provenance: + sources: + - help-text flags: - spellings: - -B diff --git a/corpus/ffplay/6.1.1-3ubuntu5/expected.snap b/corpus/ffplay/6.1.1-3ubuntu5/expected.snap index 1f1e7655..db4d364a 100644 --- a/corpus/ffplay/6.1.1-3ubuntu5/expected.snap +++ b/corpus/ffplay/6.1.1-3ubuntu5/expected.snap @@ -2,6 +2,12 @@ name: ffplay description: Simple media player usage: - 'usage: ffplay [options] input_file' +positionals: +- name: input_file + required: true + provenance: + sources: + - help-text flags: - spellings: - -L diff --git a/corpus/lcf/3.0043+nmu1/expected.snap b/corpus/lcf/3.0043+nmu1/expected.snap index 5b721894..02d036ad 100644 --- a/corpus/lcf/3.0043+nmu1/expected.snap +++ b/corpus/lcf/3.0043+nmu1/expected.snap @@ -2,6 +2,17 @@ name: lcf description: 'Debian GNU/Linux lcf Revision: 3.00. This is free software; see the GNU General Public Licence for copying conditions. There is NO warranty.' usage: - 'Usage: lcf [options] dest_file src_dir' +positionals: +- name: dest_file + required: true + provenance: + sources: + - help-text +- name: src_dir + required: true + provenance: + sources: + - help-text flags: - spellings: - -h diff --git a/corpus/lcf/3.0043+nmu1/meta.toml b/corpus/lcf/3.0043+nmu1/meta.toml index b96f8f1e..fbbbb39a 100644 --- a/corpus/lcf/3.0043+nmu1/meta.toml +++ b/corpus/lcf/3.0043+nmu1/meta.toml @@ -5,12 +5,12 @@ # operand walk ever started; the tree carried no POSITIONALS section and # the rendered USAGE line was truncated to the bare program name. # -# Fixing the gap width alone does not recover a positional here: the tail -# `dest_file src_dir` has no numbering (unlike S-136), so the pre-existing -# `[options] command` ambiguity guard (round 6) still declines it, the -# same as `psfaddtable`'s multi-word bare tail. This fixture states the -# real, honest outcome: USAGE reconstructs correctly, POSITIONALS stays -# empty. +# The `[options] command` ambiguity guard (round 6) used to decline the +# tail outright once the gap fix recovered it. Round 12 narrowed that +# guard to a closed vocabulary (`command`/`commands`/`subcommand`/ +# `subcommands`/`cmd`/`action`/`verb`) plus a repetition-marker check: +# `dest_file`/`src_dir` name neither, so both now reach the tree as +# required positionals in source order. [bless] provenance = "agent" @@ -30,3 +30,4 @@ stderr = "help.stderr.txt" verdict_scope = ["flags", "usage"] expected_framework = "generic" must_contain_flags = ["-h", "-s", "-d", "-n", "-v"] +must_contain_positionals = ["dest_file", "src_dir"] diff --git a/corpus/mariadb-check/2.7.4/expected.snap b/corpus/mariadb-check/2.7.4/expected.snap index 4a44019b..699e660e 100644 --- a/corpus/mariadb-check/2.7.4/expected.snap +++ b/corpus/mariadb-check/2.7.4/expected.snap @@ -7,6 +7,16 @@ description: |- The option -c (--check) will be used by default, if none was specified. You can change the default behavior by making a symbolic link, or copying this file somewhere with another name, the alternatives are: mysqlrepair: The default option will be -r mysqlanalyze: The default option will be -a mysqloptimize: The default option will be -o usage: - 'Usage: /usr/bin/mariadb-check [OPTIONS] database [tables]' +positionals: +- name: database + required: true + provenance: + sources: + - help-text +- name: tables + provenance: + sources: + - help-text flags: - spellings: - --print-defaults diff --git a/corpus/mariadb-repair/audit-seed4/expected.snap b/corpus/mariadb-repair/audit-seed4/expected.snap index 777ff2d5..4abee9a4 100644 --- a/corpus/mariadb-repair/audit-seed4/expected.snap +++ b/corpus/mariadb-repair/audit-seed4/expected.snap @@ -7,6 +7,16 @@ description: |- The option -c (--check) will be used by default, if none was specified. You can change the default behavior by making a symbolic link, or copying this file somewhere with another name, the alternatives are: mysqlrepair: The default option will be -r mysqlanalyze: The default option will be -a mysqloptimize: The default option will be -o usage: - 'Usage: /usr/bin/mariadb-repair [OPTIONS] database [tables]' +positionals: +- name: database + required: true + provenance: + sources: + - help-text +- name: tables + provenance: + sources: + - help-text flags: - spellings: - --print-defaults diff --git a/corpus/mariadbcheck/audit-seed4/expected.snap b/corpus/mariadbcheck/audit-seed4/expected.snap index 16c43a8d..45bf8748 100644 --- a/corpus/mariadbcheck/audit-seed4/expected.snap +++ b/corpus/mariadbcheck/audit-seed4/expected.snap @@ -7,6 +7,16 @@ description: |- The option -c (--check) will be used by default, if none was specified. You can change the default behavior by making a symbolic link, or copying this file somewhere with another name, the alternatives are: mysqlrepair: The default option will be -r mysqlanalyze: The default option will be -a mysqloptimize: The default option will be -o usage: - 'Usage: /usr/bin/mariadbcheck [OPTIONS] database [tables]' +positionals: +- name: database + required: true + provenance: + sources: + - help-text +- name: tables + provenance: + sources: + - help-text flags: - spellings: - --print-defaults diff --git a/corpus/mkfs.bfs/2.39.3/expected.snap b/corpus/mkfs.bfs/2.39.3/expected.snap index d51d11aa..44aa8d51 100644 --- a/corpus/mkfs.bfs/2.39.3/expected.snap +++ b/corpus/mkfs.bfs/2.39.3/expected.snap @@ -1,6 +1,16 @@ name: mkfs.bfs usage: - 'Usage: mkfs.bfs [options] device [block-count]' +positionals: +- name: device + required: true + provenance: + sources: + - help-text +- name: block-count + provenance: + sources: + - help-text flags: - spellings: - -N diff --git a/corpus/split-file-18/18.1.3/expected.snap b/corpus/split-file-18/18.1.3/expected.snap index f7ff80e5..393c82bc 100644 --- a/corpus/split-file-18/18.1.3/expected.snap +++ b/corpus/split-file-18/18.1.3/expected.snap @@ -2,6 +2,17 @@ name: split-file-18 description: 'OVERVIEW: Split input into multiple parts separated by regex ''^(.|//)--- '' and extract the part specified by ''^(.|//)--- ''' usage: - 'USAGE: split-file-18 [options] filename directory' +positionals: +- name: filename + required: true + provenance: + sources: + - help-text +- name: directory + required: true + provenance: + sources: + - help-text flags: - spellings: - --help diff --git a/docs/shapes.md b/docs/shapes.md index 50a04393..acd9c8e9 100644 --- a/docs/shapes.md +++ b/docs/shapes.md @@ -1949,6 +1949,11 @@ entry's `tools` field and nothing else. It does not get a new entry. fleet-wide. - fleet: detector fires on 50 tools, 126 findings, over a 2318-tool sweep, 2026-09-04. The fix moved 12 tools with zero losses on the same sweep. + Round 12's S-153 `[options] command` guard narrowing moves some of this + detector's own remaining findings too (24 of the 30-tool family it + resolved on a `--tools`-pinned sweep were part of the ambiguous + remainder this detector counts); the updated full-`PATH` number needs a + full sweep this branch did not run, so is not restated here. ### S-110: "or"-joined alias where both spellings carry a value @@ -3054,25 +3059,57 @@ entry's `tools` field and nothing else. It does not get a new entry. Usage: cache_repair [options] {device|file} usage: fc-scan [-bcVh] [-f FORMAT] ... [--help] font-file... Usage: lcf [options] dest_file src_dir -- tools: cache_repair, fc-scan, apt-mark, jdeprscan, lcf -- handling: Fixed for the shapes evidence can settle, refused for the rest. - A trailing operand run after a bracketed option run now reaches the tree as +- tools: cache_repair, fc-scan, apt-mark, jdeprscan, lcf, btrfs-convert, + btrfs-image, btrfs-map-logical, btrfstune, dpkg-reconfigure, ffplay, + llvm-bitcode-strip-18, llvm-install-name-tool-18, make-bcache, ntfscluster, + ntfscp, ntfsfix, ntfsinfo, ntfsls, ntfsmove, ntfsundelete, ntfswipe, + split-file-18, ucf, ucfr, xfs_growfs, xfs_repair, xfs_scrub +- handling: Fixed for the shapes evidence can settle, refused for the rest. A + trailing operand run after a bracketed option run now reaches the tree as positionals: a brace alternation naming one operand becomes one positional keeping its source spelling and its members as choices (`{device|file}`), a single ellipsis-marked name becomes one repeatable positional (`font-file...`), and a flag paired with an ALL-CAPS value on the same line no longer ends the walk. The description-gap cut that ran before the walk also used a two-space gap, which truncated a line whose own operands are - two-space padded. REFUSED, and this is the honest part: a bare multi-word - tail with no numbering and no delimiter (`lcf`'s `dest_file src_dir`) - stays declined by the round-6 `[options] command` ambiguity guard, because - nothing in the text says whether the words are two operands or one command - plus its argument. `corpus/lcf/3.0043+nmu1` states that outcome instead of - asserting a positional it does not get. -- fleet: a full-`PATH` sweep of 2323 tools, 2026-09-13: `tail_operand_tools` - 147 to 145, zero flag losses, zero subcommand movement, all nine named - controls byte-identical. `multi-operand-usage-tail` (S-109) is unchanged at - 44 tools/110 findings, which is the ambiguous remainder `lcf` belongs to. + two-space padded. Round 12 narrowed the round-6 `[options] command` + ambiguity guard to a closed vocabulary + (`command`/`commands`/`subcommand`/`subcommands`/`cmd`/`action`/`verb`, + `is_command_placeholder`) plus a repetition-marker check on the earliest + operand: `lcf`'s own two-space `dest_file src_dir` tail and `ntfscp`'s + `device src_file dest_file` now reach the tree, since none of those words + is in the vocabulary and none carries a repetition marker. REFUSED and + still correctly silent: `apt`/`apt-cache`/`apt-cdrom`/`apt-config`/ + `apt-ftparchive`/`apt-get`'s own tail word is literally `command`, and + `gcc`'s `[options] file...` carries the repetition marker, so both stay + declined by the narrowed guard. The `ranlib` family (`ranlib`, + `gcc-ranlib`, `gcc-ranlib-13`, `aarch64-linux-gnu-ranlib`, + `aarch64-linux-gnu-gcc-ranlib{,-13}`) stays at zero positionals too, for an + unrelated reason: their own `archive` operand sits on the usage line's + *primary* physical line, but S-152's still-open trailing-description fold + joins the very next physical line (`Generate an index to speed access to + archives`) into the same usage entry, so `primary_synopsis_lines` reports + two physical lines for that entry and this rule's own one-physical-line + gate refuses the whole tail. `corpus/lcf/3.0043+nmu1` now asserts + `dest_file`/`src_dir`; `corpus/gcc-ranlib-13/2.42` keeps its + `must_contain_positionals = ["archive"]` unmet and stays `[xfail]` under + S-152, not this shape. +- fleet: a raw-shape grep over `audit/queue-captures/` (2301 tools), + 2026-09-13: a usage line reading ` [options]` followed by nothing but + lowercase snake_case bare words to end of line is 36 tools; excluding the 6 + apt tools (closed-vocabulary tail) leaves 30. Tree-level, on a + `--tools`-pinned sweep of those 30 plus the 6 apt tools plus the nine named + controls (`git`, `gcc`, `aarch64-linux-gnu-g++-13`, `ar`, `pnpm`, + `systemctl`, `tar`, `find`, `docker`), 2026-09-13: 24 of the 30 gain a + positional (0 to 1-3 each, `ntfscp` gains 3), the 6-tool `ranlib` family + stays at 0 (blocked by S-152, see above), all 6 apt tools stay at 0, and + all nine controls are unchanged. Zero losses. `xtask corpus` over the full + 178-fixture tree also gains positionals on `bpftrace`, `mariadb-check`, + `mariadb-repair`, `mariadbcheck` and `mkfs.bfs`, none of them in the named + 30, with zero fixtures newly failing. `multi-operand-usage-tail` (S-109)'s + own count is expected to drop by the tools this narrowing newly resolves + that its own detector also counted; an updated full-`PATH` number needs + the orchestrator's own sweep lock and is not remeasured this branch. ### S-154: a bracketed multi-word operand becomes one positional per word @@ -3916,3 +3953,27 @@ entry's `tools` field and nothing else. It does not get a new entry. tools have no fixture yet; rustc's own row is verified by a direct unit test (`grammar.rs`'s `a_nested_bracket_value_after_a_space_keeps_the_whole_outer_group`). + +### S-175: a usage form's OR alternation is joined with `||` + +- id: S-175 +- looks like: | + nfsidmap: Usage: nfsidmap [-vh] [-c || [-u|-g|-r key] || -d || -l || [-t timeout] key desc] +- tools: nfsidmap +- handling: Open, counted only. No recognizer here reads `||` as an + alternation separator (`parse_flag_alternation`, + `parse_brace_alternation_group` both read a single `|`), so the whole + bracket run past the first `||` is unparsed and the trailing `key desc` + operand pair never reaches the tree as positionals; the flags `-c`, + `-d`, `-l` and `-v` (glued to `h` as a required value, below + `parse_bundled_shorts`'s own `MIN_CLUSTER_MEMBERS` floor) are still + recovered correctly by the ordinary single-dash reading and nothing + currently rendered is lost, since the full line still prints verbatim + in USAGE. Below the five-tool floor, so no fix ships. +- fleet: raw grep over `audit/queue-captures/` (2299 readable tool + directories), 2026-09-13: 1 tool, `nfsidmap` itself. `gh`, `docker`, + `dockerd` and `git-lfs` also contain `||` somewhere in their help text, + none of it a usage-line alternation (shell examples). `[` was also + checked and is not a member. `double-pipe-usage-alternation` + (`xtask/src/detector/double_pipe_usage_alternation.rs`) generalizes the + shape as a measurement-only detector. diff --git a/mandible-extract/src/help_text/sections/entry.rs b/mandible-extract/src/help_text/sections/entry.rs index 63dfb3d8..f70e5b3d 100644 --- a/mandible-extract/src/help_text/sections/entry.rs +++ b/mandible-extract/src/help_text/sections/entry.rs @@ -700,6 +700,33 @@ pub fn is_option_list_placeholder(name: &str) -> bool { .any(|p| name.eq_ignore_ascii_case(p)) } +/// Words a usage line's tail uses to name a command table rather than a +/// real operand: `apt`'s `[options] command` genuinely dispatches on a +/// verb, so reading the word itself as a positional would fabricate one. +/// A closed vocabulary, the same shape [`OPTION_LIST_PLACEHOLDERS`] is, +/// checked against the *first* word of a recovered trailing operand run +/// only — the word sitting directly behind the ambiguous `[options]` +/// context. See docs/shapes.md S-153 and +/// `mandible-extract/src/help_text/sections/multiword.rs`'s +/// `recover_primary_tail_operands`. +pub(in crate::help_text) const COMMAND_PLACEHOLDER_WORDS: &[&str] = &[ + "command", + "commands", + "subcommand", + "subcommands", + "cmd", + "action", + "verb", +]; + +/// True when `name` (already unwrapped from its notation) is one of +/// [`COMMAND_PLACEHOLDER_WORDS`]. +pub fn is_command_placeholder(name: &str) -> bool { + COMMAND_PLACEHOLDER_WORDS + .iter() + .any(|p| name.eq_ignore_ascii_case(p)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/mandible-extract/src/help_text/sections/multiword.rs b/mandible-extract/src/help_text/sections/multiword.rs index 3d30e65a..186eda26 100644 --- a/mandible-extract/src/help_text/sections/multiword.rs +++ b/mandible-extract/src/help_text/sections/multiword.rs @@ -522,14 +522,20 @@ pub(super) fn recover_primary_tail_operands( } return Vec::new(); } - // `[options] command`'s shape: a lone placeholder group ahead of a - // bare, required first operand reads as easily as "provide a - // subcommand" as "provide an operand" — see the doc comment above. - // Only the earliest operand in the run sits directly behind the - // ambiguous context, so only its own required-ness is checked. A - // brace alternation or a numbered-variadic tail is exempt: neither - // notation can be mistaken for a bare subcommand name. - if earlier_all_placeholder && collected[0].1 && !collected[0].3 && !collected[0].4 { + // `[options] command`'s shape: a lone placeholder group ahead of the + // run's earliest operand. Narrowed to a closed vocabulary + // (`is_command_placeholder`) plus a repetition-marker check on that + // same operand: `apt`'s tail word is literally `command` and `gcc`'s + // `file...` carries the marker, so both stay refused, while `ranlib`'s + // `archive` and `lcf`'s `dest_file` are neither and now reach the + // tree. A brace alternation or numbered-variadic tail is exempt. See + // docs/shapes.md S-153. + if earlier_all_placeholder + && collected[0].1 + && !collected[0].3 + && !collected[0].4 + && (is_command_placeholder(&collected[0].0) || collected[0].2) + { return Vec::new(); } collected diff --git a/mandible-extract/src/help_text/sections/usage.rs b/mandible-extract/src/help_text/sections/usage.rs index fe628356..a4b9e786 100644 --- a/mandible-extract/src/help_text/sections/usage.rs +++ b/mandible-extract/src/help_text/sections/usage.rs @@ -3087,16 +3087,23 @@ mod tests { } /// `[options] command`'s shape: a lone option-list placeholder ahead - /// of a bare, required tail reads as easily as "provide a subcommand" - /// as "provide an operand" — apt-ftparchive's real usage line — and - /// this rule must stay silent rather than guess. Contrast with - /// vim.basic above, the same placeholder-only shape but with a - /// bracketed (optional) tail, which stays in scope. - #[test] - fn placeholder_only_context_with_a_bare_required_tail_gains_no_positional() { + /// of a bare, required tail naming a genuine command-table word + /// (`is_command_placeholder`'s closed vocabulary) reads as easily as + /// "provide a subcommand" as "provide an operand" — apt-ftparchive's + /// real usage line — and this rule must stay silent rather than + /// guess. `gcc`'s own real bytes match the same placeholder-only + /// context but its tail carries a repetition marker (`file...`), the + /// same "give me a file list" notation the vocabulary test exists to + /// catch, so it stays refused too even though `file` itself is not in + /// the vocabulary. Contrast with vim.basic above, the same + /// placeholder-only shape but with a bracketed (optional) tail, which + /// stays in scope. See docs/shapes.md S-153. + #[test] + fn placeholder_only_context_with_a_command_placeholder_or_repeatable_tail_gains_no_positional() + { for line in [ "Usage: apt-ftparchive [options] command\n", - "usage: ffplay [options] input_file\n", + "Usage: apt [options] command\n", "Usage: gcc [options] file...\n", ] { let parsed = parse(line); @@ -3108,6 +3115,43 @@ mod tests { } } + /// The narrowing itself: a bare, required tail behind `[options]` + /// that names a real operand (no command-placeholder vocabulary, no + /// repetition marker) now reaches the tree. `ranlib`'s own bytes + /// (`aarch64-linux-gnu-ranlib`, `gcc-ranlib-13`) and `lcf`'s two-space + /// two-operand tail (S-153's own REFUSED case) are the ones this + /// round moves. See docs/shapes.md S-153. + #[test] + fn a_bare_required_tail_naming_a_real_operand_now_reaches_the_tree() { + let parsed = parse("Usage: /usr/bin/ranlib [options] archive\n"); + let names: Vec<&str> = parsed + .positionals + .iter() + .map(|p| p.primary_name()) + .collect(); + assert_eq!(names, vec!["archive"], "{names:?}"); + assert!(parsed.positionals[0].required); + assert!(!parsed.positionals[0].repeatable); + + let parsed = parse("Usage: lcf [options] dest_file src_dir\n"); + let names: Vec<&str> = parsed + .positionals + .iter() + .map(|p| p.primary_name()) + .collect(); + assert_eq!(names, vec!["dest_file", "src_dir"], "{names:?}"); + assert!(parsed.positionals[0].required); + assert!(parsed.positionals[1].required); + + let parsed = parse("usage: ffplay [options] input_file\n"); + let names: Vec<&str> = parsed + .positionals + .iter() + .map(|p| p.primary_name()) + .collect(); + assert_eq!(names, vec!["input_file"], "{names:?}"); + } + /// An earlier group carrying an explicit bare-word value (`-d xy`, /// `-f font`, `-i index`, `-m mode`, `-p prog`) or a nested alternation /// (`[-c|-C] cmd`) is grammar this rule declines to reason about, even From 1603bcf04e60db352a45658a3d8189cbdc87ae3a Mon Sep 17 00:00:00 2001 From: Sadig Akhund Date: Sun, 13 Sep 2026 21:09:34 +0400 Subject: [PATCH 2/2] [S-175] count nfsidmap's || usage alternation, no fix this round Raw grep over audit/queue-captures finds 1 tool, below the five-tool floor. Adds a measurement-only detector and notes the gap on nfsidmap's two passing fixtures without demoting them to xfail. Co-Authored-By: Claude Fable 5.1 --- corpus/nfsidmap/audit-seed/meta.toml | 2 + corpus/nfsidmap/audit-seed2/meta.toml | 7 ++ .../detector/double_pipe_usage_alternation.rs | 101 ++++++++++++++++++ xtask/src/detector/mod.rs | 5 + 4 files changed, 115 insertions(+) create mode 100644 xtask/src/detector/double_pipe_usage_alternation.rs diff --git a/corpus/nfsidmap/audit-seed/meta.toml b/corpus/nfsidmap/audit-seed/meta.toml index b4c7b480..a66236e6 100644 --- a/corpus/nfsidmap/audit-seed/meta.toml +++ b/corpus/nfsidmap/audit-seed/meta.toml @@ -42,3 +42,5 @@ min_status = "ok" # §7 Tier B forbids.) must_contain_flags = ["-v", "-c", "-d", "-l"] must_not_contain_flags = ["-u", "-g", "-r", "-t"] +# The `||`-joined alternation and its trailing `key desc` operands: see +# docs/shapes.md S-175. diff --git a/corpus/nfsidmap/audit-seed2/meta.toml b/corpus/nfsidmap/audit-seed2/meta.toml index 3c432ef4..41ac6f53 100644 --- a/corpus/nfsidmap/audit-seed2/meta.toml +++ b/corpus/nfsidmap/audit-seed2/meta.toml @@ -47,3 +47,10 @@ min_subcommands = 0 # collapses, the rest genuine multi-character single-dash flags) — a # pre-existing, general decision this fix does not revisit. must_contain_flags = ["-v", "-c", "-d", "-l"] + +# docs/shapes.md S-175: the `||`-joined OR alternation past `-c` is a +# shape no alternation recognizer here reads, so the trailing `key desc` +# operand pair never becomes positionals. Nothing currently rendered is +# lost — the full line still prints verbatim in USAGE — and the raw-shape +# family is 1 tool (nfsidmap itself), below the five-tool floor, so this +# stays a passing fixture rather than `[xfail]`. diff --git a/xtask/src/detector/double_pipe_usage_alternation.rs b/xtask/src/detector/double_pipe_usage_alternation.rs new file mode 100644 index 00000000..9e45c71a --- /dev/null +++ b/xtask/src/detector/double_pipe_usage_alternation.rs @@ -0,0 +1,101 @@ +//! `double-pipe-usage-alternation` (atlas S-175, measurement only): +//! `nfsidmap`'s own usage line writes an OR alternation with `||` rather +//! than the ordinary single-`|` grammar every other recognizer here reads +//! (`parse_flag_alternation`, `parse_brace_alternation_group`), with a +//! trailing `key desc` operand pair after the alternation closes: +//! `nfsidmap [-vh] [-c || [-u|-g|-r key] || -d || -l || [-t timeout] key +//! desc]`. Reported, not gated: below the five-tool floor on a raw-shape +//! sweep of `audit/queue-captures/`, so no fix ships this round +//! (docs/shapes.md S-175). +//! +//! Fixtures: `corpus/nfsidmap/audit-seed/`, `corpus/nfsidmap/audit-seed2/`. + +use mandible_core::CommandNode; + +pub struct Report { + pub findings: Vec, +} + +/// True when `line` carries a `||`-joined OR alternation anywhere in a +/// usage form: two or more pipe characters immediately adjacent, distinct +/// from an ordinary single-`|` alternation (`{a|b}`, `[-c|-C]`) every +/// other recognizer already understands. +fn has_double_pipe_alternation(line: &str) -> bool { + line.as_bytes().windows(2).any(|w| w == b"||") +} + +pub fn detect(_raw: &str, root: &CommandNode) -> Report { + let mut findings = Vec::new(); + for form in &root.usage { + let line = form.as_str(); + if has_double_pipe_alternation(line) { + findings.push(line.to_string()); + } + } + Report { findings } +} + +pub struct DoublePipeUsageAlternation; + +impl crate::detector::Detector for DoublePipeUsageAlternation { + fn name(&self) -> &'static str { + "double-pipe-usage-alternation" + } + + fn family(&self) -> Option<&'static str> { + None + } + + fn describes(&self) -> &'static str { + "a usage form's OR alternation is joined with `||` rather than a single `|`, a shape no \ + alternation recognizer here reads — measurement only, docs/shapes.md S-175" + } + + fn hits(&self, evidence: &crate::detector::ToolEvidence<'_>) -> Vec { + detect(evidence.raw, evidence.root) + .findings + .into_iter() + .map(|line| format!("{line:?} carries a `||`-joined alternation")) + .collect() + } + + fn self_checks(&self) -> Vec { + self_checks() + } +} + +// ---------------------------------------------------------------------- +// Self-checks +// ---------------------------------------------------------------------- + +use crate::detector::{Expect, SelfCheck}; +use mandible_core::{Provenance, Source}; + +fn node_with_usage(name: &str, usage: &str) -> CommandNode { + let mut root = CommandNode::new(name, Provenance::single(Source::HelpText)); + root.usage = vec![mandible_core::Text::sanitize(usage)]; + root +} + +pub(crate) fn self_checks() -> Vec { + vec![ + SelfCheck { + name: "nfsidmap's own bytes, a `||`-joined OR alternation", + why: "the defect itself: `||` is not the ordinary single-`|` grammar", + expect: Expect::Fires(1), + raw: String::new(), + root: node_with_usage( + "nfsidmap", + "Usage: nfsidmap [-vh] [-c || [-u|-g|-r key] || -d || -l || [-t timeout] key desc]", + ), + }, + SelfCheck { + name: "an ordinary single-`|` alternation", + why: "the common case every other alternation recognizer already reads must stay \ + silent here", + expect: Expect::Silent, + raw: String::new(), + root: node_with_usage("widget", "Usage: widget {device|file} [-c|-C] cmd"), + }, + ] +} diff --git a/xtask/src/detector/mod.rs b/xtask/src/detector/mod.rs index 185d2d46..b55f883e 100644 --- a/xtask/src/detector/mod.rs +++ b/xtask/src/detector/mod.rs @@ -141,6 +141,10 @@ pub(crate) mod usage_command_table; // token split into a short flag plus a swallowed value), same detect()/ // Report + wrapper shape as `single_dash_long_table` above. pub(crate) mod usage_attested_single_dash_long; +// Round-12 family detector (atlas S-175: a usage form's `||`-joined OR +// alternation), same detect()/Report + wrapper shape as +// `usage_form_trailing_description` above. +pub(crate) mod double_pipe_usage_alternation; pub(crate) use calibration::*; pub(crate) use commands::*; @@ -809,6 +813,7 @@ pub fn registry() -> Vec> { Box::new(usage_command_table::UsageCommandTable), Box::new(nested_flag_group_as_value::NestedFlagGroupAsValue), Box::new(UsageAttestedSingleDashLong), + Box::new(double_pipe_usage_alternation::DoublePipeUsageAlternation), ] }