Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ once it reaches a published 0.1.0 release.
- `xtask sweep-diff` now reports subcommand-count gains and losses per tool, separately from flags, so an invented subcommand row no longer passes as a clean sweep.
- `xtask detector calibrate` now prints `NOT EVALUABLE` instead of `DOES NOT PASS` for a detector whose family has zero labelled members in the audit seed, so a legitimate gap in the sample is no longer reported as a failing detector.
- [S-149] A bare-word block opening on a centered ALL-CAPS group label no longer ends before reading a single real row beneath it (`mandible fail2ban-client`).
- [S-173] A description's own `* NAME - VALUE` bullet list no longer splits at its inline dash into two lines per bullet (`mandible rustc`'s `--emit`).
- [S-174] A value spec with one bracket nested inside another keeps its outer group now even when more of it follows the inner pair's close, generalizing S-119 (`mandible fzf`'s `--listen`, `cpio`, `journalctl`, `lsusb`, `node`/`nodejs`, `rustc`'s `-l`).
- [S-133] `icupkg`'s `-tl or --type l`/`-tb or --type b`/`-te or --type e` rows fold into one `-t`/`--type` flag with `choices`, each keeping its own description (`mandible icupkg`).
- [S-103, S-104] A command table's short-alias-comma rows (`i, install`) and the descriptions wrapping off their neighbors no longer drop the whole table or invent commands from wrapped text (`mandible pnpm`).
- [S-117] A single-dash long option with an uppercase flag letter and a spaced value keeps its full name and value now, instead of truncating to its first letter with the value swallowed (`mandible g++`'s `-Xassembler`, `-Xpreprocessor`, `-Xlinker`).
- [S-130] A docopt bracket row's own trailing choice list no longer repeats as the flag's value name too, so `mandible pvdisplay`'s `--configreport` and `--driverloaded` show their choices once instead of twice.
Expand Down
2 changes: 1 addition & 1 deletion corpus/fzf/0.44.1/expected.snap
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,7 @@ flags:
- help-text
- spellings:
- --listen
value_name: '[ADDR:'
value_name: '[[ADDR:]PORT]'
value_kind: Optional
group: Scripting
description: Start HTTP server to receive actions (POST /) (To allow remote process execution, use --listen-unsafe)
Expand Down
9 changes: 9 additions & 0 deletions corpus/fzf/0.44.1/meta.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,16 @@ must_contain_flags = [
"--extended", "--multi", "--height", "--history", "--preview", "--query",
"+i", "+s", "--scheme",
]

[contract.must_flag_group]
"+i" = "Search"
"+s" = "Search"
"--scheme" = "Search"

# [S-174, round 11] `--listen[=[ADDR:]PORT]`'s own value spec: a bracket
# nested inside another with more of the outer group after the inner pair
# closes. The bracket matcher used to stop at the inner close, dropping
# the rest and the closing bracket (`value_name` came out `[ADDR:`, missing
# `]PORT]`). Locks in the whole spec so a regression here fails loudly.
[contract.must_value_name]
"--listen" = "[[ADDR:]PORT]"
2 changes: 1 addition & 1 deletion corpus/icupkg/74.2/meta.toml
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,4 @@ must_not_contain_flags = ["--auto"]

[xfail]
broken = true
reason = "the row `-c or --copyright include the ICU copyright notice` joins its two spellings with the word or and puts the description one space after the long spelling, so -c takes the literal word or as a value and --copyright reaches nothing. The value-carrying form of the same row shape is fixed; this value-free form with a one-space description column is not (docs/shapes.md S-134: or-joined-alias-single-space-gap reads 4 tools / 4 findings fleet-wide, below the five-tool bar; ships nothing). The -tl/-tb/-te rows are one option with three literal choice values, each keeping its own description, never folded into one flag's choices (docs/shapes.md S-133: choice-value-rows-unfolded reads 8 tools / 12 findings fleet-wide, but hand review finds only icupkg is this shape, the rest being different, already-tracked shapes; true count 1, below the five-tool bar; ships nothing)"
reason = "the row `-c or --copyright include the ICU copyright notice` joins its two spellings with the word or and puts the description one space after the long spelling, so -c takes the literal word or as a value and --copyright reaches nothing. The value-carrying form of the same row shape is fixed; this value-free form with a one-space description column is not (docs/shapes.md S-134: or-joined-alias-single-space-gap reads 4 tools / 4 findings fleet-wide, below the five-tool bar; ships nothing). [round 11] The -tl/-tb/-te rows (docs/shapes.md S-133) are now folded into one -t/--type flag with choices l/b/e, each keeping its own description, gated on the raw `-tl or --type l` literal so S-134's shape (above) is never touched; fleet count for S-133 stayed 1 tool, below the bar, but the fixture's own contract.must_attach_choices assertion now passes (gated exception, S-133 fixed for icupkg specifically). This fixture stays [xfail] solely for the still-open -c/--copyright defect above; issue #142 stays open."
2 changes: 1 addition & 1 deletion corpus/rustc/1.97.1/expected.snap
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ flags:
- help-text
- spellings:
- -l
value_name: <KIND>[:<MODIFIERS>
value_name: '[<KIND>[:<MODIFIERS>]=]'
value_kind: Optional
description: 'Link the generated crate(s) to the specified native library NAME. The optional KIND can be one of <static|framework|dylib> (default: dylib). Optional comma separated MODIFIERS <bundle|verbatim|whole-archive|as-needed> may be specified each with a prefix of either ''+'' to enable or ''-'' to disable.'
provenance:
Expand Down
137 changes: 128 additions & 9 deletions docs/shapes.md
Original file line number Diff line number Diff line change
Expand Up @@ -2407,13 +2407,44 @@ entry's `tools` field and nothing else. It does not get a new entry.
-tb or --type b output for big-endian/ASCII charset family
-te or --type e output for big-endian/EBCDIC charset family
- tools: icupkg
- handling: Open defect. `icupkg`'s three rows are one flag, `-t, --type`, with three
literal choice values, each keeping its own description. The tree
carries them as three separate entities, all spelled `-t`/`--type`,
differing only in `value_name` and description, never folded into one
flag's `choices`. `docs/shapes.md` S-102 already records a prototype
fold that moved only `icupkg` on a full-`PATH` sweep, below the
five-tool bar.
- handling: Fixed (gated exception, round 11). `icupkg`'s three rows fold into one
flag, `-t`/`--type`, with three literal choices, each keeping its own
description (`help_text::sections::or_choice_fold::fold_or_joined_choice_rows`).
Gated on the raw row's own literal text — the short spelling's glued
tail equal to the long spelling's own value literal (`-tl` / `--type
l`) — never on entity shape alone, so an unrelated same-spelling repeat
(S-134's fused `pod2man` regression, round 10's issue #142 attempt) is
never folded; `pod2man`'s `--lquote`/`--rquote` stay separate. Below
the five-tool bar, shipped only because `corpus/icupkg/74.2`'s own
`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.

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
alone — and is not a real loss. The two entities that disappear are the
three unfolded `-t`/`--type` rows collapsing to one: three rows in, one
row out, net `-2`. Nothing they carried is dropped — each row's own
`l`/`b`/`e` value and its own description survive as that one flag's
three `choices`, description-for-description, and the trailing
"The output type defaults to the input type." sentence (originally
folded onto the third row's own description by an unrelated
continuation-join rule) rides along on the `e` choice's description.
Read the count as three-rows-become-one, never as two flags vanishing.

Fencing gap, recorded rather than worked around: with the fold disabled,
`cargo run -p xtask -- corpus` reports `icupkg/74.2` as
`xfail (as expected)`, not `FAIL` — `must_attach_choices["-t"]` does fail
once disabled, but the fixture stays `[xfail]` for the separate,
still-open `-c`/`--copyright` defect (S-134), so strict xfail has no
clean-fixture transition to demand and the corpus gate stays green
either way. A regression in this fold is therefore invisible to
`cargo run -p xtask -- corpus`; the only fence that would catch one is
`or_choice_fold.rs`'s own unit test,
`icupkgs_three_type_rows_fold_into_one_flag_with_choices`. This is not
changed this round — no new xfail-independent assertion is added, and
the fixture's layout stays as-is.
- fleet: `choice-value-rows-unfolded`
(`xtask/src/detector/choice_value_rows_unfolded.rs`) reads 8 tools / 12
findings on a full-`PATH` sweep of 2319 tools, 2026-09-06, requiring
Expand All @@ -2427,8 +2458,8 @@ entry's `tools` field and nothing else. It does not get a new entry.
`pkg-config`-style ambiguity S-120's own handling notes; `-q`/`-qq` and
`-t`/`-tt` are S-035's repeated-letter verbosity levels. Folding any of
those into `choices` would misrepresent them. True count for this
family alone: 1 tool, below the five-tool bar. Ship nothing; the
fixture stays `[xfail]`.
family alone: 1 tool, below the five-tool bar, fixed anyway as a gated
exception.

### S-134: value-free `or`-joined alias, one-space description gap

Expand Down Expand Up @@ -3777,3 +3808,91 @@ entry's `tools` field and nothing else. It does not get a new entry.
below the five-tool bar. Shipped as a gated exception (docs/design.md
§16): both fixtures promote out of a prior unfixed state, `cargo xtask
corpus` is zero-loss, and every self-check holds, both directions.

### S-173: a description's own `* NAME - VALUE` bullet split at its inline dash

- id: S-173
- looks like: |
Each TYPE has the default FILE name:
* asm - CRATE_NAME.s
* llvm-bc - CRATE_NAME.bc
- tools: rustc
- handling: Fixed (round 11, TUI render layer, not the parser: the description
field itself was already whole — `mandible-extract` joins these lines
correctly). `wrap_description`'s enumerator-break heuristic
(`mandible-tui/src/render/detail_pane/wrap.rs`) treats a bare `-` word as
opening a fresh enumerated item unconditionally, so the ` - ` inside each
bullet's own `* asm - CRATE_NAME.s` opened a second, spurious break,
splitting `* asm` from `- CRATE_NAME.s`. `opens_enumerated_item` now
refuses a bare `-` sitting two words after a bare `*` opener — that dash
is the bullet's own inline separator, not a new item. These are prose
bullets, never choices; nothing about `Entity::choices` changes.
- fleet: A broadened raw-text sweep of the frozen `audit/queue-captures` set
(2301 tools) for a description line opening `[*+-]` or a digit bullet
followed by ` - ` inside its own text finds this shape on 1 tool, rustc,
9 findings (`* asm`, `* llvm-bc`, `* dep-info`, `* link`, `* llvm-ir`,
`* metadata`, `* mir`, `* obj`, `* thin-link-bitcode`); `-`/`+`/digit-led
variants add 0 tools. Below the five-tool bar; shipped anyway as a small,
self-contained render-layer generalization with its own unit tests
(`mandible-tui/src/render/detail_pane/wrap.rs`'s `tests` module), verified
not to disturb the genuine `N ->` (`sg_luns`) or real dash-bullet
(`fail2ban-client`) shapes the same heuristic serves.

Both the defect and the fix live entirely in `mandible-tui`'s render
layer, not in `mandible-extract`'s parser: the description field a
fixture snapshots is already whole (`xtask corpus --show` confirms this
directly). A full-`PATH` sweep, which measures the extracted tree, is
structurally blind to this family — a sweep-diff can show zero change
here and that is expected, not evidence of nothing shipped. The named
controls (screen-level, post-render output) and the unit tests above are
this family's actual regression fence, not a sweep.

### S-174: a value spec's nested bracket pair with trailing text before the outer close

- id: S-174
- looks like: |
--listen[=[ADDR:]PORT]
-l [<KIND>[:<MODIFIERS>]=]<NAME>[:<RENAME>]
- tools: cpio, fzf, journalctl, lsusb, node, nodejs, rustc
- handling: Fixed (round 11), CLEARS THE FIVE-TOOL BAR, generalizing S-119.
S-119's own bracket matcher requires the outer group to close immediately
after the inner pair's own close; each tool above carries more of the
outer group *after* the inner pair closes before the true outer close, so
S-119's matcher refused them and the naive first-`]` reader truncated the
value. `nested_bracket_content_general`
(`mandible-extract/src/help_text/grammar.rs`) matches by bracket depth
instead of anchoring to the inner close, still refusing a second nested
pair or a second level of nesting — the same exclusions S-119 documents
(`fzf-tmux`'s `[WIDTH[%][,HEIGHT[%]]]` stays out of scope). Distinct id
from S-158 (branch `r10/option-values`, not present on `main`): that entry
records the same first-nesting-level defect S-119 already fixed on
`main`; whoever merges both branches should fold S-158's entry into
S-119/S-174 rather than keep three ids for two defects.

Not a complete recovery everywhere — only the outer bracket group is
recovered, so a spec with real content *after* that group's own close
still loses that trailing part. Per tool: `node`/`nodejs` (`--inspect`,
`--inspect-brk`, `--inspect-wait`) and `journalctl` (`-n`/`--lines`) and
`fzf` (`--listen`) come out COMPLETE, nothing left over after the outer
group. `cpio` (`-I`/`-O`) keeps `FILE-NAME` unrendered after
`[[USER@]HOST:]`; `lsusb` (`-s`) keeps `[devnum]` after `[[bus]:]`; `rustc`
(`-l`) keeps `<NAME>[:<RENAME>]` after `[<KIND>[:<MODIFIERS>]=]`. Fixing
the trailing part too is a further, separate rule.
- 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`,
`r11/p1value.diff.txt`): `cpio` `-I`/`-O` (`[USER@` -> `[[USER@]HOST:]`,
raw `-I [[USER@]HOST:]FILE-NAME`), `fzf` `--listen` (`[[ADDR:]` ->
`[[ADDR:]PORT]`), `journalctl` `-n`/`--lines` (`[+` -> `[[+]INTEGER]`),
`lsusb` `-s` (`[bus` -> `[[bus]:]`, raw `-s [[bus]:][devnum]`), `node` and
`nodejs` `--inspect`/`--inspect-brk`/`--inspect-wait` (`[host:` ->
`[[host:]port]`, raw `--inspect[=[host:]port]`), `rustc` `-l`
(`<KIND>[:<MODIFIERS>` -> `[<KIND>[:<MODIFIERS>]=]`). Clears the
five-tool admission bar; not a gated exception. `corpus/fzf/0.44.1`'s own
`must_value_name["--listen"]` assertion passes (`[[ADDR:]PORT]`), a
corpus run over all 155 fixtures is zero-loss (0 failed), and the nine
named controls (`git`, `gcc`, `aarch64-linux-gnu-g++-13`, `ar`, `pnpm`,
`systemctl`, `tar`, `find`, `docker`) held byte-identical. The other six
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`).
79 changes: 79 additions & 0 deletions mandible-extract/src/help_text/grammar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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] 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
/// `--listen[=[ADDR:]PORT]`, rustc's `-l
/// [<KIND>[:<MODIFIERS>]=]<NAME>...`'s own `[<KIND>[:<MODIFIERS>]=]`).
/// Depth-tracked rather than anchored to the inner close, but still
/// refuses a second nested pair or a second level of nesting — the exact
/// exclusions S-119 already documents (`fzf-tmux`'s
/// `[WIDTH[%][,HEIGHT[%]]]`). Returns the content up to (not including)
/// the matching outer `]`.
fn nested_bracket_content_general(s: &str) -> Option<&str> {
let mut depth = 1i32;
let mut inner_pairs = 0usize;
for (i, c) in s.char_indices() {
match c {
'[' => {
depth += 1;
if depth > 2 {
return None;
}
inner_pairs += 1;
if inner_pairs > 1 {
return None;
}
}
']' => {
depth -= 1;
if depth == 0 {
return (inner_pairs == 1).then(|| &s[..i]);
}
}
_ => {}
}
}
None
}

/// A value spec following the flag token(s): `=VALUE`, ` VALUE`,
/// `[=VALUE]`, `[VALUE]`, `<value>`, or a bare uppercase-ish word. Returns
/// `(value_name, kind, rest)`.
Expand Down Expand Up @@ -919,6 +956,15 @@ fn try_value(input: &str) -> Option<(String, ValueKind, &str)> {
.expect("nested_bracket_content only returns a prefix a `]` follows");
return Some((format!("[{content}]"), ValueKind::Optional, after_outer));
}
// [S-174] The same one-nested-pair shape, but with more of the
// outer group after the inner pair closes (`--listen[=[ADDR:]PORT]`).
if let Some(content) = nested_bracket_content_general(s) {
let after_inner = &s[content.len()..];
let after_outer = after_inner
.strip_prefix(']')
.expect("nested_bracket_content_general only returns a prefix a `]` follows");
return Some((format!("[{content}]"), ValueKind::Optional, after_outer));
}
let name = value_inside_brackets(&mut s).ok()?;
close_bracket(&mut s).ok()?;
// A bracket group whose every `|`-separated member is itself an
Expand Down Expand Up @@ -1642,6 +1688,39 @@ mod tests {
}
}

/// `fzf`'s own `--listen[=[ADDR:]PORT]` row, byte-exact
/// (`audit/queue-captures/fzf/0.stdout`). The nested pair (`[ADDR:]`)
/// closes with more of the outer group (`PORT`) still to come before
/// the outer close — S-119's own matcher stops at the inner close and
/// drops the rest; S-174 generalizes it by depth instead. See
/// docs/shapes.md S-174.
#[test]
fn a_nested_bracket_value_with_trailing_text_keeps_the_whole_spec() {
let spec = parse_flag_spec("--listen[=[ADDR:]PORT]");
assert_eq!(spec.long(), Some("listen"));
// The leading `=` is stripped the same way a plain `[=VALUE]`
// spec already strips it (`parses_optional_bracketed_value`
// above) — this rule only extends how far the bracket matcher
// reads, not that convention.
assert_eq!(spec.value_name.as_deref(), Some("[[ADDR:]PORT]"));
assert_eq!(spec.value_kind, ValueKind::Optional);
assert!(spec.fully_consumed);
}

/// `rustc`'s own `-l [<KIND>[:<MODIFIERS>]=]<NAME>[:<RENAME>]` row
/// (`audit/queue-captures/rustc/0.stdout`): the same S-174 shape, one
/// space before the bracket rather than glued. The outer group
/// (`[<KIND>[:<MODIFIERS>]=]`) survives whole; the trailing
/// `<NAME>[:<RENAME>]` is a second, later spec fragment and out of
/// this rule's scope.
#[test]
fn a_nested_bracket_value_after_a_space_keeps_the_whole_outer_group() {
let spec = parse_flag_spec("-l [<KIND>[:<MODIFIERS>]=]<NAME>[:<RENAME>]");
assert_eq!(spec.short(), Some('l'));
assert_eq!(spec.value_name.as_deref(), Some("[<KIND>[:<MODIFIERS>]=]"));
assert_eq!(spec.value_kind, ValueKind::Optional);
}

/// `xxd`'s own `-s [+][-]seek` row, byte-exact. Neither bracket names
/// a value, so nothing is folded and the flag keeps what it had. See
/// docs/shapes.md S-097.
Expand Down
2 changes: 1 addition & 1 deletion mandible-extract/src/help_text/sections/flag_rows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -763,7 +763,7 @@ pub(super) fn choice_description_sub_row(trimmed: &str) -> Option<(&str, &str)>
/// A bare identifier: letters/digits, `_`/`-`, first character
/// alphanumeric — the shape a choice value is written in (`r`, `pvseg`,
/// `shell-escape`), never punctuation alone. See docs/shapes.md S-120.
fn is_choice_token(token: &str) -> bool {
pub(super) fn is_choice_token(token: &str) -> bool {
let mut chars = token.chars();
chars.next().is_some_and(|c| {
c.is_ascii_alphanumeric()
Expand Down
Loading
Loading