diff --git a/CHANGELOG.md b/CHANGELOG.md index f9e4fe3..9ccbda4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ once it reaches a published 0.1.0 release. - [S-167] A `Usage:` line whose leading word carries a bracketed optional-abbreviation suffix (`v[ersion]`, `g[dbserver]`) now reaches the tree as its own subcommand, named and shown by its full word with the short prefix kept as an alias, and probed for its own flags, instead of folding into the usage block (`mandible lldb-server`). - [S-169] A command table sitting directly under the root's own usage synopsis, rows never repeating the tool's own name, now recovers as subcommands carrying their own flags and operands instead of fabricated flag groups on the root, so `mandible dmsetup` and `mandible dmstats` show their full command lists. - [S-170] A usage-synopsis bracket group whose own value spec is itself entirely option spellings no longer invents a value from it, so `mandible dmsetup`'s `-h, --help` and `-v, --verbose` show no value name instead of `-c|-C|--columns`/`-v|--verbose ...`. +- [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 0000000..13e5a30 --- /dev/null +++ b/corpus/fuser/23.7/expected.snap @@ -0,0 +1,142 @@ +name: fuser +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' +- ' 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 0000000..042ddef --- /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 0000000..e69de29 diff --git a/corpus/fuser/23.7/meta.toml b/corpus/fuser/23.7/meta.toml new file mode 100644 index 0000000..fbf600f --- /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/fuser/audit-seed/expected.snap b/corpus/fuser/audit-seed/expected.snap index 714f0ac..13e5a30 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 new file mode 100644 index 0000000..3ff9838 --- /dev/null +++ b/corpus/lshw/02.19/expected.snap @@ -0,0 +1,136 @@ +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 + value_name: CLASS + value_kind: Required + 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 + value_name: TEST + value_kind: Required + group: options can be + description: disable a test (like pci, isapnp, cpuid, etc. ) + provenance: + sources: + - help-text +- spellings: + - -enable + value_name: TEST + value_kind: Required + 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 0000000..aca25fc --- /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 0000000..e69de29 diff --git a/corpus/lshw/02.19/meta.toml b/corpus/lshw/02.19/meta.toml new file mode 100644 index 0000000..fb9032e --- /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 ff7a2fc..891c030 100644 --- a/docs/shapes.md +++ b/docs/shapes.md @@ -3727,3 +3727,53 @@ entry's `tools` field and nothing else. It does not get a new entry. fixture as the only change, and all nine named controls stay byte-identical (see gate log). `sqfstar` was checked and is not a member: its own usage has no numbered pair at all. + +### 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). + + 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 + 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 43fcbab..25dca25 100644 --- a/mandible-extract/src/help_text/sections/mod.rs +++ b/mandible-extract/src/help_text/sections/mod.rs @@ -2177,6 +2177,14 @@ fn parse_body( // silently replaced by whichever entity happened to land // first. } + // 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 @@ -2191,6 +2199,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 03f789d..95fbf18 100644 --- a/mandible-extract/src/help_text/sections/repair.rs +++ b/mandible-extract/src/help_text/sections/repair.rs @@ -345,6 +345,136 @@ 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`). +/// 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) + || 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; + } + // 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 + .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 @@ -838,6 +968,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] diff --git a/xtask/src/detector/detectors_families.rs b/xtask/src/detector/detectors_families.rs index aba5c5d..8a2ffc8 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 4be7454..a697138 100644 --- a/xtask/src/detector/mod.rs +++ b/xtask/src/detector/mod.rs @@ -132,6 +132,10 @@ pub(crate) mod choice_list_under_placeholder; // usage block, atlas S-169 and S-170), same direct-`Detector`-impl shape. pub(crate) mod nested_flag_group_as_value; pub(crate) mod usage_command_table; +// 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::*; @@ -798,6 +802,7 @@ pub fn registry() -> Vec> { Box::new(UsageOptionalWordTable), Box::new(usage_command_table::UsageCommandTable), Box::new(nested_flag_group_as_value::NestedFlagGroupAsValue), + 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 0000000..05387de --- /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"), + ], + ), + }, + ] +}