diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e05ad6..6886dc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ once it reaches a published 0.1.0 release. ## [Unreleased] +### Fixed + +- [S-148] `pkcheck`'s `-d, --details=KEY VALUE` row keeps `KEY VALUE` as the value name instead of just `KEY` (`mandible pkcheck`). +- [S-174] A nested bracket group's own trailing text after its outer close now glues onto the value name too (`mandible cpio`'s `-I`/`-O`, `lsusb`'s `-s`, `rustc`'s `-l`). + ## [0.8.0] - 2026-09-14 ### Changed diff --git a/corpus/pkcheck/124/expected.snap b/corpus/pkcheck/124/expected.snap index a0da809..00f752b 100644 --- a/corpus/pkcheck/124/expected.snap +++ b/corpus/pkcheck/124/expected.snap @@ -31,7 +31,7 @@ flags: - spellings: - -d - --details - value_name: KEY + value_name: KEY VALUE value_kind: Required group: 'Application Options:' description: Add (KEY, VALUE) to information about the action diff --git a/corpus/rustc/1.97.1/expected.snap b/corpus/rustc/1.97.1/expected.snap index 368fc0e..7aa6834 100644 --- a/corpus/rustc/1.97.1/expected.snap +++ b/corpus/rustc/1.97.1/expected.snap @@ -41,8 +41,8 @@ flags: - help-text - spellings: - -l - value_name: '[[:]=]' - value_kind: Optional + value_name: '[[:]=][:]' + value_kind: Required description: 'Link the generated crate(s) to the specified native library NAME. The optional KIND can be one of (default: dylib). Optional comma separated MODIFIERS may be specified each with a prefix of either ''+'' to enable or ''-'' to disable.' provenance: sources: diff --git a/docs/shapes.md b/docs/shapes.md index 50a0439..50aa48d 100644 --- a/docs/shapes.md +++ b/docs/shapes.md @@ -2419,7 +2419,9 @@ entry's `tools` field and nothing else. It does not get a new entry. `must_attach_choices` assertion now passes with a zero-loss sweep and the nine named controls held byte-identical. The fixture stays `[xfail]` for S-134's separate, still-open `-c`/`--copyright` defect; - issue #142 stays open. + issue #142 stays open. Round 12: the maintainer verified `-t l` binds + the same way `-tl` does and accepted the current rendering; no parser + change. A full-`PATH` sweep of icupkg alone reads its own flag count as `19 -> 17 (-2)`, which lands on the loss side of a sweep-diff by number @@ -2892,16 +2894,33 @@ entry's `tools` field and nothing else. It does not get a new entry. - looks like: | --annotate WHAT KEY VALUE WHAT KEY VALUE WHAT KEY VALUE Add annotation (may be used several times) -- tools: gdbus-codegen -- handling: Open defect, the option-table sibling of S-131. `gdbus-codegen`'s - block-derived option-table reader wins over the usage-derived one for a - flag documented in both places (`help_text/sections/mod.rs`'s "let the - described version win"), and its own multi-word metavar row keeps only - the first word, `WHAT`, rather than the whole repeated run. +- tools: gdbus-codegen, pkcheck +- handling: `gdbus-codegen`'s own shape stays an open defect, the option-table + sibling of S-131: its block-derived option-table reader wins over the + usage-derived one for a flag documented in both places + (`help_text/sections/mod.rs`'s "let the described version win"), and its + own multi-word metavar row keeps only the first word, `WHAT`, rather + than the whole repeated run. + + Round 12: fixed a narrower member of the same family, `pkcheck`'s + `-d, --details=KEY VALUE`, a glued `=`-value spec written as two + space-separated ALL-CAPS words where only the first survived + (`take_glued_second_metavar_word`, `mandible-extract/src/help_text/ + grammar.rs`). Gated on both words reading as plain ALL-CAPS metavars + and the second word being followed by a real column gap (2+ spaces, a + tab, or the fragment's own end) — the same column-gap discipline + S-157's `spaced_bare_word_value` uses, so a two-word cut can never eat + the first word of a description that starts in the value column + (`qemu-arm64-static`'s own ragged three-column table, whose own + handling field records the same refusal for a different shape). - fleet: `option-table-multiword-value-name` - (`xtask/src/detector/option_table_multiword_value_name.rs`) reads 1 - tool / 1 finding on a full-`PATH` sweep of 2269 tools, 2026-09-07. Below - the five-tool bar; not fixed this round. + (`xtask/src/detector/option_table_multiword_value_name.rs`) still reads + 1 tool / 1 finding fleet-wide for `gdbus-codegen`'s own shape; below + the five-tool bar, not fixed this round. `pkcheck`'s glued `=`-value + sub-shape is a raw-shape upper bound of 1 tool over the 2301 frozen + `audit/queue-captures`, also below the five-tool bar; fixed anyway as + a gated exception (docs/design.md §16), narrowly scoped and zero-loss + on the tools it was checked against. 2026-09-13. ### S-149: a centered ALL-CAPS group label sets a bare-word block's baseline @@ -3313,7 +3332,12 @@ entry's `tools` field and nothing else. It does not get a new entry. table test (no `--long` row anywhere in the document), and `pod2text`'s table is full of `--long` rows, so the same evidence that protects `gcc`, `clang` and `ld` from S-145 refuses `pod2text` here. That is the same gate - that refuses `fuser`'s `-SIGNAL`. + that refuses `fuser`'s `-SIGNAL`. Round 12: reconsidered and left refused. + `pod2text`'s own usage line (`[--stderr] [-w width] [input [output + ...]]`) spells `-w`, not `-width`, so S-172's own route (independent + usage-line attestation of the exact reconstructed single-dash-long + spelling) finds no evidence here either. Raw-shape count stays 1 tool, + pod2text. - fleet: `comma-swallowed-alias` (`xtask/src/detector/comma_swallowed_alias.rs`) reads 10 tools/31 findings post-fix on a full-`PATH` sweep of 2323 tools, 2026-09-13, which is the @@ -3898,6 +3922,19 @@ entry's `tools` field and nothing else. It does not get a new entry. `[[USER@]HOST:]`; `lsusb` (`-s`) keeps `[devnum]` after `[[bus]:]`; `rustc` (`-l`) keeps `[:]` after `[[:]=]`. Fixing the trailing part too is a further, separate rule. + + Round 12: that further rule, `glued_bracket_group_residue` + (`mandible-extract/src/help_text/grammar.rs`), fixes all three. It + glues whatever sits directly on the outer close with no separator: a + required angle placeholder, itself optionally followed by one more + glued bracket group (`rustc`'s `[:]`, value becomes + Required); a bare further bracket group (`lsusb`'s `[devnum]`); or an + ALL-CAPS-only glued word (`cpio`'s `FILE-NAME`), gated to carry no + lowercase letter so a description that happened to start immediately + after the bracket is never mistaken for a fourth glued group. Raw-shape + count for the residue alone: the three tools this entry already named; + no wider grep was run this round beyond the tools already on record + here. - fleet: `nested-bracket-value-general` (the depth-tracked matcher itself; no separate xtask detector module this round) moved 7 tools on a full-`PATH` sweep of 2323 tools, zero losses, 2026-09-13 (`r11/p1value.txt`, @@ -3916,3 +3953,42 @@ 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`). + + Round 12: the trailing-residue rule above moved the same three named + tools, checked by direct unit test + (`a_nested_bracket_value_after_a_space_keeps_the_whole_outer_group`, + `a_second_bare_bracket_group_glues_onto_the_nested_pair`, + `an_all_caps_bare_word_glues_onto_the_nested_pair`); `corpus/rustc/1.97.1` + and `corpus/fzf/0.44.1` are zero-loss and `must_value_name["--listen"]` + still passes. `cpio` and `lsusb` have no fixture. 3 tools is a raw-shape + count, not a full-`PATH` sweep; below the five-tool bar on its own but + ships alongside a family that already cleared it, docs/design.md §16. + +### S-178: a genuinely two-flag repeated-letter coincidence loses its value + +- id: S-178 +- looks like: | + -c turns off key-click + -cc int default color visual class +- tools: Xvfb +- handling: Open defect, declined this round. `Xvfb` documents an unrelated + bare `-c` boolean ("turns off key-click") on its own row, which is exactly + the evidence `repair_repeated_character_flags` (S-035) requires to treat + `-cc`'s swallowed value `"c"` as a repeated-verbosity spelling + (`documents_bare_boolean`); it rewrites `-cc` to a valueless boolean + spelling and `int`, the flag's real value, is dropped outright, not even + left in the description. `-c` and `-cc` are two unrelated flags that + happen to share the repeated-letter shape by coincidence, the exact + ambiguity S-035's own doc comment names as unresolvable by token shape + alone (`lessecho`'s genuine `-nn`). `repair_single_dash_long_options` + never gets a turn either: its own condition 6 defers any + `value_repeats_short` shape to the repeated-character repair. A safe fix + needs a second, independent signal that `-cc int` is a real value-taking + flag rather than a verbosity level — not found this round. `-deferglyphs + [none|all|16]` on the same table (raised alongside `-cc` this round) is + not a defect: it already reads its bracket value correctly, recovered by + S-145's own round-11 placeholder-gap widening, verified against + `mandible Xvfb`. +- fleet: Not measured fleet-wide; a raw-shape count of 1 tool, Xvfb, is the + only evidence this round. Below the five-tool bar. `corpus/Xvfb/audit-seed` + does not assert `-cc`, so leaving this open costs no fixture regression. diff --git a/mandible-extract/src/help_text/grammar.rs b/mandible-extract/src/help_text/grammar.rs index c5dfc1c..d767c0d 100644 --- a/mandible-extract/src/help_text/grammar.rs +++ b/mandible-extract/src/help_text/grammar.rs @@ -829,6 +829,43 @@ fn take_glued_bracket_group(s: &str) -> Option<(&str, &str)> { (close > 0).then(|| (&s[..close + 2], &rest[close + 1..])) } +/// [S-174] Whatever glues onto a matched nested-bracket value spec's own +/// outer close, with no separator: a required angle placeholder, itself +/// optionally followed by one further glued bracket group (rustc's `-l +/// [[:]=][:]`); a bare bracket group +/// (lsusb's `-s [[bus]:][devnum]`); or an ALL-CAPS glued word (cpio's +/// `-I [[USER@]HOST:]FILE-NAME`). Same S-097 ruling as the two helpers +/// above. The bare-word arm requires no lowercase letter, so a +/// description glued with no space is never mistaken for a value group. +/// Returns the text to append, whether the value becomes required, and +/// the remaining tail; `None` when nothing is glued on. +fn glued_bracket_group_residue(s: &str) -> Option<(String, bool, &str)> { + if let Some((angle, after)) = take_glued_angle_group(s) { + let mut suffix = angle.to_string(); + let after = if let Some((bracket, after2)) = take_glued_bracket_group(after) { + suffix.push_str(bracket); + after2 + } else { + after + }; + return Some((suffix, true, after)); + } + if let Some((bracket, after)) = take_glued_bracket_group(s) { + return Some((bracket.to_string(), false, after)); + } + let is_name_char = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_'; + if s.starts_with(is_name_char) { + let end = s.find(|c: char| !is_name_char(c)).unwrap_or(s.len()); + let word = &s[..end]; + if word.chars().any(|c| c.is_ascii_uppercase()) + && !word.chars().any(|c| c.is_ascii_lowercase()) + { + return Some((word.to_string(), true, &s[end..])); + } + } + None +} + /// [S-174] Generalizes [`nested_bracket_content`] (S-119) to the same /// one-nested-pair shape when the outer group carries more text *after* /// the inner pair closes, before its own close (`fzf`'s @@ -954,6 +991,14 @@ fn try_value(input: &str) -> Option<(String, ValueKind, &str)> { let after_outer = after_inner .strip_prefix(']') .expect("nested_bracket_content only returns a prefix a `]` follows"); + if let Some((suffix, required, after)) = glued_bracket_group_residue(after_outer) { + let kind = if required { + ValueKind::Required + } else { + ValueKind::Optional + }; + return Some((format!("[{content}]{suffix}"), kind, after)); + } return Some((format!("[{content}]"), ValueKind::Optional, after_outer)); } // [S-174] The same one-nested-pair shape, but with more of the @@ -963,6 +1008,17 @@ fn try_value(input: &str) -> Option<(String, ValueKind, &str)> { let after_outer = after_inner .strip_prefix(']') .expect("nested_bracket_content_general only returns a prefix a `]` follows"); + // [S-174 continuation] Trailing text after the outer close + // itself: `rustc`'s `[:]`, `lsusb`'s + // `[devnum]`, `cpio`'s bare `FILE-NAME`. + if let Some((suffix, required, after)) = glued_bracket_group_residue(after_outer) { + let kind = if required { + ValueKind::Required + } else { + ValueKind::Optional + }; + return Some((format!("[{content}]{suffix}"), kind, after)); + } return Some((format!("[{content}]"), ValueKind::Optional, after_outer)); } let name = value_inside_brackets(&mut s).ok()?; @@ -1024,6 +1080,9 @@ fn try_value(input: &str) -> Option<(String, ValueKind, &str)> { // `=VALUE` if equals_sign(&mut s).is_ok() { let (name, tail) = take_rest_value_token(s); + if let Some((combined, consumed)) = take_glued_second_metavar_word(&name, tail) { + return Some((combined, ValueKind::Required, &tail[consumed..])); + } return Some((name, ValueKind::Required, tail)); } @@ -1073,6 +1132,56 @@ fn take_rest_value_token(input: &str) -> (String, &str) { (s[..end].to_string(), &s[end..]) } +/// True when `w` is a plain, all-uppercase metavar word: letter-led, +/// alphanumeric/`-`/`_`, at least one letter, every letter uppercase — +/// the argparse metavar convention `pkcheck`'s option table writes. +/// Twin of `xtask`'s own +/// `option_table_multiword_value_name::plain_metavar_word`. See +/// docs/shapes.md S-148. +fn plain_metavar_word(w: &str) -> bool { + !w.is_empty() + && w.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) + && w.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + && w.chars() + .filter(|c| c.is_ascii_alphabetic()) + .all(|c| c.is_ascii_uppercase()) +} + +/// A second ALL-CAPS metavar word glued one space after an `=`-value's +/// first word, itself followed by a real column gap (`pkcheck`'s +/// `--details=KEY VALUE`, atlas S-148): admitted only when both words +/// are ALL-CAPS ([`plain_metavar_word`]) and the second is followed by +/// 2+ spaces, a tab, or the fragment's own end — the same column-gap +/// discipline [`spaced_bare_word_value`]-style repairs use (S-157), so a +/// description that happens to open with an uppercase word right after +/// the first value word (`qemu-arm64-static`'s own ragged three-column +/// table) is never donated a second value word. `name` is the value's +/// first word, already taken; `tail` is the text right after it. +/// Returns the combined `"KEY VALUE"` name and how many bytes of `tail` +/// it consumed. +fn take_glued_second_metavar_word(name: &str, tail: &str) -> Option<(String, usize)> { + if !plain_metavar_word(name) { + return None; + } + let after_space = tail.strip_prefix(' ')?; + if after_space.starts_with([' ', '\t']) { + return None; + } + let second_len = after_space + .find(char::is_whitespace) + .unwrap_or(after_space.len()); + let second = &after_space[..second_len]; + if !plain_metavar_word(second) { + return None; + } + let rest = &after_space[second_len..]; + if !rest.is_empty() && !rest.starts_with('\t') && !rest.starts_with(" ") { + return None; + } + Some((format!("{name} {second}"), 1 + second_len)) +} + /// True if `input` starts with something recognizable as a flag — used /// by the layout parser to decide whether a line begins a new flag entry. /// The `-` prefix is the dominant case; the second arm is a @@ -1710,14 +1819,54 @@ mod tests { /// `rustc`'s own `-l [[:]=][:]` row /// (`audit/queue-captures/rustc/0.stdout`): the same S-174 shape, one /// space before the bracket rather than glued. The outer group - /// (`[[:]=]`) survives whole; the trailing - /// `[:]` is a second, later spec fragment and out of - /// this rule's scope. + /// (`[[:]=]`) survives whole, and the trailing + /// `[:]` glues directly onto its close with no + /// separator, so it joins the same value name (S-174's own "further, + /// separate rule", `glued_bracket_group_residue`) and the value + /// becomes required once the angle placeholder joins it. #[test] fn a_nested_bracket_value_after_a_space_keeps_the_whole_outer_group() { let spec = parse_flag_spec("-l [[:]=][:]"); assert_eq!(spec.short(), Some('l')); - assert_eq!(spec.value_name.as_deref(), Some("[[:]=]")); + assert_eq!( + spec.value_name.as_deref(), + Some("[[:]=][:]") + ); + assert_eq!(spec.value_kind, ValueKind::Required); + } + + /// `lsusb`'s own `-s [[bus]:][devnum]` row: a second, bare bracket + /// group glues directly onto the nested pair's own close, with no + /// separator (`audit/queue-captures/lsusb/0.stdout`). See + /// docs/shapes.md S-174. + #[test] + fn a_second_bare_bracket_group_glues_onto_the_nested_pair() { + let spec = parse_flag_spec("-s [[bus]:][devnum]"); + assert_eq!(spec.short(), Some('s')); + assert_eq!(spec.value_name.as_deref(), Some("[[bus]:][devnum]")); + assert_eq!(spec.value_kind, ValueKind::Optional); + } + + /// `cpio`'s own `-I [[USER@]HOST:]FILE-NAME` row: an ALL-CAPS bare + /// word glues directly onto the nested pair's own close, with no + /// bracket and no separator (`audit/queue-captures/cpio/0.stdout`). + /// See docs/shapes.md S-174. + #[test] + fn an_all_caps_bare_word_glues_onto_the_nested_pair() { + let spec = parse_flag_spec("-I [[USER@]HOST:]FILE-NAME"); + assert_eq!(spec.short(), Some('I')); + assert_eq!(spec.value_name.as_deref(), Some("[[USER@]HOST:]FILE-NAME")); + assert_eq!(spec.value_kind, ValueKind::Required); + } + + /// A lowercase word directly after the nested pair's close is never + /// glued on: nothing in the fleet writes a value spec that way, and + /// admitting it would risk mistaking a description's own leading + /// word for a fourth glued group. + #[test] + fn does_not_glue_a_lowercase_word_onto_the_nested_pair() { + let spec = parse_flag_spec("-I [[USER@]HOST:]archive filename to use"); + assert_eq!(spec.value_name.as_deref(), Some("[[USER@]HOST:]")); assert_eq!(spec.value_kind, ValueKind::Optional); } @@ -1752,6 +1901,40 @@ mod tests { assert!(!spec.fully_consumed); } + /// `pkcheck`'s own `-d, --details=KEY VALUE` row + /// (`audit/queue-captures/pkcheck/0.stdout`): a glued `=`-value + /// written as two space-separated ALL-CAPS words. See docs/shapes.md + /// S-148. + #[test] + fn a_glued_value_keeps_a_second_all_caps_metavar_word() { + let spec = parse_flag_spec( + "-d, --details=KEY VALUE Add (KEY, VALUE) to information about the action", + ); + assert_eq!(spec.short(), Some('d')); + assert_eq!(spec.long(), Some("details")); + assert_eq!(spec.value_name.as_deref(), Some("KEY VALUE")); + assert_eq!(spec.value_kind, ValueKind::Required); + } + + /// The safety gate `qemu-arm64-static`'s own ragged three-column + /// table names (docs/shapes.md S-157's own handling field): an + /// ALL-CAPS second word not followed by a real column gap is a + /// ragged column's own header, not a second value word, and must + /// stay in the description. + #[test] + fn does_not_glue_a_second_word_without_a_real_column_gap() { + let spec = parse_flag_spec("--dfilter=RANGE COLUMN Env-variable QEMU_DFILTER"); + assert_eq!(spec.value_name.as_deref(), Some("RANGE")); + } + + /// The other half of the same gate: a lowercase second word is + /// ordinary prose, never a second metavar. + #[test] + fn does_not_glue_a_lowercase_second_word() { + let spec = parse_flag_spec("--foo=BAR baz qux"); + assert_eq!(spec.value_name.as_deref(), Some("BAR")); + } + #[test] fn parses_multiple_long_aliases_keeping_first() { let spec = parse_flag_spec("-A, --catenate, --concatenate");