From e16d6127153b2b84a0d91589abc785b5f25761b3 Mon Sep 17 00:00:00 2001 From: Sadig Akhund Date: Sun, 13 Sep 2026 13:21:55 +0400 Subject: [PATCH 1/3] [S-172] usage-line single-dash-long token no longer splits after one letter lshw's -format/-options and fuser's -SIGNAL kept a whole spelling instead of a short flag plus a fabricated value, licensed by the tool's own usage line or a ` can be` heading rather than S-145's table-wide gate. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + corpus/fuser/23.7/expected.snap | 142 +++++++++++++ corpus/fuser/23.7/help.stderr.txt | 26 +++ corpus/fuser/23.7/help.txt | 0 corpus/fuser/23.7/meta.toml | 26 +++ corpus/lshw/02.19/expected.snap | 130 ++++++++++++ corpus/lshw/02.19/help.stderr.txt | 24 +++ corpus/lshw/02.19/help.txt | 0 corpus/lshw/02.19/meta.toml | 24 +++ docs/shapes.md | 34 +++ .../src/help_text/sections/mod.rs | 13 ++ .../src/help_text/sections/repair.rs | 122 +++++++++++ xtask/src/detector/detectors_families.rs | 28 +++ xtask/src/detector/mod.rs | 6 + .../usage_attested_single_dash_long.rs | 199 ++++++++++++++++++ 15 files changed, 775 insertions(+) create mode 100644 corpus/fuser/23.7/expected.snap create mode 100644 corpus/fuser/23.7/help.stderr.txt create mode 100644 corpus/fuser/23.7/help.txt create mode 100644 corpus/fuser/23.7/meta.toml create mode 100644 corpus/lshw/02.19/expected.snap create mode 100644 corpus/lshw/02.19/help.stderr.txt create mode 100644 corpus/lshw/02.19/help.txt create mode 100644 corpus/lshw/02.19/meta.toml create mode 100644 xtask/src/detector/usage_attested_single_dash_long.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a6bbc073..86a39a1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ once it reaches a published 0.1.0 release. - [S-145] A single-dash long option in a table with no double-dash row anywhere keeps its whole name and case now, so `mandible mksquashfs` and `mandible sqfstar` show `-pf`, `-ef`, `-Xhelp`, `-Xstrategy`, `-Xhc` and `-Xbcj` instead of splitting each one. - [S-145] A tab-separated value name on a single-dash long option survives the same repair now, so `mandible mksquashfs` and `mandible sqfstar` keep `-mem`, `-comp` and `-mkfs-time` with their placeholders instead of losing them. - [S-146] A heading with no indent step to its own rows, and a bare label above a nested option block, now name a group instead of leaving every row underneath ungrouped, so `mandible mksquashfs` shows its ten option headings and its five compressor names. +- [S-172] A single-dash-long token standing alone in a usage line's own bracket keeps its whole spelling now instead of splitting into a short flag plus a fabricated value, so `mandible lshw` shows `-format` and `-options` and `mandible fuser` shows `-SIGNAL` with its real description. ## [0.7.0] - 2026-09-05 diff --git a/corpus/fuser/23.7/expected.snap b/corpus/fuser/23.7/expected.snap new file mode 100644 index 00000000..fa9002c4 --- /dev/null +++ b/corpus/fuser/23.7/expected.snap @@ -0,0 +1,142 @@ +name: fuser +description: '/usr/bin/fuser: Invalid option --help Show which processes use the named files, sockets, or filesystems.' +usage: +- 'Usage: fuser [-fIMuvw] [-a|-s] [-4|-6] [-c|-m|-n SPACE] [-k [-i] [-SIGNAL]] NAME...' +- ' fuser -l' +- ' fuser -V' +positionals: +- name: NAME + required: true + variadic: true + provenance: + sources: + - help-text +flags: +- spellings: + - -a + - --all + description: display unused files too + provenance: + sources: + - help-text +- spellings: + - -i + - --interactive + description: ask before killing (ignored without -k) + provenance: + sources: + - help-text +- spellings: + - -I + - --inode + description: use always inodes to compare files + provenance: + sources: + - help-text +- spellings: + - -k + - --kill + description: kill processes accessing the named file + provenance: + sources: + - help-text +- spellings: + - -l + - --list-signals + description: list available signal names + provenance: + sources: + - help-text +- spellings: + - -m + - --mount + description: show all processes using the named filesystems or block device + provenance: + sources: + - help-text +- spellings: + - -M + - --ismountpoint + description: fulfill request only if NAME is a mount point + provenance: + sources: + - help-text +- spellings: + - -n + - --namespace + value_name: SPACE + value_kind: Required + description: search in this name space (file, udp, or tcp) + provenance: + sources: + - help-text +- spellings: + - -s + - --silent + description: silent operation + provenance: + sources: + - help-text +- spellings: + - -SIGNAL + description: send this signal instead of SIGKILL + provenance: + sources: + - help-text +- spellings: + - -u + - --user + description: display user IDs + provenance: + sources: + - help-text +- spellings: + - -v + - --verbose + description: verbose output + provenance: + sources: + - help-text +- spellings: + - -w + - --writeonly + description: kill only processes with write access + provenance: + sources: + - help-text +- spellings: + - -V + - --version + description: display version information + provenance: + sources: + - help-text +- spellings: + - '-4' + - --ipv4 + description: search IPv4 sockets only + provenance: + sources: + - help-text +- spellings: + - '-6' + - --ipv6 + description: search IPv6 sockets only + provenance: + sources: + - help-text +- spellings: + - -f + provenance: + sources: + - help-text-synopsis +- spellings: + - -c + provenance: + sources: + - help-text-synopsis +provenance: + sources: + - help-text + confidence: 0.5 +children_filled: true diff --git a/corpus/fuser/23.7/help.stderr.txt b/corpus/fuser/23.7/help.stderr.txt new file mode 100644 index 00000000..042ddefe --- /dev/null +++ b/corpus/fuser/23.7/help.stderr.txt @@ -0,0 +1,26 @@ +/usr/bin/fuser: Invalid option --help +Usage: fuser [-fIMuvw] [-a|-s] [-4|-6] [-c|-m|-n SPACE] + [-k [-i] [-SIGNAL]] NAME... + fuser -l + fuser -V +Show which processes use the named files, sockets, or filesystems. + + -a,--all display unused files too + -i,--interactive ask before killing (ignored without -k) + -I,--inode use always inodes to compare files + -k,--kill kill processes accessing the named file + -l,--list-signals list available signal names + -m,--mount show all processes using the named filesystems or + block device + -M,--ismountpoint fulfill request only if NAME is a mount point + -n,--namespace SPACE search in this name space (file, udp, or tcp) + -s,--silent silent operation + -SIGNAL send this signal instead of SIGKILL + -u,--user display user IDs + -v,--verbose verbose output + -w,--writeonly kill only processes with write access + -V,--version display version information + -4,--ipv4 search IPv4 sockets only + -6,--ipv6 search IPv6 sockets only + udp/tcp names: [local_port][,[rmt_host][,[rmt_port]]] + diff --git a/corpus/fuser/23.7/help.txt b/corpus/fuser/23.7/help.txt new file mode 100644 index 00000000..e69de29b diff --git a/corpus/fuser/23.7/meta.toml b/corpus/fuser/23.7/meta.toml new file mode 100644 index 00000000..fbf600fe --- /dev/null +++ b/corpus/fuser/23.7/meta.toml @@ -0,0 +1,26 @@ +[bless] +provenance = "agent" + +[tool] +name = "fuser" +version = "23.7" +platform = "ubuntu-24.04" +captured_with = "audit/queue-captures/fuser (seed 7 maintainer audit)" + +# fuser is on spec section 6 rule 0's thirteen-program list (signals +# processes): probed only as `fuser --help`, which this build declines +# and answers on stderr. +[[capture]] +argv = ["fuser", "--help"] +stdout = "help.txt" +stderr = "help.stderr.txt" +exit_code = 1 + +[contract] +expected_framework = "generic" +# S-172: `-SIGNAL` is one whole single-dash-long spelling. The unfixed +# parser split it into `-S` valued `"IGNAL"`. +must_contain_flags = ["-SIGNAL", "--all", "--kill", "--namespace"] + +[contract.must_not_value_name] +"-SIGNAL" = "IGNAL" diff --git a/corpus/lshw/02.19/expected.snap b/corpus/lshw/02.19/expected.snap new file mode 100644 index 00000000..27e4d5ea --- /dev/null +++ b/corpus/lshw/02.19/expected.snap @@ -0,0 +1,130 @@ +name: lshw +description: Hardware Lister (lshw) - +usage: +- 'usage: /usr/bin/lshw [-format] [-options ...]' +- ' /usr/bin/lshw -version' +flags: +- spellings: + - -version + description: print program version () + provenance: + sources: + - help-text +- spellings: + - -html + group: format can be + description: output hardware tree as HTML + provenance: + sources: + - help-text +- spellings: + - -xml + group: format can be + description: output hardware tree as XML + provenance: + sources: + - help-text +- spellings: + - -json + group: format can be + description: output hardware tree as a JSON object + provenance: + sources: + - help-text +- spellings: + - -short + group: format can be + description: output hardware paths + provenance: + sources: + - help-text +- spellings: + - -businfo + group: format can be + description: output bus information + provenance: + sources: + - help-text +- spellings: + - -class + group: options can be + description: only show a certain class of hardware + provenance: + sources: + - help-text +- spellings: + - -C + value_name: CLASS + value_kind: Required + group: options can be + description: same as '-class CLASS' + provenance: + sources: + - help-text +- spellings: + - -c + value_name: CLASS + value_kind: Required + group: options can be + description: same as '-class CLASS' + provenance: + sources: + - help-text +- spellings: + - -disable + group: options can be + description: disable a test (like pci, isapnp, cpuid, etc. ) + provenance: + sources: + - help-text +- spellings: + - -enable + group: options can be + description: enable a test (like pci, isapnp, cpuid, etc. ) + provenance: + sources: + - help-text +- spellings: + - -quiet + group: options can be + description: don't display status + provenance: + sources: + - help-text +- spellings: + - -sanitize + group: options can be + description: sanitize output (remove sensitive information like serial numbers, etc.) + provenance: + sources: + - help-text +- spellings: + - -numeric + group: options can be + description: output numeric IDs (for PCI, USB, etc.) + provenance: + sources: + - help-text +- spellings: + - -notime + group: options can be + description: exclude volatile attributes (timestamps) from output + provenance: + sources: + - help-text +- spellings: + - -format + provenance: + sources: + - help-text-synopsis +- spellings: + - -options + provenance: + sources: + - help-text-synopsis +detected_framework: BSD-terse +provenance: + sources: + - help-text + confidence: 0.8 +children_filled: true diff --git a/corpus/lshw/02.19/help.stderr.txt b/corpus/lshw/02.19/help.stderr.txt new file mode 100644 index 00000000..aca25fc9 --- /dev/null +++ b/corpus/lshw/02.19/help.stderr.txt @@ -0,0 +1,24 @@ +Hardware Lister (lshw) - +usage: /usr/bin/lshw [-format] [-options ...] + /usr/bin/lshw -version + + -version print program version () + +format can be + -html output hardware tree as HTML + -xml output hardware tree as XML + -json output hardware tree as a JSON object + -short output hardware paths + -businfo output bus information + +options can be + -class CLASS only show a certain class of hardware + -C CLASS same as '-class CLASS' + -c CLASS same as '-class CLASS' + -disable TEST disable a test (like pci, isapnp, cpuid, etc. ) + -enable TEST enable a test (like pci, isapnp, cpuid, etc. ) + -quiet don't display status + -sanitize sanitize output (remove sensitive information like serial numbers, etc.) + -numeric output numeric IDs (for PCI, USB, etc.) + -notime exclude volatile attributes (timestamps) from output + diff --git a/corpus/lshw/02.19/help.txt b/corpus/lshw/02.19/help.txt new file mode 100644 index 00000000..e69de29b diff --git a/corpus/lshw/02.19/meta.toml b/corpus/lshw/02.19/meta.toml new file mode 100644 index 00000000..fb9032e4 --- /dev/null +++ b/corpus/lshw/02.19/meta.toml @@ -0,0 +1,24 @@ +[bless] +provenance = "agent" + +[tool] +name = "lshw" +version = "02.19" +platform = "ubuntu-24.04" +captured_with = "audit/queue-captures/lshw (seed 7 maintainer audit)" + +[[capture]] +argv = ["lshw", "--help"] +stdout = "help.txt" +stderr = "help.stderr.txt" +exit_code = 0 + +[contract] +expected_framework = "BSD-terse" +# S-172: `-format` and `-options` are each one whole single-dash-long +# spelling. The unfixed parser split `-format` into `-f` valued `"ormat"` +# and dropped `-options` entirely as the generic option-list placeholder. +must_contain_flags = ["-format", "-options", "-version", "-html", "-class"] + +[contract.must_not_value_name] +"-format" = "ormat" diff --git a/docs/shapes.md b/docs/shapes.md index ad8e4642..93855abe 100644 --- a/docs/shapes.md +++ b/docs/shapes.md @@ -2817,3 +2817,37 @@ entry's `tools` field and nothing else. It does not get a new entry. not gated: 3 are fail2ban-client's own still-open `set`/`add` gap (S-141's name rule, not this fix), the rest are false alarms on text that merely resembles a label followed by a row. 2026-09-07. + +### S-172: a usage-line single-dash-long token split after one letter + +- id: S-172 +- looks like: | + usage: /usr/bin/lshw [-format] [-options ...] + Usage: fuser ... [-k [-i] [-SIGNAL]] NAME... +- tools: lshw, fuser +- handling: A single-dash-long spelling that stands alone as its own bracketed + token in a usage line — never joined to a separate value by a space, an + `=`, or a bracket of its own — reached the tree split into a short flag + plus a swallowed value (`-format` to `-f` valued `"ormat"`, `-SIGNAL` to + `-S` valued `"IGNAL"`). Two sites, one shape: a usage-derived flag's own + swallowed tail is repaired when uniformly lowercase and the reconstructed + name matches a ` can be` heading the same document carries + (`recover_can_be_placeholder_flags`, also recovering `-options`, dropped + entirely as the generic option-list placeholder before this); a + table-derived flag's swallowed tail is repaired when the tool's own + usage line independently spells the same name as one stand-alone + bracketed token (`repair_usage_attested_single_dash_long`) — direct proof + from a document location distinct from the row itself, never a loosening + of S-145's own table-wide gate, which stays closed for both tools (no + unambiguous single-dash-only row establishes either document as that + convention). Never claims a bundle of already-known short flags + (`rpcbind`'s own `-adhilswfr`, left to the bundling family). +- fleet: `usage-attested-single-dash-long` + (`xtask/src/detector/usage_attested_single_dash_long.rs`) measures against + the parse (a swallowed-value split whose name a usage line independently + attests), not the raw text alone. Both fixture tools, lshw and fuser, are + seed-7 maintainer-audited (`audit/7.toml`'s own `k1 = true` rows); no + broader full-`PATH` sweep was drawn this round, so the honest count is 2, + 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. diff --git a/mandible-extract/src/help_text/sections/mod.rs b/mandible-extract/src/help_text/sections/mod.rs index 54495ee5..660fa565 100644 --- a/mandible-extract/src/help_text/sections/mod.rs +++ b/mandible-extract/src/help_text/sections/mod.rs @@ -1728,6 +1728,14 @@ fn parse_body( // duplicate is not added. "Let the described version win" // taken literally: the existing entry is never touched. } + // A dash-prefixed usage word normally swallowed as the generic + // "any option" placeholder, or a usage-derived flag already + // misread as a short flag plus a fabricated value, is repaired or + // recovered as its own flag when a ` can be` line attests + // it names a real one (S-172). + if result.flags.len() < MAX_RECOVERED_ENTRIES { + recover_can_be_placeholder_flags(&usage_lines, &lines, &mut result.flags); + } } // Last, over everything both scans produced: the repeated-character @@ -1742,6 +1750,11 @@ fn parse_body( // by the time this one runs the repeated-character family is already // gone from the fingerprint the two detectors share. repair_single_dash_long_options(&mut result.flags, &glued_tokens, raw); + // A narrower sibling of the repair above, admitted on its own + // evidence rather than S-145's table-wide argument (atlas S-172): a + // table-derived flag whose reconstructed name the tool's own usage + // line spells as one stand-alone bracketed token. + repair_usage_attested_single_dash_long(&mut result.flags, &usage_lines); // Last because it can only fill what the two above finished naming: // descriptions written as free prose paragraphs, not option-table // columns. diff --git a/mandible-extract/src/help_text/sections/repair.rs b/mandible-extract/src/help_text/sections/repair.rs index 39e63fc3..dcbf606c 100644 --- a/mandible-extract/src/help_text/sections/repair.rs +++ b/mandible-extract/src/help_text/sections/repair.rs @@ -336,6 +336,128 @@ pub(super) fn repair_single_dash_long_options( } } +/// A table-sourced single-dash-long flag S-145 declines (too few +/// unambiguous rows to qualify — every long alias here is comma-glued to +/// its short letter, `fuser`'s own `-a,--all`) is admitted on a third, +/// independent evidence source: the tool's own usage line, a document +/// location distinct from the table row itself, spells the exact +/// reconstructed name as one stand-alone bracketed token (`[-SIGNAL]`) — +/// direct proof, never a change to S-145. Never applied to a flag the +/// usage line itself produced (that would be self-attestation, no new +/// evidence at all — see `parse_bundled_shorts`'s own `-adhilswfr`). +/// Atlas S-172. Fixtures: `corpus/fuser/*`. +pub(super) fn repair_usage_attested_single_dash_long(flags: &mut [Entity], usage_lines: &[String]) { + for flag in flags.iter_mut() { + if !flag.provenance.sources.contains(&Source::HelpText) + || flag.provenance.sources.contains(&Source::HelpTextSynopsis) + { + continue; + } + let Some(short) = flag.short() else { continue }; + if flag.long().is_some() || flag.value_kind != ValueKind::Required { + continue; + } + let Some(tail) = flag.value_name.as_deref() else { + continue; + }; + if !is_option_name_tail(tail) || tail.chars().count() < MIN_SWALLOWED_NAME_CHARS { + continue; + } + let name = format!("{short}{tail}"); + let needle = format!("-{name}"); + let attested = usage_lines + .iter() + .any(|line| usage_line_has_standalone_token(line, &needle)); + if !attested { + continue; + } + flag.spellings = vec![Spelling::single_dash(&name)]; + flag.value_name = None; + flag.value_kind = ValueKind::None; + } +} + +/// A dash-prefixed usage-line word that normally reads as the generic +/// "any option" placeholder, or a swallowed-value split (`lshw`'s +/// `-format`), is the tool's own literal flag when the document also +/// carries a line reading exactly ` can be` — `lshw`'s own `format +/// can be`/`options can be` (atlas S-172). Repairs the matching broken +/// entry if one exists, else adds a plain, valueless spelling; the block +/// underneath stays its own flags either way, never folded into `choices` +/// here. See `docs/shapes.md` S-172. +pub(super) fn recover_can_be_placeholder_flags( + usage_lines: &[String], + lines: &[&str], + flags: &mut Vec, +) { + for line in lines { + let trimmed = line.trim(); + let Some(word) = trimmed.strip_suffix("can be").map(str::trim) else { + continue; + }; + if word.is_empty() || !word.chars().all(|c| c.is_ascii_alphanumeric()) { + continue; + } + let name = word.to_ascii_lowercase(); + let dash_word = format!("-{name}"); + let attested = usage_lines + .iter() + .any(|u| usage_line_has_standalone_token(u, &dash_word)); + if !attested { + continue; + } + if flags.iter().any(|f| { + f.spellings + .iter() + .any(|s| s.dashes == Dashes::Single && s.name == name) + }) { + continue; // already a clean spelling; nothing to do + } + let broken = flags.iter_mut().find(|f| { + f.long().is_none() + && f.value_kind == ValueKind::Required + && f.short() + .zip(f.value_name.as_deref()) + .is_some_and(|(short, tail)| format!("{short}{tail}") == name) + }); + match broken { + Some(flag) => { + flag.spellings = vec![Spelling::single_dash(&name)]; + flag.value_name = None; + flag.value_kind = ValueKind::None; + } + None => { + let mut flag = Entity::new( + EntityKind::Flag, + Provenance::single(Source::HelpTextSynopsis), + ); + flag.spellings = vec![Spelling::single_dash(&name)]; + flags.push(flag); + } + } + } +} + +/// True when `needle` occurs in `line` bounded on both sides by a +/// bracket, whitespace, or the line's own edge — never glued to another +/// character, which would mean it is only part of a longer token. See +/// [`recover_can_be_placeholder_flags`]. +fn usage_line_has_standalone_token(line: &str, needle: &str) -> bool { + let is_boundary = |c: char| c.is_whitespace() || c == '[' || c == ']'; + let mut start = 0usize; + while let Some(rel) = line[start..].find(needle) { + let idx = start + rel; + let before_ok = line[..idx].chars().next_back().is_none_or(is_boundary); + let after_idx = idx + needle.len(); + let after_ok = line[after_idx..].chars().next().is_none_or(is_boundary); + if before_ok && after_ok { + return true; + } + start = idx + 1; + } + false +} + /// Fewest table-shaped single-dash rows a document must carry before /// [`single_dash_long_table`] trusts it. Two, not one: a single such row /// carries no evidence that the *document's own convention* is diff --git a/xtask/src/detector/detectors_families.rs b/xtask/src/detector/detectors_families.rs index aba5c5d5..8a2ffc8d 100644 --- a/xtask/src/detector/detectors_families.rs +++ b/xtask/src/detector/detectors_families.rs @@ -610,6 +610,34 @@ impl Detector for SingleDashLongTable { } } +pub(crate) struct UsageAttestedSingleDashLong; + +impl Detector for UsageAttestedSingleDashLong { + fn name(&self) -> &'static str { + "usage-attested-single-dash-long" + } + fn family(&self) -> Option<&'static str> { + // No audit seed carries this family's own machine-derived label + // yet (spec §13.1e rule 6): calibration has nothing to + // generalize against. + None + } + fn describes(&self) -> &'static str { + "a single-dash-long spelling the tool's own usage line spells as one stand-alone \ + bracketed token reaches the tree split into a short flag plus a swallowed value" + } + fn hits(&self, evidence: &ToolEvidence<'_>) -> Vec { + super::usage_attested_single_dash_long::detect(evidence.raw, evidence.root) + .findings + .iter() + .map(|f| format!("-{} never became its own spelling", f.name)) + .collect() + } + fn self_checks(&self) -> Vec { + super::usage_attested_single_dash_long::self_checks() + } +} + pub(crate) struct CommandRowArgumentPlaceholder; impl Detector for CommandRowArgumentPlaceholder { diff --git a/xtask/src/detector/mod.rs b/xtask/src/detector/mod.rs index 15c03797..56f3092f 100644 --- a/xtask/src/detector/mod.rs +++ b/xtask/src/detector/mod.rs @@ -102,6 +102,11 @@ pub(crate) mod lowdown_bullet_option_row; // parser change ships for it. pub(crate) mod option_table_multiword_value_name; +// Round-11 family detector (atlas S-172: a usage-line single-dash-long +// 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; + pub(crate) use calibration::*; pub(crate) use commands::*; pub(crate) use detectors_families::*; @@ -748,6 +753,7 @@ pub fn registry() -> Vec> { Box::new(crate::centered_label_baseline::LabelPrecedesShallowerLine), Box::new(crate::centered_label_baseline::MissingRowAfterLabel), Box::new(option_table_multiword_value_name::OptionTableMultiwordValueName), + Box::new(UsageAttestedSingleDashLong), ] } diff --git a/xtask/src/detector/usage_attested_single_dash_long.rs b/xtask/src/detector/usage_attested_single_dash_long.rs new file mode 100644 index 00000000..05387de1 --- /dev/null +++ b/xtask/src/detector/usage_attested_single_dash_long.rs @@ -0,0 +1,199 @@ +//! `usage-attested-single-dash-long` (atlas S-172): a single-dash-long +//! spelling the tool's own usage line spells as one stand-alone bracketed +//! token — `lshw`'s own `[-format]`, `fuser`'s own `[-SIGNAL]` — currently +//! reaches the tree split into a short flag plus a swallowed value. +//! Fixtures: `corpus/lshw/02.19/`, `corpus/fuser/23.7/`. + +// Never a bundle of already-known short flags (`rpcbind`'s own +// `-adhilswfr`, deliberately left to the bundling family): a name is +// reported only when at least one of its own characters is *not* already +// a short flag elsewhere in the tree, mirroring the parser's own guard. + +use mandible_core::{CommandNode, ValueKind}; +use std::collections::{BTreeSet, HashSet}; + +pub struct Finding { + pub name: String, +} + +pub struct Report { + pub findings: Vec, +} + +const MIN_SWALLOWED_CHARS: usize = 2; + +/// Twin of `mandible_extract`'s own `is_option_name_tail`: alphanumerics, +/// `-`, `_`, at least one letter. +fn is_option_name_tail(tail: &str) -> bool { + tail.chars().any(|c| c.is_ascii_alphabetic()) + && tail + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} + +/// A `usage:`/`Usage:`-led physical line, plus every immediately +/// following non-blank line indented under it — the common multi-line +/// synopsis continuation shape (`fuser`'s own second line). +fn usage_lines(raw: &str) -> Vec<&str> { + let mut out = Vec::new(); + let mut in_block = false; + for line in raw.lines() { + let trimmed = line.trim_start(); + if trimmed + .get(..6) + .is_some_and(|p| p.eq_ignore_ascii_case("usage:")) + { + in_block = true; + out.push(line); + } else if in_block && !line.trim().is_empty() && line.starts_with(char::is_whitespace) { + out.push(line); + } else { + in_block = false; + } + } + out +} + +/// True when `needle` occurs in `line` bounded on both sides by a +/// bracket, whitespace, or the line's own edge. +fn line_has_standalone_token(line: &str, needle: &str) -> bool { + let is_boundary = |c: char| c.is_whitespace() || c == '[' || c == ']'; + let mut start = 0usize; + while let Some(rel) = line.get(start..).and_then(|s| s.find(needle)) { + let idx = start + rel; + let before_ok = line[..idx].chars().next_back().is_none_or(is_boundary); + let after_idx = idx + needle.len(); + let after_ok = line[after_idx..].chars().next().is_none_or(is_boundary); + if before_ok && after_ok { + return true; + } + start = idx + 1; + } + false +} + +pub fn detect(raw: &str, root: &CommandNode) -> Report { + let lines = usage_lines(raw); + if lines.is_empty() { + return Report { + findings: Vec::new(), + }; + } + let existing_shorts: HashSet = root.flags().filter_map(|f| f.short()).collect(); + let mut seen = BTreeSet::new(); + let mut findings = Vec::new(); + for flag in root.flags() { + let Some(short) = flag.short() else { continue }; + if flag.long().is_some() || flag.value_kind != ValueKind::Required { + continue; + } + let Some(tail) = flag.value_name.as_deref() else { + continue; + }; + if !is_option_name_tail(tail) || tail.chars().count() < MIN_SWALLOWED_CHARS { + continue; + } + let name = format!("{short}{tail}"); + if name.chars().all(|c| existing_shorts.contains(&c)) { + continue; + } + let needle = format!("-{name}"); + if !lines.iter().any(|l| line_has_standalone_token(l, &needle)) { + continue; + } + if seen.insert(name.clone()) { + findings.push(Finding { name }); + } + } + Report { findings } +} + +// ---------------------------------------------------------------------- +// Self-checks +// ---------------------------------------------------------------------- + +use crate::detector::{Expect, SelfCheck}; +use mandible_core::{Entity, Provenance, Source, Spelling}; + +pub(crate) const LSHW_USAGE_LINE: &str = "usage: /usr/bin/lshw [-format] [-options ...]\n"; +pub(crate) const FUSER_USAGE_LINES: &str = "Usage: fuser [-fIMuvw] [-a|-s] [-4|-6] [-c|-m|-n SPACE]\n [-k [-i] [-SIGNAL]] NAME...\n"; + +fn split_flag(short: char, tail: &str) -> Entity { + let mut e = Entity::flag_spelled( + None, + None, + false, + false, + Provenance::single(Source::HelpText), + ); + e.spellings = vec![Spelling::short(short)]; + e.value_name = Some(tail.to_string()); + e.value_kind = ValueKind::Required; + e +} + +fn whole_flag(name: &str) -> Entity { + let mut e = Entity::flag_spelled( + None, + None, + false, + false, + Provenance::single(Source::HelpText), + ); + e.spellings = vec![Spelling::single_dash(name)]; + e +} + +fn node_with_flags(name: &str, flags: Vec) -> CommandNode { + let mut root = CommandNode::new(name, Provenance::single(Source::HelpText)); + root.set_entities_of(mandible_core::EntityKind::Flag, flags); + root +} + +pub(crate) fn self_checks() -> Vec { + vec![ + SelfCheck { + name: "lshw's own bytes, `-format` truncated to `-f` valued `\"ormat\"`", + why: "the defect itself: the usage line spells `-format` whole, but the tree \ + carries only a swallowed-value split", + expect: Expect::Fires(1), + raw: LSHW_USAGE_LINE.to_string(), + root: node_with_flags("lshw", vec![split_flag('f', "ormat")]), + }, + SelfCheck { + name: "`-format` recovered as its own single-dash spelling", + why: "once the tree carries the whole name, the same raw line must go silent", + expect: Expect::Silent, + raw: LSHW_USAGE_LINE.to_string(), + root: node_with_flags("lshw", vec![whole_flag("format")]), + }, + SelfCheck { + name: "fuser's own bytes, `-SIGNAL` truncated to `-S` valued `\"IGNAL\"`", + why: "the second usage line spells `-SIGNAL` whole, inside a nested bracket", + expect: Expect::Fires(1), + raw: FUSER_USAGE_LINES.to_string(), + root: node_with_flags("fuser", vec![split_flag('S', "IGNAL")]), + }, + SelfCheck { + name: "rpcbind's own bundle, `-adhilswfr`, never claimed as one long name", + why: "every one of its own letters is already a real short flag elsewhere in \ + the tree, so this is the bundling family's shape, not this one", + expect: Expect::Silent, + raw: "usage: rpcbind [-adhilswfr]\n".to_string(), + root: node_with_flags( + "rpcbind", + vec![ + split_flag('a', "dhilswfr"), + whole_flag("d"), + whole_flag("h"), + whole_flag("i"), + whole_flag("l"), + whole_flag("s"), + whole_flag("w"), + whole_flag("f"), + whole_flag("r"), + ], + ), + }, + ] +} From 980efcbb1d2f583db1b94971145546496412c70a Mon Sep 17 00:00:00 2001 From: Sadig Akhund Date: Sun, 13 Sep 2026 13:46:07 +0400 Subject: [PATCH 2/3] [S-172] never read a glued uppercase placeholder as a longer name memhog's usage line brackets [-rNUM] the way fuser's brackets [-SIGNAL], so the usage-attested repair destroyed a real value name. Refused now when the case changes at the split point; the full-PATH sweep is what caught it, memhog's flag count being 3 either way. Co-Authored-By: Claude Fable 5.1 --- docs/shapes.md | 16 +++++++ .../src/help_text/sections/repair.rs | 48 ++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/docs/shapes.md b/docs/shapes.md index 93855abe..8a9d704f 100644 --- a/docs/shapes.md +++ b/docs/shapes.md @@ -2842,6 +2842,22 @@ entry's `tools` field and nothing else. It does not get a new entry. unambiguous single-dash-only row establishes either document as that convention). Never claims a bundle of already-known short flags (`rpcbind`'s own `-adhilswfr`, left to the bundling family). + + Refused when the case changes at the split point — a lowercase flag + letter running straight into an uppercase tail — because that is the + GCC/Clang glued-value convention, not a longer name. `memhog`'s usage + line brackets `[-rNUM]` exactly the way `fuser`'s brackets `[-SIGNAL]`, + and `-r` valued `NUM` is the correct parse, so without this guard the + repair destroyed a real value name. `fuser`'s `-SIGNAL` is uppercase + throughout and so has no such transition; `lshw`'s `-format` is + lowercase throughout. Found by the full-`PATH` sweep and by nothing + else: memhog's flag count is 3 either way, so the change lands in the + sweep-diff's field-level list and never on the loss bar, and neither + fixture tool carries the shape. Fenced by + `memhogs_glued_uppercase_placeholder_is_never_usage_attested` + (`sections/repair.rs`), memhog having no fixture of its own; that + test's doc comment states the one label it adds to memhog's text and + why a bare `parse` needs it. - fleet: `usage-attested-single-dash-long` (`xtask/src/detector/usage_attested_single_dash_long.rs`) measures against the parse (a swallowed-value split whose name a usage line independently diff --git a/mandible-extract/src/help_text/sections/repair.rs b/mandible-extract/src/help_text/sections/repair.rs index dcbf606c..089aefde 100644 --- a/mandible-extract/src/help_text/sections/repair.rs +++ b/mandible-extract/src/help_text/sections/repair.rs @@ -345,7 +345,9 @@ pub(super) fn repair_single_dash_long_options( /// direct proof, never a change to S-145. Never applied to a flag the /// usage line itself produced (that would be self-attestation, no new /// evidence at all — see `parse_bundled_shorts`'s own `-adhilswfr`). -/// Atlas S-172. Fixtures: `corpus/fuser/*`. +/// Refused when the case changes at the split point (`memhog`'s +/// `[-rNUM]`): a glued value spec, never a longer name. Atlas S-172, +/// which carries the discriminator. Fixtures: `corpus/fuser/*`. pub(super) fn repair_usage_attested_single_dash_long(flags: &mut [Entity], usage_lines: &[String]) { for flag in flags.iter_mut() { if !flag.provenance.sources.contains(&Source::HelpText) @@ -363,6 +365,12 @@ pub(super) fn repair_usage_attested_single_dash_long(flags: &mut [Entity], usage if !is_option_name_tail(tail) || tail.chars().count() < MIN_SWALLOWED_NAME_CHARS { continue; } + // A lowercase flag letter running straight into an uppercase + // tail is a glued value spec, not a longer name (`memhog`'s + // `-rNUM`); see this function's own doc comment. + if short.is_ascii_lowercase() && tail.starts_with(|c: char| c.is_ascii_uppercase()) { + continue; + } let name = format!("{short}{tail}"); let needle = format!("-{name}"); let attested = usage_lines @@ -810,6 +818,44 @@ mod tests { assert!(flag_named(&parsed, "nn").single_dash()); } + /// `memhog`'s own text: its usage line brackets `[-rNUM]` exactly + /// the way `fuser`'s brackets `[-SIGNAL]`, but `-r` valued `NUM` is + /// the correct parse and the usage-attested repair must leave it + /// alone. The case change at the split point is the whole + /// discriminator. See docs/shapes.md S-172. + /// + /// One deviation from the raw bytes, deliberate: memhog's first line + /// opens on its own program name with no `usage:` label, which the + /// real pipeline recognizes because it knows the program it ran and + /// this bare [`parse`] does not. The label is added so the standalone + /// parse reaches the same rule; every other byte is memhog's own. + #[test] + fn memhogs_glued_uppercase_placeholder_is_never_usage_attested() { + let raw = concat!( + "usage: memhog [-fFILE] [-rNUM] [-H] size[kmg] [policy [nodeset]]\n", + "-f mmap is backed by FILE\n", + "-rNUM repeat memset NUM times\n", + "-H disable transparent hugepages\n", + ); + let parsed = parse(raw); + let r = parsed + .flags + .iter() + .find(|f| f.short() == Some('r')) + .expect("-r must survive as its own short flag"); + assert_eq!(r.value_name.as_deref(), Some("NUM")); + assert_eq!(r.value_kind, ValueKind::Required); + assert!( + !parsed.flags.iter().any(|f| f.spelling() == "-rNUM"), + "-rNUM is a glued value spec, never a spelling: {:?}", + parsed + .flags + .iter() + .map(|f| f.spelling()) + .collect::>() + ); + } + /// A spaced value is indistinguishable from a glued one once stored; /// the raw text is what decides. #[test] From c40bb4f029399a3f8a5d5c0f552aca225e43dc3e Mon Sep 17 00:00:00 2001 From: Sadig Akhund Date: Sun, 13 Sep 2026 15:35:34 +0400 Subject: [PATCH 3/3] corpus: rebless fuser and lshw for the merged tree S-162 drops fuser's `Invalid option --help` prefix from its description, S-172 keeps `-SIGNAL` whole on the second fuser fixture, and lshw gains the `CLASS` and `TEST` value names. Nothing else moved. Co-Authored-By: Claude Fable 5.1 --- corpus/fuser/23.7/expected.snap | 2 +- corpus/fuser/audit-seed/expected.snap | 4 +--- corpus/lshw/02.19/expected.snap | 6 ++++++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/corpus/fuser/23.7/expected.snap b/corpus/fuser/23.7/expected.snap index fa9002c4..13e5a300 100644 --- a/corpus/fuser/23.7/expected.snap +++ b/corpus/fuser/23.7/expected.snap @@ -1,5 +1,5 @@ name: fuser -description: '/usr/bin/fuser: Invalid option --help Show which processes use the named files, sockets, or filesystems.' +description: Show which processes use the named files, sockets, or filesystems. usage: - 'Usage: fuser [-fIMuvw] [-a|-s] [-4|-6] [-c|-m|-n SPACE] [-k [-i] [-SIGNAL]] NAME...' - ' fuser -l' diff --git a/corpus/fuser/audit-seed/expected.snap b/corpus/fuser/audit-seed/expected.snap index 714f0ac2..13e5a300 100644 --- a/corpus/fuser/audit-seed/expected.snap +++ b/corpus/fuser/audit-seed/expected.snap @@ -78,9 +78,7 @@ flags: sources: - help-text - spellings: - - -S - value_name: IGNAL - value_kind: Required + - -SIGNAL description: send this signal instead of SIGKILL provenance: sources: diff --git a/corpus/lshw/02.19/expected.snap b/corpus/lshw/02.19/expected.snap index 27e4d5ea..3ff98385 100644 --- a/corpus/lshw/02.19/expected.snap +++ b/corpus/lshw/02.19/expected.snap @@ -47,6 +47,8 @@ flags: - help-text - spellings: - -class + value_name: CLASS + value_kind: Required group: options can be description: only show a certain class of hardware provenance: @@ -72,6 +74,8 @@ flags: - help-text - spellings: - -disable + value_name: TEST + value_kind: Required group: options can be description: disable a test (like pci, isapnp, cpuid, etc. ) provenance: @@ -79,6 +83,8 @@ flags: - help-text - spellings: - -enable + value_name: TEST + value_kind: Required group: options can be description: enable a test (like pci, isapnp, cpuid, etc. ) provenance: