From fbf1822567140091b44dba6b1a352ed59559cdf0 Mon Sep 17 00:00:00 2001 From: Sadig Akhund Date: Sat, 12 Sep 2026 20:13:51 +0400 Subject: [PATCH 01/10] core: render one placeholder and one unioned choice list per merged spelling A same-spelling merge collects every form's literal value into one choice list and keeps one placeholder, replacing the value-name union. docs/shapes.md S-147. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 +- corpus/README.md | 57 ++++- corpus/fc-validate/audit-seed2/expected.snap | 14 ++ .../ip6tables-apply/audit-seed2/expected.snap | 7 + .../iptables-apply/audit-seed2/expected.snap | 7 + corpus/lvcreate/2.03.16/expected.snap | 80 +++++++ corpus/lvcreate/2.03.16/meta.toml | 24 +- corpus/pod2man/5.01/expected.snap | 7 + corpus/ssh-keygen/9.6p1/expected.snap | 56 +++++ corpus/ssh-keygen/audit-seed2/expected.snap | 56 +++++ mandible-core/src/entity.rs | 25 +++ mandible-core/src/lib.rs | 2 +- mandible-core/src/merge.rs | 206 ++++++++++++++---- .../src/help_text/sections/mod.rs | 23 +- .../src/help_text/sections/usage.rs | 102 +++++++++ xtask/src/corpus/contract.rs | 1 + xtask/src/corpus/mod.rs | 14 ++ xtask/src/corpus/refill_contract.rs | 113 ++++++++-- xtask/src/corpus/runner.rs | 4 + 19 files changed, 714 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6bbc073..2dc26dcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ once it reaches a published 0.1.0 release. - [S-137] Every invocation form lvm2 documents now reaches usage as its own alternative, keeping the prose sentence above it as its option group instead of inventing a heading from the form's own usage line (`mandible lvcreate`). - [S-139] A single-dash long option carrying an interior uppercase letter keeps its whole name when a sibling row in the same table shares its lowercase prefix, so `mandible mksquashfs` shows `-noI` and its nine siblings instead of ten rows reading `-n`. - [S-143, S-144] lowdown's man-page-like `--help` rendering now parses its bullet-marked commands and options instead of rendering verbatim, recovering all 30 subcommands and 9 flags of `mandible nix`, including a `/`-joined alias and a multi-word value name (#138). -- [S-147] A flag documented once per invocation form now keeps every form's own value name instead of one form's name beside another's choices, so `mandible lvcreate`'s `--type` shows `linear, striped, raid10, snapshot, thin` beside its `raid1, mirror` choices. +- [S-147] A flag documented once per invocation form now renders one placeholder and one unioned choice list instead of splitting across a value name and a separate choices line, so `mandible lvcreate`'s `--type` shows no placeholder beside `values: linear, striped, raid1, mirror, raid, raid10, snapshot, thin-pool, cache-pool, thin, vdo, cache, writecache`. - [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. diff --git a/corpus/README.md b/corpus/README.md index 469c4832..d0798aee 100644 --- a/corpus/README.md +++ b/corpus/README.md @@ -553,21 +553,21 @@ produces no root, the same reasoning `must_not_describe` uses. ### A value name after the real app's own root refill: `must_value_names_after_root_refill` `must_value_name` is satisfied by *any* entity carrying the spelling, on -the raw, unrefilled tree — which is exactly why it cannot see `lvcreate`'s -own defect (docs/shapes.md S-147). `--type` reaches that tree once per -invocation form (`linear`, `striped`, the choices `raid1`/`mirror`, ...), -each form still its own entity, each already naming its own value: the -positive claim passes trivially before any fold has even happened. The -loss only exists once the same node is folded, and every running session -folds it: `mandible::background::Warmer::submit_root_fill` always merges -the already-extracted root against a fresh reprobe as soon as the TUI -opens, and `Runner::fill_node`'s own contract ("`existing` is always -included as a candidate") is what pools every same-spelling entity into -one bucket for `mandible_core::merge::merge_entity_bucket` to fold. +the raw, unrefilled tree — which is exactly why it cannot see a +same-spelling merge bucket's own defect (docs/shapes.md S-147). A flag +whose several invocation forms each name their own value reaches the raw +tree once per form, each form still its own entity, each already naming +its own value: the positive claim passes trivially before any fold has +even happened. The loss only exists once the same node is folded, and +every running session folds it: `mandible::background::Warmer::submit_root_fill` +always merges the already-extracted root against a fresh reprobe as soon +as the TUI opens, and `Runner::fill_node`'s own contract ("`existing` is +always included as a candidate") is what pools every same-spelling entity +into one bucket for `mandible_core::merge::merge_entity_bucket` to fold. ```toml [contract.must_value_names_after_root_refill] -"--type" = ["linear", "striped", "raid10", "snapshot", "thin"] +"-help" = ["topic"] ``` `cargo xtask corpus` simulates that exact refill — `merge_nodes` over two @@ -581,6 +581,39 @@ listed substring, naming what was expected and what survived instead. A fixture that produces no root fails this exactly as it fails `must_value_name`. +S-147's own follow-up ruling (2026-09-07 "queue", docs/design.md §16) +narrowed what this field can still claim about `lvcreate`'s own `--type`: +a merge bucket whose forms disagree about a *literal* value now unions +every one of them into `choices` instead, rendering one placeholder +(``, or none, never a comma list of literals) beside the union — +see `must_choices_after_root_refill` below for that half. This field +still states its own claim correctly for a bucket disagreeing about a +genuine, capitalised placeholder name. + +### Choices after the real app's own root refill: `must_choices_after_root_refill` + +The `choices` twin of `must_value_names_after_root_refill`, for the same +S-147 follow-up: a same-spelling bucket whose forms each name a different +*literal* value (`linear`, `striped`, `raid10`, ...) unions every one, +plus any `choices` a bracket-row form already carried, into one list on +the refilled entity. `must_attach_choices` alone cannot see this: it +walks the raw, unrefilled tree, where `.find()` sees only the first +invocation form's own entity and its usually-empty `choices`. + +```toml +[contract.must_choices_after_root_refill] +"--type" = ["linear", "striped", "raid1", "mirror", "raid", "raid10", "snapshot", "thin-pool", "cache-pool", "thin", "vdo", "cache", "writecache"] +``` + +`cargo xtask corpus` simulates the same refill +`must_value_names_after_root_refill` does and checks the named flag's +*merged* `choices` for every listed value (matched the way +`must_attach_choices` matches: exact name, no substring). `cargo xtask +corpus` fails when the flag is absent from the refilled tree, or when +the merged choices are missing any listed value, naming what was +expected and what the merged choices actually held. A fixture that +produces no root fails this exactly as it fails `must_attach_choices`. + ### What `--bless` does and does not assert: `verdict_scope` `--bless` freezes the *entire* tree into `expected.snap` — node summaries, diff --git a/corpus/fc-validate/audit-seed2/expected.snap b/corpus/fc-validate/audit-seed2/expected.snap index a53a8f97..5530ac42 100644 --- a/corpus/fc-validate/audit-seed2/expected.snap +++ b/corpus/fc-validate/audit-seed2/expected.snap @@ -47,6 +47,20 @@ flags: provenance: sources: - help-text +- spellings: + - -i + value_name: index + value_kind: Required + provenance: + sources: + - help-text-synopsis +- spellings: + - --index + value_name: index + value_kind: Required + provenance: + sources: + - help-text-synopsis provenance: sources: - help-text diff --git a/corpus/ip6tables-apply/audit-seed2/expected.snap b/corpus/ip6tables-apply/audit-seed2/expected.snap index dcba4f4d..7040bd2e 100644 --- a/corpus/ip6tables-apply/audit-seed2/expected.snap +++ b/corpus/ip6tables-apply/audit-seed2/expected.snap @@ -45,6 +45,13 @@ flags: provenance: sources: - help-text +- spellings: + - -t + value_name: timeout + value_kind: Required + provenance: + sources: + - help-text-synopsis provenance: sources: - help-text diff --git a/corpus/iptables-apply/audit-seed2/expected.snap b/corpus/iptables-apply/audit-seed2/expected.snap index 3966e14f..253b1294 100644 --- a/corpus/iptables-apply/audit-seed2/expected.snap +++ b/corpus/iptables-apply/audit-seed2/expected.snap @@ -45,6 +45,13 @@ flags: provenance: sources: - help-text +- spellings: + - -t + value_name: timeout + value_kind: Required + provenance: + sources: + - help-text-synopsis provenance: sources: - help-text diff --git a/corpus/lvcreate/2.03.16/expected.snap b/corpus/lvcreate/2.03.16/expected.snap index 0edb97d1..7165d0de 100644 --- a/corpus/lvcreate/2.03.16/expected.snap +++ b/corpus/lvcreate/2.03.16/expected.snap @@ -133,6 +133,14 @@ flags: provenance: sources: - help-text +- spellings: + - --type + value_name: raid + value_kind: Required + group: Create a raid LV (a specific raid level must be used, e.g. raid1). + provenance: + sources: + - help-text-synopsis - spellings: - -l - --extents @@ -315,6 +323,14 @@ flags: provenance: sources: - help-text +- spellings: + - --type + value_name: thin-pool + value_kind: Required + group: Create a thin pool. + provenance: + sources: + - help-text-synopsis - spellings: - -l - --extents @@ -405,6 +421,14 @@ flags: provenance: sources: - help-text +- spellings: + - --type + value_name: cache-pool + value_kind: Required + group: Create a cache pool. + provenance: + sources: + - help-text-synopsis - spellings: - -l - --extents @@ -536,6 +560,14 @@ flags: provenance: sources: - help-text +- spellings: + - --type + value_name: thin + value_kind: Required + group: Create a thin LV that is a snapshot of an external origin LV. + provenance: + sources: + - help-text-synopsis - spellings: - -T - --thin @@ -543,6 +575,14 @@ flags: provenance: sources: - help-text +- spellings: + - --type + value_name: vdo + value_kind: Required + group: Create a LV that returns VDO when used. + provenance: + sources: + - help-text-synopsis - spellings: - -l - --extents @@ -621,6 +661,14 @@ flags: provenance: sources: - help-text +- spellings: + - --type + value_name: cache + value_kind: Required + group: Create a new LV, then attach the specified cachepool which converts the new LV to type cache. + provenance: + sources: + - help-text-synopsis - spellings: - -l - --extents @@ -720,6 +768,14 @@ flags: provenance: sources: - help-text +- spellings: + - --type + value_name: cache + value_kind: Required + group: Create a new LV, then attach the specified cachevol which converts the new LV to type cache. + provenance: + sources: + - help-text-synopsis - spellings: - -l - --extents @@ -794,6 +850,14 @@ flags: provenance: sources: - help-text +- spellings: + - --type + value_name: cache + value_kind: Required + group: Create a new LV, then attach a cachevol created from the specified cache device, which converts the new LV to type cache. + provenance: + sources: + - help-text-synopsis - spellings: - -l - --extents @@ -876,6 +940,14 @@ flags: provenance: sources: - help-text +- spellings: + - --type + value_name: writecache + value_kind: Required + group: Create a new LV, then attach the specified cachevol which converts the new LV to type writecache. + provenance: + sources: + - help-text-synopsis - spellings: - -l - --extents @@ -911,6 +983,14 @@ flags: provenance: sources: - help-text +- spellings: + - --type + value_name: writecache + value_kind: Required + group: Create a new LV, then attach a cachevol created from the specified cache device, which converts the new LV to type writecache. + provenance: + sources: + - help-text-synopsis - spellings: - -l - --extents diff --git a/corpus/lvcreate/2.03.16/meta.toml b/corpus/lvcreate/2.03.16/meta.toml index 83b305d7..1a0666f0 100644 --- a/corpus/lvcreate/2.03.16/meta.toml +++ b/corpus/lvcreate/2.03.16/meta.toml @@ -38,13 +38,17 @@ must_not_contain_positionals = ["COMMON_OPTIONS"] [contract.must_flag_group] "--mirrorlog" = "Create a raid1 or mirror LV." -# docs/shapes.md S-147: seven invocation forms all reach the tree spelled -# `--type`, each naming (or, for the raid1/mirror form's own choices, not -# naming) a different literal value. `must_value_name` alone cannot state -# this claim — it passes vacuously off the raw, unrefilled tree, where the -# forms are still separate entities and each already names its own value. -# The claim only means something once the same background root-refill the -# running app always performs (`Warmer::submit_root_fill`) folds them into -# one bucket, which is what this field simulates. -[contract.must_value_names_after_root_refill] -"--type" = ["linear", "striped", "raid10", "snapshot", "thin"] +# docs/shapes.md S-147 follow-up (ruled 2026-09-07 "queue"): thirteen +# invocation forms all reach the tree spelled `--type`, each naming a +# different literal value, either directly (`--type raid`) or via a +# bracket row's own trailing choice list (`raid1|mirror`). +# `must_attach_choices` alone cannot state this claim — it walks the raw, +# unrefilled tree, where `.find()` sees only the first form's own, +# usually-empty `choices`. The claim only means something once the same +# background root-refill the running app always performs +# (`Warmer::submit_root_fill`) folds every form into one bucket, which is +# what `must_choices_after_root_refill` simulates. No form ever names +# `--type`'s value with a capitalised token, so the merged flag carries no +# placeholder at all rather than a fabricated ``. +[contract.must_choices_after_root_refill] +"--type" = ["linear", "striped", "raid1", "mirror", "raid", "raid10", "snapshot", "thin-pool", "cache-pool", "thin", "vdo", "cache", "writecache"] diff --git a/corpus/pod2man/5.01/expected.snap b/corpus/pod2man/5.01/expected.snap index 5134eb7a..2674bc31 100644 --- a/corpus/pod2man/5.01/expected.snap +++ b/corpus/pod2man/5.01/expected.snap @@ -198,6 +198,13 @@ flags: provenance: sources: - help-text +- spellings: + - --section + value_name: manext + value_kind: Required + provenance: + sources: + - help-text-synopsis provenance: sources: - help-text diff --git a/corpus/ssh-keygen/9.6p1/expected.snap b/corpus/ssh-keygen/9.6p1/expected.snap index a507040d..16140f20 100644 --- a/corpus/ssh-keygen/9.6p1/expected.snap +++ b/corpus/ssh-keygen/9.6p1/expected.snap @@ -115,6 +115,13 @@ flags: provenance: sources: - help-text-synopsis +- spellings: + - -f + value_name: keyfile + value_kind: Required + provenance: + sources: + - help-text-synopsis - spellings: - -P value_name: old_passphrase @@ -142,6 +149,13 @@ flags: provenance: sources: - help-text-synopsis +- spellings: + - -P + value_name: passphrase + value_kind: Required + provenance: + sources: + - help-text-synopsis - spellings: - -l provenance: @@ -214,6 +228,13 @@ flags: provenance: sources: - help-text-synopsis +- spellings: + - -M + value_name: screen + value_kind: Required + provenance: + sources: + - help-text-synopsis - spellings: - -I value_name: certificate_identity @@ -235,6 +256,13 @@ flags: provenance: sources: - help-text-synopsis +- spellings: + - -n + value_name: principals + value_kind: Required + provenance: + sources: + - help-text-synopsis - spellings: - -V value_name: validity_interval @@ -281,6 +309,34 @@ flags: provenance: sources: - help-text-synopsis +- spellings: + - -Y + value_name: match-principals + value_kind: Required + provenance: + sources: + - help-text-synopsis +- spellings: + - -Y + value_name: check-novalidate + value_kind: Required + provenance: + sources: + - help-text-synopsis +- spellings: + - -Y + value_name: sign + value_kind: Required + provenance: + sources: + - help-text-synopsis +- spellings: + - -Y + value_name: verify + value_kind: Required + provenance: + sources: + - help-text-synopsis provenance: sources: - help-text diff --git a/corpus/ssh-keygen/audit-seed2/expected.snap b/corpus/ssh-keygen/audit-seed2/expected.snap index a507040d..16140f20 100644 --- a/corpus/ssh-keygen/audit-seed2/expected.snap +++ b/corpus/ssh-keygen/audit-seed2/expected.snap @@ -115,6 +115,13 @@ flags: provenance: sources: - help-text-synopsis +- spellings: + - -f + value_name: keyfile + value_kind: Required + provenance: + sources: + - help-text-synopsis - spellings: - -P value_name: old_passphrase @@ -142,6 +149,13 @@ flags: provenance: sources: - help-text-synopsis +- spellings: + - -P + value_name: passphrase + value_kind: Required + provenance: + sources: + - help-text-synopsis - spellings: - -l provenance: @@ -214,6 +228,13 @@ flags: provenance: sources: - help-text-synopsis +- spellings: + - -M + value_name: screen + value_kind: Required + provenance: + sources: + - help-text-synopsis - spellings: - -I value_name: certificate_identity @@ -235,6 +256,13 @@ flags: provenance: sources: - help-text-synopsis +- spellings: + - -n + value_name: principals + value_kind: Required + provenance: + sources: + - help-text-synopsis - spellings: - -V value_name: validity_interval @@ -281,6 +309,34 @@ flags: provenance: sources: - help-text-synopsis +- spellings: + - -Y + value_name: match-principals + value_kind: Required + provenance: + sources: + - help-text-synopsis +- spellings: + - -Y + value_name: check-novalidate + value_kind: Required + provenance: + sources: + - help-text-synopsis +- spellings: + - -Y + value_name: sign + value_kind: Required + provenance: + sources: + - help-text-synopsis +- spellings: + - -Y + value_name: verify + value_kind: Required + provenance: + sources: + - help-text-synopsis provenance: sources: - help-text diff --git a/mandible-core/src/entity.rs b/mandible-core/src/entity.rs index 9c69daa5..632f770e 100644 --- a/mandible-core/src/entity.rs +++ b/mandible-core/src/entity.rs @@ -188,6 +188,31 @@ pub struct Choice { pub description: Option, } +/// Whether `value_name` reads as one clean literal enumerated value +/// (`"raid1"`, `"thin-pool"`) rather than a placeholder name (`"Number"`, +/// `"VG"`) or a grammar fragment glued onto a neighboring flag +/// (`blkid`'s `"[--match-tag"`, a docopt value spec restated whole, +/// `"y|n"`). The rule the S-147 follow-up ruling states (2026-09-07 +/// queue, `docs/design.md` §16): a capitalised token is a placeholder +/// name and stays out of a merged `choices` list. Narrowed to one word of +/// lowercase ASCII letters, digits and hyphens: any other punctuation +/// (`|`, `[`, `<`, a leading dash) means this token is notation, not an +/// enumerated member, and letting it through would union a restated value +/// spec into `choices` alongside the real values it already restates. +/// Shared by `merge::merge_entity_bucket` (bucket-wide reclassification) +/// and `mandible_extract`'s stanza-head literal-value recovery, so both +/// sides of the merge agree on one rule. +pub fn is_literal_choice_value(value_name: &str) -> bool { + !value_name.is_empty() + && value_name + .chars() + .next() + .is_some_and(|c| c.is_ascii_lowercase()) + && value_name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + impl Choice { /// A choice with no documented description — the common case. pub fn bare(name: impl Into) -> Choice { diff --git a/mandible-core/src/lib.rs b/mandible-core/src/lib.rs index dd4358a6..54c9c1cf 100644 --- a/mandible-core/src/lib.rs +++ b/mandible-core/src/lib.rs @@ -24,7 +24,7 @@ mod provenance; mod snapshot; mod text; -pub use entity::{Choice, Dashes, Entity, EntityKind, Spelling}; +pub use entity::{is_literal_choice_value, Choice, Dashes, Entity, EntityKind, Spelling}; pub use merge::{ merge_entity_lists, merge_nodes, merge_subcommand_lists, pair_aliases, MergeError, }; diff --git a/mandible-core/src/merge.rs b/mandible-core/src/merge.rs index 58716d61..2e5e0806 100644 --- a/mandible-core/src/merge.rs +++ b/mandible-core/src/merge.rs @@ -11,7 +11,7 @@ //! order wins": attempt order is a cost ordering (spec §7); conflict //! resolution is authority (spec §4.4). -use crate::entity::{Dashes, Entity, EntityKind, Spelling}; +use crate::entity::{is_literal_choice_value, Choice, Dashes, Entity, EntityKind, Spelling}; use crate::node::CommandNode; use crate::provenance::{Axis, Provenance}; use crate::text::Text; @@ -357,15 +357,96 @@ fn merge_entity_bucket(mut bucket: Vec) -> Entity { // no other source can have seen the same flag spelled the other way. let negatable = bucket.iter().any(|f| f.negatable()); let single_dash = bucket.iter().any(|f| f.single_dash()); - // Union, not pick-one (docs/shapes.md S-147). `lvcreate` reaches this - // bucket once per invocation form, each naming a different literal - // value for `--type` (`linear`, `striped`, `raid10`, ...): a - // highest-authority single winner rendered one form's value name - // beside another form's `choices`, silently dropping every other - // form's name. Every distinct name across the whole bucket survives, - // in first-appearance order, joined the same way `choices` already - // joins for display (spec §9.2, "values: raid1, mirror"). - let value_name = union_value_names(bucket.iter().map(|f| f.value_name.as_deref())); + // Every distinct `value_name` the bucket's forms carry, in + // first-appearance order. Computed before the fields it gates so + // every branch below can share it; a bucket with zero or one + // distinct name is the ordinary case and never reaches either + // disagreement branch. + let distinct_value_names: Vec<&str> = { + let mut v: Vec<&str> = Vec::new(); + for f in &bucket { + if let Some(name) = f.value_name.as_deref() { + if !v.contains(&name) { + v.push(name); + } + } + } + v + }; + let has_any_choices = bucket.iter().any(|f| !f.choices.is_empty()); + // S-147's own follow-up (ruled 2026-09-07 "queue", docs/design.md §16) + // narrows what used to be one blanket rule into two: a bucket whose + // *every* distinct name is a literal enumerated value (`is_literal_ + // choice_value`) is `--type`'s own shape and gets the new one- + // placeholder, one-unioned-choices treatment; a bucket that mixes a + // real placeholder name with something else (`tar`'s `--rsh-command + // COMMAND` beside its own default `/usr/bin/rsh`, `gcc`'s `--format + // FORMAT` beside its own default `gnu`) is not `--type`'s shape and + // must keep the union-into-`value_name` behavior S-147 already + // shipped fleet-wide, unchanged, or a control tool's row moves for a + // ruling that was never about it. + let all_literal_disagreement = distinct_value_names.len() > 1 + && distinct_value_names.iter().all(|n| is_literal_choice_value(n)); + let single_literal_beside_choices = distinct_value_names.len() == 1 + && has_any_choices + && is_literal_choice_value(distinct_value_names[0]); + let (value_name, choices) = if all_literal_disagreement || single_literal_beside_choices { + // One placeholder, one unioned choice list. `lvcreate` reaches + // this bucket once per invocation form, each naming its own + // literal value for `--type` (`linear`, `striped`, `raid10`, + // ...) — a highest-authority single winner used to render one + // form's value name beside another form's `choices`, silently + // dropping every other form's name. Every distinct literal + // value across the whole bucket, from a form's own `value_name` + // and from any `choices` it already carries, unions into one + // `choices` list, in first-appearance order, joined the same way + // `choices` already joins for display (spec §9.2, "values: + // raid1, mirror"). Every name here is already known literal + // (both branches above require it), so `value_name` is always + // `None` — never fabricated from the flag's own spelling. + let mut choices: Vec = Vec::new(); + let mut choice_names: Vec<&str> = Vec::new(); + for f in &bucket { + if let Some(name) = f.value_name.as_deref() { + if is_literal_choice_value(name) && !choice_names.contains(&name) { + choice_names.push(name); + choices.push(Choice::bare(name.to_string())); + } + } + for c in &f.choices { + if !choice_names.contains(&c.name.as_str()) { + choice_names.push(&c.name); + choices.push(c.clone()); + } + } + } + (None, choices) + } else if distinct_value_names.len() > 1 { + // A mixed disagreement (at least one non-literal name): the + // union-into-`value_name` behavior S-147 shipped fleet-wide, + // unchanged. `choices` still resolves by authority, as it always + // has outside the all-literal shape above. + ( + Some(distinct_value_names.join(", ")), + pick_vec( + bucket.iter().map(|f| (&f.provenance, &f.choices)), + Axis::Prose, + ), + ) + } else { + // The ordinary case: at most one distinct name, so there is + // nothing to disagree about. Exactly the pre-S147 resolution. + ( + pick_option( + bucket.iter().map(|f| (&f.provenance, f.value_name.as_ref())), + Axis::Structural, + ), + pick_vec( + bucket.iter().map(|f| (&f.provenance, &f.choices)), + Axis::Prose, + ), + ) + }; let value_kind = bucket .iter() .map(|f| f.value_kind) @@ -375,10 +456,6 @@ fn merge_entity_bucket(mut bucket: Vec) -> Entity { crate::node::ValueKind::Required => 2, }) .unwrap_or(crate::node::ValueKind::None); - let choices = pick_vec( - bucket.iter().map(|f| (&f.provenance, &f.choices)), - Axis::Prose, - ); let repeatable = bucket.iter().any(|f| f.repeatable); let required = bucket.iter().any(|f| f.required); let deprecated = pick_option( @@ -551,26 +628,6 @@ where best.map(|(_, v)| v.clone()).unwrap_or_default() } -/// Union every distinct non-empty `value_name` across one identity bucket, -/// in first-appearance order, joined by `", "` (spec §9.2's own join for -/// `choices`). Unlike [`pick_option`], authority plays no part here: two -/// invocation forms of one flag can each name a real, different literal -/// value, and a single winner would silently drop the rest (docs/shapes.md -/// S-147). `None` when the bucket names none at all. -fn union_value_names<'a, I: IntoIterator>>(names: I) -> Option { - let mut seen: Vec<&str> = Vec::new(); - for name in names.into_iter().flatten() { - if !seen.contains(&name) { - seen.push(name); - } - } - if seen.is_empty() { - None - } else { - Some(seen.join(", ")) - } -} - /// Unify flags that arrived as separate short/long rows from the same /// source, per spec §4.4: sources legitimately emit a flag's short and long /// forms as distinct items (e.g. `gh __complete pr -` returns `--repo` and @@ -1274,14 +1331,16 @@ mod tests { ); } - /// docs/shapes.md S-147: `lvcreate` reaches the merge bucket once per - /// invocation form, and three of its forms each name a different - /// literal value for `--type`. A single-winner pick rendered one - /// form's value name (`linear`) while a later fold attached another - /// form's `choices` (`raid1`, `mirror`) to the same row, silently - /// dropping `striped`. Every distinct name must survive. + /// docs/shapes.md S-147 follow-up (ruled 2026-09-07 "queue"): `lvcreate` + /// reaches the merge bucket once per invocation form, and three of its + /// forms each name a different literal value for `--type`. A + /// single-winner pick used to render one form's value name (`linear`) + /// while a later fold attached another form's `choices` (`raid1`, + /// `mirror`) to the same row, splitting one flag across two lists. + /// Every literal value now joins one `choices` list, and no + /// placeholder is fabricated when every form named a literal. #[test] - fn merge_unions_value_names_from_several_invocation_forms() { + fn merge_unions_literal_value_names_into_choices_from_several_invocation_forms() { fn type_flag(value_name: &str) -> Entity { let mut e = Entity::flag_long("type", Provenance::single(Source::HelpText)); e.value_kind = ValueKind::Required; @@ -1294,14 +1353,40 @@ mod tests { type_flag("raid10"), ]; let merged = merge_entity_bucket(bucket); + assert_eq!(merged.value_name, None); assert_eq!( - merged.value_name.as_deref(), - Some("linear, striped, raid10") + merged + .choices + .iter() + .map(|c| c.name.as_str()) + .collect::>(), + vec!["linear", "striped", "raid10"] ); } - /// The ordinary case — every form names the same value — must not - /// start repeating itself. + /// A bucket that disagrees about its value name but is NOT the + /// all-literal S-147 follow-up shape (`tar`'s own `--rsh-command + /// COMMAND` beside its own default `/usr/bin/rsh`) must keep the + /// union-into-`value_name` behavior S-147 already shipped fleet-wide. + /// A control-tool row moving on this fix would mean it reached + /// further than `--type`. + #[test] + fn merge_unions_a_mixed_disagreement_into_value_name_unchanged_from_s147() { + fn valued(value_name: &str) -> Entity { + let mut e = Entity::flag_long("rsh-command", Provenance::single(Source::HelpText)); + e.value_kind = ValueKind::Required; + e.value_name = Some(value_name.to_string()); + e + } + let bucket = vec![valued("COMMAND"), valued("/usr/bin/rsh")]; + let merged = merge_entity_bucket(bucket); + assert_eq!(merged.value_name.as_deref(), Some("COMMAND, /usr/bin/rsh")); + assert!(merged.choices.is_empty()); + } + + /// The ordinary case — every form names the same capitalised + /// placeholder — must not start repeating itself, and must not be + /// misread as a literal choice value. #[test] fn merge_does_not_repeat_a_value_name_every_form_agrees_on() { fn type_flag() -> Entity { @@ -1312,5 +1397,36 @@ mod tests { } let merged = merge_entity_bucket(vec![type_flag(), type_flag()]); assert_eq!(merged.value_name.as_deref(), Some("TYPE")); + assert!(merged.choices.is_empty()); + } + + /// A bucket that mixes a real bracket-form `choices` list with other + /// forms' own literal `value_name`s must union both into one list, + /// per docs/shapes.md S-147's follow-up ruling. + #[test] + fn merge_unions_a_bracket_choices_list_with_other_forms_literal_value_names() { + fn valued(value_name: &str) -> Entity { + let mut e = Entity::flag_long("type", Provenance::single(Source::HelpText)); + e.value_kind = ValueKind::Required; + e.value_name = Some(value_name.to_string()); + e + } + fn choice_row(names: &[&str]) -> Entity { + let mut e = Entity::flag_long("type", Provenance::single(Source::HelpText)); + e.value_kind = ValueKind::Required; + e.choices = names.iter().map(|n| Choice::bare(*n)).collect(); + e + } + let bucket = vec![valued("linear"), choice_row(&["raid1", "mirror"])]; + let merged = merge_entity_bucket(bucket); + assert_eq!(merged.value_name, None); + assert_eq!( + merged + .choices + .iter() + .map(|c| c.name.as_str()) + .collect::>(), + vec!["linear", "raid1", "mirror"] + ); } } diff --git a/mandible-extract/src/help_text/sections/mod.rs b/mandible-extract/src/help_text/sections/mod.rs index 54495ee5..091585a0 100644 --- a/mandible-extract/src/help_text/sections/mod.rs +++ b/mandible-extract/src/help_text/sections/mod.rs @@ -27,8 +27,8 @@ use super::grammar::{ }; use super::profile::{heading_matches_markers, FrameworkProfile}; use mandible_core::{ - is_command_name_shaped, strip_escapes, Choice, CommandNode, Dashes, Entity, EntityKind, - Provenance, Source, Spelling, Text, ValueKind, + is_command_name_shaped, is_literal_choice_value, strip_escapes, Choice, CommandNode, Dashes, + Entity, EntityKind, Provenance, Source, Spelling, Text, ValueKind, }; mod backfill; @@ -941,7 +941,15 @@ fn emit_heading_block( st.result.usage.push(heading.clone()); } if !st.in_ignorable_section { - if let Some(mut flag) = recover_stanza_head_flag(heading, tool_name) { + // A head naming exactly one flag is `recover_stanza_head_flag`'s + // own shape; a head naming a leading flag's own literal value + // plus further required flags (`lvcreate --type raid -L|--size + // Size[m|UNIT] VG`) is not, so the fallback only runs when the + // first recognizer stays silent, never both, since either would + // otherwise recover the same leading flag twice. See S-147. + let recovered = recover_stanza_head_flag(heading, tool_name) + .or_else(|| recover_stanza_head_leading_flag_value(heading, tool_name)); + if let Some(mut flag) = recovered { if let Some(label) = stanza_label.clone() { flag.group = Some(label); } @@ -1720,13 +1728,20 @@ fn parse_body( if result.flags.len() >= MAX_RECOVERED_ENTRIES { break; } - if !flag_spelling_already_present(&flag, &result.flags) { + if !flag_spelling_already_present(&flag, &result.flags) + || usage_flag_names_a_new_literal_value(&flag, &result.flags) + { result.flags.push(flag); } // else: this spelling already names a flag the block scan // recovered, so the usage-derived, always-undescribed // duplicate is not added. "Let the described version win" // taken literally: the existing entry is never touched. + // `usage_flag_names_a_new_literal_value` is the one exception + // (S-147): a real, distinct literal value still gets added + // for `merge_entity_bucket` to union later, rather than + // silently replaced by whichever entity happened to land + // first. } } diff --git a/mandible-extract/src/help_text/sections/usage.rs b/mandible-extract/src/help_text/sections/usage.rs index 50c27047..7a08a712 100644 --- a/mandible-extract/src/help_text/sections/usage.rs +++ b/mandible-extract/src/help_text/sections/usage.rs @@ -210,6 +210,60 @@ pub(super) fn recover_stanza_head_flag(heading: &str, tool_name: Option<&str>) - Some(flag) } +/// Recover a stanza head's own *leading* flag when it takes an immediate +/// bare literal value and further required flags follow on the same line +/// — `lvcreate --type raid -L|--size Size[m|UNIT] VG`, where +/// [`looks_like_stanza_head_flag`] refuses because `-L|--size` reads as a +/// second flag token. [`recover_stanza_head_flag`] only ever recovers a +/// head naming exactly one flag; this recovers just the leading flag and +/// its literal value, leaving the line's other flags to whichever path +/// already documents them (an option table, or their own bracket row). +/// +/// Gated to a literal, lowercase value ([`is_literal_choice_value`]) so +/// this never fabricates a placeholder from a positional operand a +/// stanza head happens to write next to its own flag (`VG`, `Size[m| +/// UNIT]`) — only an enumerated literal like `raid`/`thin-pool` qualifies. +/// `merge::merge_entity_bucket` folds every literal value this and +/// [`recover_stanza_head_flag`]'s bracket-row siblings recover into one +/// `choices` list. See docs/shapes.md S-147. +/// +/// Gated to [`is_literal_choice_value`] — one word of lowercase ASCII +/// letters, digits and hyphens, nothing else, the shape `raid1`/ +/// `thin-pool` carry. Refused for any docopt notation glued onto the word +/// (`blkid`'s `[--match-tag`, a bracketed *reference* to a further flag +/// on the same head, not a value at all): a bracket, angle bracket, pipe +/// or leading dash means this token is punctuation the generic value walk +/// must not mistake for an enumerated literal. See docs/shapes.md S-089, +/// S-147. +pub(super) fn recover_stanza_head_leading_flag_value( + heading: &str, + tool_name: Option<&str>, +) -> Option { + let name = tool_name?; + if is_ignorable_heading(heading) || !starts_with_tool_name(heading, name) { + return None; + } + let rest = heading.strip_prefix(name)?.trim_start(); + let mut words = rest.split_whitespace(); + let flag_tok = words.next()?; + if !is_bare_flag_token(flag_tok) { + return None; + } + let value_tok = words.next()?; + if !is_literal_choice_value(value_tok) { + return None; + } + let spec = parse_flag_spec(flag_tok); + if spec.spellings.is_empty() { + return None; + } + let mut flag = Entity::new(EntityKind::Flag, Provenance::single(Source::HelpTextSynopsis)); + flag.spellings = spec.spellings; + flag.value_name = Some(value_tok.to_string()); + flag.value_kind = ValueKind::Required; + Some(flag) +} + /// Pull placeholder tokens (``, bare `UPPERCASE` words not preceded /// by `-`) out of usage lines as positionals. Best-effort: usage-line /// grammar is genuinely varied (docopt-style `[OPTIONS]`, ``, @@ -1010,6 +1064,54 @@ pub(super) fn flag_spelling_already_present(candidate: &Entity, existing: &[Enti }) } +/// True when `candidate` names a real, literal value distinct from every +/// existing entity that already shares its spelling — the S-147 shape, +/// not the ordinary duplicate [`flag_spelling_already_present`] exists to +/// drop. `lvchange`'s `-M|--persistent` reaches this: a stanza head +/// (`recover_stanza_head_leading_flag_value`) names it `y`, an unrelated +/// paren-alternation row names it `n`; without this check the stanza +/// head's own entity, already in `result.flags` by the time the usage +/// scan runs, would make `flag_spelling_already_present` drop `n` +/// outright, replacing a value that used to render with a different one +/// instead of letting both reach `merge_entity_bucket`'s own union. +/// +/// Refused when any existing same-spelling entity is a plain boolean +/// (`ValueKind::None`): `ssh-keygen`'s own `-F hostname [-lv] [...]` +/// bundles `-lv` into two boolean switches (`-l`, `-v`), and without this +/// guard a value-attaching heuristic elsewhere in this same document can +/// glue a stray literal onto `-l`'s spelling, which this override would +/// then wrongly admit as a second, "disagreeing" form instead of the +/// fabrication it is. A flag documented as boolean anywhere never also +/// gets a literal value here. +/// +/// Also refused when any existing same-spelling entity carries an +/// abbreviation-bracket spelling (`Spelling::abbrev`): `ip`'s own +/// `-b[atch] [filename]` is one single-dash long option abbreviated to +/// its first letter, already recovered correctly elsewhere as `-b[atch]` +/// with value `filename`. A later, unrelated pass in this same document +/// reads the bracket's own inner text as if `-b` took a bare value +/// `atch`, and without this guard that misparse would be admitted as a +/// second, "disagreeing" form rather than refused as the same flag read +/// twice. See docs/shapes.md S-147. +pub(super) fn usage_flag_names_a_new_literal_value(candidate: &Entity, existing: &[Entity]) -> bool { + let Some(name) = candidate.value_name.as_deref() else { + return false; + }; + if !is_literal_choice_value(name) { + return false; + } + let mut same_spelling = existing + .iter() + .filter(|f| flag_spelling_already_present(candidate, std::slice::from_ref(f))) + .peekable(); + same_spelling.peek().is_some() + && same_spelling.all(|f| { + f.value_name.as_deref() != Some(name) + && f.value_kind != ValueKind::None + && !f.spellings.iter().any(|s| s.abbrev.is_some()) + }) +} + /// Push the flag(s) one synopsis token names: either a bundle of /// single-character boolean switches, one [`Flag`] per member, or — for /// every other shape — the single flag [`parse_flag_spec`] reads. The diff --git a/xtask/src/corpus/contract.rs b/xtask/src/corpus/contract.rs index 88c6f280..11beb589 100644 --- a/xtask/src/corpus/contract.rs +++ b/xtask/src/corpus/contract.rs @@ -227,6 +227,7 @@ fn new_field_weakened_lines(label: &str, b: &ContractMeta, n: &ContractMeta) -> } } lines.extend(super::refill_contract::weakened_lines(label, b, n)); + lines.extend(super::refill_contract::choices_weakened_lines(label, b, n)); for spec in b.must_contain_positionals.iter() { let Some(base) = spec.strip_suffix("...") else { continue; diff --git a/xtask/src/corpus/mod.rs b/xtask/src/corpus/mod.rs index 5faee7f8..8415accb 100644 --- a/xtask/src/corpus/mod.rs +++ b/xtask/src/corpus/mod.rs @@ -337,6 +337,20 @@ pub(crate) struct ContractMeta { /// docs/shapes.md S-147. #[serde(default)] must_value_names_after_root_refill: std::collections::BTreeMap>, + /// Every value a root flag's `choices` must carry after this + /// fixture's root is refilled the same way + /// `must_value_names_after_root_refill` simulates. Keyed by the + /// flag's own spelling. `must_attach_choices` alone cannot state this + /// claim for a same-spelling merge bucket: it walks the raw, + /// unrefilled tree, where `--type`'s several invocation forms are + /// still separate entities and `.find()` sees only the first one's + /// own (possibly empty) `choices`. S-147 follow-up (ruled 2026-09-07 + /// "queue", docs/design.md §16): a bucket whose forms disagree about + /// a value name unions every literal one into `choices`, and this is + /// the only field that can see the union actually happened. See + /// `refill_contract.rs`. + #[serde(default)] + must_choices_after_root_refill: std::collections::BTreeMap>, /// Which dimensions of this fixture's tree a human actually verified /// before blessing it — machine-readable replacement for the /// "SCOPE OF REVIEW" prose comment (`git show c9bfe76`). Not itself a diff --git a/xtask/src/corpus/refill_contract.rs b/xtask/src/corpus/refill_contract.rs index cc9fdd33..5c3bb0f0 100644 --- a/xtask/src/corpus/refill_contract.rs +++ b/xtask/src/corpus/refill_contract.rs @@ -60,21 +60,9 @@ pub(crate) fn check_must_value_names_after_root_refill( if contract.must_value_names_after_root_refill.is_empty() { return failures; } - let Some(root) = root else { - failures.push(ContractFailure( - "must_value_names_after_root_refill: no root produced".into(), - )); + let Some(refilled) = refill(root, &mut failures, "must_value_names_after_root_refill") else { return failures; }; - let refilled = match mandible_core::merge_nodes(vec![root.clone(), root.clone()]) { - Ok(node) => node, - Err(e) => { - failures.push(ContractFailure(format!( - "must_value_names_after_root_refill: simulated refill failed: {e}" - ))); - return failures; - } - }; for (flag_spec, expected_substrings) in &contract.must_value_names_after_root_refill { match refilled .flags() @@ -101,3 +89,102 @@ pub(crate) fn check_must_value_names_after_root_refill( } failures } + +/// `must_choices_after_root_refill`'s own `CONTRACT WEAKENED` lines, the +/// twin of [`weakened_lines`] above for the `choices` half of S-147's +/// follow-up ruling. +pub(crate) fn choices_weakened_lines(label: &str, b: &ContractMeta, n: &ContractMeta) -> Vec { + let mut lines = Vec::new(); + for (flag, base_names) in &b.must_choices_after_root_refill { + match n.must_choices_after_root_refill.get(flag) { + None => lines.push(format!( + "CONTRACT WEAKENED: {label} must_choices_after_root_refill[{flag:?}] \ + (assertion removed)" + )), + Some(now_names) => { + for name in base_names { + if !now_names.contains(name) { + lines.push(format!( + "CONTRACT WEAKENED: {label} \ + must_choices_after_root_refill[{flag:?}] ({name:?} dropped)" + )); + } + } + } + } + } + lines +} + +/// The `choices` twin of [`check_must_value_names_after_root_refill`]: +/// asserts every named literal survives in the refilled flag's `choices` +/// list, not its `value_name`. S-147's own follow-up ruling (2026-09-07 +/// "queue") moves a same-spelling bucket's disagreeing literal values +/// out of `value_name` and into one unioned `choices` list, and +/// `must_attach_choices` alone cannot see that union: it walks the raw, +/// unrefilled tree, where `.find()` sees only the first invocation +/// form's own, usually-empty `choices`. +pub(crate) fn check_must_choices_after_root_refill( + contract: &ContractMeta, + root: Option<&CommandNode>, +) -> Vec { + let mut failures = Vec::new(); + if contract.must_choices_after_root_refill.is_empty() { + return failures; + } + let Some(refilled) = refill(root, &mut failures, "must_choices_after_root_refill") else { + return failures; + }; + for (flag_spec, expected_choices) in &contract.must_choices_after_root_refill { + match refilled + .flags() + .find(|f| entity_matches_flag_spec(f, flag_spec)) + { + None => failures.push(ContractFailure(format!( + "must_choices_after_root_refill[{flag_spec:?}]: flag not present after refill" + ))), + Some(entity) => { + let missing: Vec<&str> = expected_choices + .iter() + .filter(|c| !entity.choices.iter().any(|ch| &ch.name == *c)) + .map(|s| s.as_str()) + .collect(); + if !missing.is_empty() { + failures.push(ContractFailure(format!( + "must_choices_after_root_refill[{flag_spec:?}]: expected the merged \ + choices to include {missing:?}, got {:?}", + entity + .choices + .iter() + .map(|c| c.name.as_str()) + .collect::>() + ))); + } + } + } + } + failures +} + +/// Shared "no root"/"simulated refill failed" handling for both refill +/// checks above, keyed by the caller's own contract-field name so each +/// failure still names the field it belongs to. +fn refill( + root: Option<&CommandNode>, + failures: &mut Vec, + field: &str, +) -> Option { + let Some(root) = root else { + failures.push(ContractFailure(format!("{field}: no root produced"))); + return None; + }; + match mandible_core::merge_nodes(vec![root.clone(), root.clone()]) { + Ok(node) => Some(node), + Err(e) => { + failures.push(ContractFailure(format!( + "{field}: simulated refill failed: {e}" + ))); + None + } + } +} diff --git a/xtask/src/corpus/runner.rs b/xtask/src/corpus/runner.rs index 2aa47c6d..5db9bc9c 100644 --- a/xtask/src/corpus/runner.rs +++ b/xtask/src/corpus/runner.rs @@ -271,6 +271,10 @@ fn run_fixtures( root.as_ref(), ), ); + contract_failures.extend(super::refill_contract::check_must_choices_after_root_refill( + &fixture.meta.contract, + root.as_ref(), + )); let is_xfail = fixture.meta.xfail.as_ref().is_some_and(|x| x.broken); // A missing `expected.snap` is legal only for a fixture still From 24886f618da078cfe58eb35fa4703943eadeb1b6 Mon Sep 17 00:00:00 2001 From: Sadig Akhund Date: Sat, 12 Sep 2026 20:13:51 +0400 Subject: [PATCH 02/10] extract: read a usage form's optional-suffix leading word as a subcommand A usage form opening `g[dbserver]` names the subcommand `gdbserver` and keeps its source spelling as the display name. docs/shapes.md S-167. Co-Authored-By: Claude Fable 5.1 --- corpus/lldb-server/18.1.3/help.stderr.txt | 5 + corpus/lldb-server/18.1.3/help.txt | 0 corpus/lldb-server/18.1.3/meta.toml | 30 +++ .../src/help_text/sections/mod.rs | 18 +- .../help_text/sections/usage_optional_word.rs | 186 ++++++++++++++ xtask/src/coverage/aggregate.rs | 31 ++- xtask/src/coverage/mod.rs | 2 + xtask/src/coverage/render_markdown.rs | 11 + xtask/src/coverage/render_text.rs | 20 ++ xtask/src/coverage/score.rs | 86 +++++-- xtask/src/detector/detectors_misc.rs | 41 +++ xtask/src/detector/mod.rs | 1 + xtask/src/main.rs | 1 + xtask/src/usage_optional_word_table.rs | 233 ++++++++++++++++++ 14 files changed, 639 insertions(+), 26 deletions(-) create mode 100644 corpus/lldb-server/18.1.3/help.stderr.txt create mode 100644 corpus/lldb-server/18.1.3/help.txt create mode 100644 corpus/lldb-server/18.1.3/meta.toml create mode 100644 mandible-extract/src/help_text/sections/usage_optional_word.rs create mode 100644 xtask/src/usage_optional_word_table.rs diff --git a/corpus/lldb-server/18.1.3/help.stderr.txt b/corpus/lldb-server/18.1.3/help.stderr.txt new file mode 100644 index 00000000..bd8997d4 --- /dev/null +++ b/corpus/lldb-server/18.1.3/help.stderr.txt @@ -0,0 +1,5 @@ +Usage: + lldb-server v[ersion] + lldb-server g[dbserver] [options] + lldb-server p[latform] [options] +Invoke subcommand for additional help diff --git a/corpus/lldb-server/18.1.3/help.txt b/corpus/lldb-server/18.1.3/help.txt new file mode 100644 index 00000000..e69de29b diff --git a/corpus/lldb-server/18.1.3/meta.toml b/corpus/lldb-server/18.1.3/meta.toml new file mode 100644 index 00000000..a187f627 --- /dev/null +++ b/corpus/lldb-server/18.1.3/meta.toml @@ -0,0 +1,30 @@ +# S-167: a bare `Usage:` heading's rows repeat the tool's own name plus +# one subcommand word abbreviated with a bracket suffix. `version`, +# `gdbserver` and `platform` recover as subcommand nodes, never usage +# forms, each keeping its source spelling in `must_display_name` and +# staying invocation-attested only, so no probe follows (docs/design.md +# §16). The help text sits entirely on stderr; stdout is empty. + +[bless] +provenance = "agent" + +[tool] +name = "lldb-server" +version = "18.1.3" +platform = "ubuntu-24.04 (aarch64)" +captured_with = "mandible 0.7.0" + +[[capture]] +argv = ["lldb-server", "--help"] +stdout = "help.txt" +stderr = "help.stderr.txt" +exit_code = 0 + +[contract] +expected_framework = "generic" +min_subcommands = 3 + +[contract.must_display_name] +version = "v[ersion]" +gdbserver = "g[dbserver]" +platform = "p[latform]" diff --git a/mandible-extract/src/help_text/sections/mod.rs b/mandible-extract/src/help_text/sections/mod.rs index 54495ee5..c8fea80f 100644 --- a/mandible-extract/src/help_text/sections/mod.rs +++ b/mandible-extract/src/help_text/sections/mod.rs @@ -46,6 +46,7 @@ mod spelling; #[cfg(test)] mod test_support; mod usage; +mod usage_optional_word; use backfill::*; use bullets::*; @@ -62,6 +63,7 @@ use spelling::*; #[cfg(test)] use test_support::*; pub use usage::*; +use usage_optional_word::scan_usage_optional_word_table; /// Hard cap on distinct entries (subcommands, flags, or choices) accepted /// from a single probe's output. Real `--help` output never remotely @@ -1657,7 +1659,21 @@ fn parse_body( None }; let usage_start = labelled_usage_start.or(unlabelled_synopsis_start); - if let Some(start) = usage_start { + // A bare `Usage:` heading (nothing else on that line) whose following + // rows each repeat the tool's own name plus one command word with an + // optional-abbreviation suffix (`lldb-server`'s `v[ersion]`) names + // subcommands, not usage forms. Tried before the ordinary usage scan + // so it never gets a chance to fold these rows into `result.usage` + // instead. See docs/shapes.md S-167. + let optional_word_table = labelled_usage_start.and_then(|start| { + tool_name.and_then(|name| scan_usage_optional_word_table(&lines, start, name)) + }); + if let Some((end, nodes)) = optional_word_table { + i = end; + for node in nodes { + result.try_push_subcommand(node); + } + } else if let Some(start) = usage_start { let scan = scan_usage_section( &lines, start, diff --git a/mandible-extract/src/help_text/sections/usage_optional_word.rs b/mandible-extract/src/help_text/sections/usage_optional_word.rs new file mode 100644 index 00000000..c88307a6 --- /dev/null +++ b/mandible-extract/src/help_text/sections/usage_optional_word.rs @@ -0,0 +1,186 @@ +//! F11 / docs/shapes.md S-167: a `Usage:` line's leading word is a +//! subcommand spelled with an optional abbreviation suffix — +//! `lldb-server`'s `v[ersion]`, `g[dbserver]`, `p[latform]`. Distinct from +//! S-020's modifier table (`ar`'s `r[ab][f][u]`): there the bracket groups +//! name separate modifier LETTERS glued onto a command letter; here one +//! bracket group's content, appended to the single letter before it, spells +//! out the rest of ONE whole command word. The node's name is that whole +//! word; `display_name` keeps the row's own spelling. Never confused with +//! S-020, which is a `commands:`-heading row scanned by a different code +//! path this function is never reached from. + +use super::usage::{starts_with_tool_name, starts_with_tool_name_spelled_differently}; +use mandible_core::{is_command_name_shaped, CommandNode, Provenance, Source}; + +/// `token` reads as one command word with an optional-abbreviation suffix +/// only when it carries exactly one bracket group, opened right after a +/// single leading lowercase letter, holding nothing but lowercase letters, +/// and closing at the token's own end. `ar`'s `r[ab][f][u]` fails this (a +/// second group follows the first), so the two shapes never collide. +fn optional_abbrev_word(token: &str) -> Option<(String, String)> { + let mut chars = token.chars(); + let lead = chars.next()?; + if !lead.is_ascii_lowercase() { + return None; + } + let rest = &token[lead.len_utf8()..]; + let inner = rest.strip_prefix('[')?.strip_suffix(']')?; + if inner.is_empty() || inner.contains(['[', ']']) { + return None; + } + if !inner.chars().all(|c| c.is_ascii_lowercase()) { + return None; + } + let whole = format!("{lead}{inner}"); + if !is_command_name_shaped(&whole) { + return None; + } + Some((whole, token.to_string())) +} + +/// One recognized row: the tool's own name (in whatever spelling it printed +/// itself under), then a word matched by [`optional_abbrev_word`], then +/// zero or more further tokens that must each be a single bracketed +/// lowercase word (`[options]`) — anything else and the whole row is +/// refused rather than partially accepted. +fn parse_row(line: &str, tool_name: &str) -> Option<(String, String)> { + let t = line.trim(); + let is_own_name = starts_with_tool_name(t, tool_name) + || starts_with_tool_name_spelled_differently(t, tool_name); + if !is_own_name { + return None; + } + let mut words = t.split_whitespace(); + words.next()?; // the tool's own name, already confirmed above + let first = words.next()?; + let (name, display) = optional_abbrev_word(first)?; + for trailing in words { + let inner = trailing + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']'))?; + if inner.is_empty() || !inner.chars().all(|c| c.is_ascii_lowercase()) { + return None; + } + } + Some((name, display)) +} + +/// The fewest recognized rows before this shape is trusted at all: one row +/// alone is too cheap a coincidence to act on. `lldb-server` documents +/// three. See docs/design.md §16 — below AGENTS.md §3.1's five-tool bar, +/// shipped as a recorded exception the way S-103/S-104/S-143 were. +const MIN_ROWS: usize = 2; + +/// Scan the labelled `Usage:` block starting at `heading_idx` (the bare +/// `Usage:` line itself, with nothing else on it) for this shape. +/// `Some((end, nodes))` only when every line from `heading_idx + 1` parses +/// as a row up to the first line that does not, and at least [`MIN_ROWS`] +/// did — a mixed or under-populated run is refused whole, never partially +/// accepted, so an ordinary usage synopsis is never swallowed by a guess. +/// `end` is the index of the first line NOT consumed. +pub(super) fn scan_usage_optional_word_table( + lines: &[&str], + heading_idx: usize, + tool_name: &str, +) -> Option<(usize, Vec)> { + if !lines[heading_idx].trim().eq_ignore_ascii_case("usage:") { + return None; + } + let mut i = heading_idx + 1; + let mut rows: Vec<(String, String)> = Vec::new(); + while let Some(&line) = lines.get(i) { + if line.trim().is_empty() { + break; + } + let Some(row) = parse_row(line, tool_name) else { + break; + }; + rows.push(row); + i += 1; + } + if rows.len() < MIN_ROWS { + return None; + } + let nodes = rows + .into_iter() + .map(|(name, display)| { + let mut node = CommandNode::new(name.clone(), Provenance::single(Source::HelpText)); + // §7 Tier B rule 8 / §6 rule 0: a name read off a usage form is + // invocation-attested only, never heading-attested, so no probe + // is ever sent under it (docs/design.md §16). + node.invocation_attested = true; + node.heading_attested = false; + node.children_filled = false; + if display != name { + node.display_name = Some(display); + } + node + }) + .collect(); + Some((i, nodes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const LLDB_SERVER: &str = "Usage:\n lldb-server v[ersion]\n lldb-server g[dbserver] [options]\n lldb-server p[latform] [options]\nInvoke subcommand for additional help\n"; + + #[test] + fn recovers_three_named_nodes_with_source_spelling_preserved() { + let lines: Vec<&str> = LLDB_SERVER.lines().collect(); + let (end, nodes) = scan_usage_optional_word_table(&lines, 0, "lldb-server").unwrap(); + assert_eq!(end, 4, "stops at the trailing prose sentence"); + assert_eq!(nodes.len(), 3); + assert_eq!(nodes[0].name, "version"); + assert_eq!(nodes[0].display_name.as_deref(), Some("v[ersion]")); + assert!(nodes[0].invocation_attested); + assert!(!nodes[0].heading_attested); + assert_eq!(nodes[1].name, "gdbserver"); + assert_eq!(nodes[1].display_name.as_deref(), Some("g[dbserver]")); + assert_eq!(nodes[2].name, "platform"); + assert_eq!(nodes[2].display_name.as_deref(), Some("p[latform]")); + } + + #[test] + fn matches_the_resolved_full_path_spelling_too() { + let raw = "Usage:\n /usr/bin/lldb-server v[ersion]\n /usr/bin/lldb-server g[dbserver] [options]\n /usr/bin/lldb-server p[latform] [options]\n"; + let lines: Vec<&str> = raw.lines().collect(); + let (_, nodes) = scan_usage_optional_word_table(&lines, 0, "lldb-server").unwrap(); + assert_eq!(nodes.len(), 3); + } + + #[test] + fn refuses_ars_modifier_shape_a_second_bracket_group_follows() { + assert_eq!(optional_abbrev_word("r[ab][f][u]"), None); + } + + #[test] + fn accepts_a_single_bracket_group_that_spells_a_whole_word() { + assert_eq!( + optional_abbrev_word("v[ersion]"), + Some(("version".to_string(), "v[ersion]".to_string())) + ); + } + + #[test] + fn refuses_a_single_row_below_the_min_rows_floor() { + let raw = "Usage:\n lldb-server v[ersion]\n"; + let lines: Vec<&str> = raw.lines().collect(); + assert!(scan_usage_optional_word_table(&lines, 0, "lldb-server").is_none()); + } + + #[test] + fn refuses_a_mixed_run_whole_rather_than_partially() { + let raw = "Usage:\n lldb-server v[ersion]\n lldb-server [options] \n"; + let lines: Vec<&str> = raw.lines().collect(); + assert!(scan_usage_optional_word_table(&lines, 0, "lldb-server").is_none()); + } + + #[test] + fn refuses_a_labelled_usage_line_carrying_its_own_synopsis() { + let raw = "Usage: foo [OPTIONS]\n foo v[ersion]\n foo g[dbserver]\n"; + let lines: Vec<&str> = raw.lines().collect(); + assert!(scan_usage_optional_word_table(&lines, 0, "foo").is_none()); + } +} diff --git a/xtask/src/coverage/aggregate.rs b/xtask/src/coverage/aggregate.rs index c40dcbb6..5dd43d57 100644 --- a/xtask/src/coverage/aggregate.rs +++ b/xtask/src/coverage/aggregate.rs @@ -211,6 +211,13 @@ pub struct Aggregate { pub centered_label_baseline_tools: usize, /// Real rows lost to that shape, fleet-wide — one per finding. pub centered_label_baseline_flags: usize, + /// Tools with at least one [`crate::usage_optional_word_table`] + /// finding — a bare `Usage:` block's row abbreviating a subcommand + /// with a bracket suffix (`v[ersion]`), missing from the tree (atlas + /// S-167). + pub usage_optional_word_tools: usize, + /// Abbreviated words lost to that shape, fleet-wide — one per finding. + pub usage_optional_word_flags: usize, } /// Compute aggregate stats over `rows`. @@ -292,6 +299,11 @@ pub(super) fn compute_aggregate(rows: &[Row]) -> Aggregate { let wrapped_command_flags: usize = rows.iter().map(|r| r.wrapped_command_count).sum(); let command_pattern_tools = rows.iter().filter(|r| r.command_pattern_count > 0).count(); let command_pattern_flags: usize = rows.iter().map(|r| r.command_pattern_count).sum(); + let usage_optional_word_tools = rows + .iter() + .filter(|r| r.usage_optional_word_count > 0) + .count(); + let usage_optional_word_flags: usize = rows.iter().map(|r| r.usage_optional_word_count).sum(); let centered_label_baseline_tools = rows .iter() .filter(|r| r.centered_label_baseline_count > 0) @@ -344,6 +356,8 @@ pub(super) fn compute_aggregate(rows: &[Row]) -> Aggregate { wrapped_command_flags, command_pattern_tools, command_pattern_flags, + usage_optional_word_tools, + usage_optional_word_flags, centered_label_baseline_tools, centered_label_baseline_flags, } @@ -408,7 +422,7 @@ pub(super) fn detection_rate_pct(aggregate: &Aggregate) -> f64 { /// `coverage-scoreboard.txt`). pub(super) fn aggregate_footer_line(aggregate: &Aggregate) -> String { format!( - "# aggregate: pct_flags_with_text={:.2} no_tier_count={} suspicious_count={} verbatim_count={} incomplete_count={} man_shaped_count={} zero_flag_ok_count={} misattribution_suspect_tools={} misattribution_column_aligned_tools={} existence_fabrication_tools={} bundle_collapse_tools={} bundle_destroyed_flags={} alternation_defect_tools={} alternation_defect_flags={} command_table_tools={} single_dash_split_tools={} single_dash_split_flags={} repeated_char_tools={} repeated_char_flags={} wrapped_prose_tools={} wrapped_prose_flags={} tail_operand_tools={} tail_operand_flags={} vim_family={} ragged_command_tools={} ragged_command_flags={} wrapped_command_tools={} wrapped_command_flags={} command_pattern_tools={} command_pattern_flags={} centered_label_baseline_tools={} centered_label_baseline_flags={} total={} described_flags={:.4} describable_flags={:.4} total_flags={}\n", + "# aggregate: pct_flags_with_text={:.2} no_tier_count={} suspicious_count={} verbatim_count={} incomplete_count={} man_shaped_count={} zero_flag_ok_count={} misattribution_suspect_tools={} misattribution_column_aligned_tools={} existence_fabrication_tools={} bundle_collapse_tools={} bundle_destroyed_flags={} alternation_defect_tools={} alternation_defect_flags={} command_table_tools={} single_dash_split_tools={} single_dash_split_flags={} repeated_char_tools={} repeated_char_flags={} wrapped_prose_tools={} wrapped_prose_flags={} tail_operand_tools={} tail_operand_flags={} vim_family={} ragged_command_tools={} ragged_command_flags={} wrapped_command_tools={} wrapped_command_flags={} command_pattern_tools={} command_pattern_flags={} usage_optional_word_tools={} usage_optional_word_flags={} centered_label_baseline_tools={} centered_label_baseline_flags={} total={} described_flags={:.4} describable_flags={:.4} total_flags={}\n", aggregate.pct_flags_with_text, aggregate.no_tier_count, aggregate.suspicious_count, @@ -439,6 +453,8 @@ pub(super) fn aggregate_footer_line(aggregate: &Aggregate) -> String { aggregate.wrapped_command_flags, aggregate.command_pattern_tools, aggregate.command_pattern_flags, + aggregate.usage_optional_word_tools, + aggregate.usage_optional_word_flags, aggregate.centered_label_baseline_tools, aggregate.centered_label_baseline_flags, aggregate.total, @@ -611,6 +627,11 @@ pub fn parse_aggregate_footer(scoreboard: &str) -> Option { // no such key. let mut centered_label_baseline_tools = 0usize; let mut centered_label_baseline_flags = 0usize; + // Same reasoning again, brand new field (round 10): a scoreboard + // written before the usage-optional-word-table detector existed + // carries no such key. + let mut usage_optional_word_tools = 0usize; + let mut usage_optional_word_flags = 0usize; for field in line.trim_start_matches("# aggregate:").split_whitespace() { let (key, value) = field.split_once('=')?; match key { @@ -659,6 +680,12 @@ pub fn parse_aggregate_footer(scoreboard: &str) -> Option { "wrapped_command_flags" => wrapped_command_flags = value.parse::().ok()?, "command_pattern_tools" => command_pattern_tools = value.parse::().ok()?, "command_pattern_flags" => command_pattern_flags = value.parse::().ok()?, + "usage_optional_word_tools" => { + usage_optional_word_tools = value.parse::().ok()? + } + "usage_optional_word_flags" => { + usage_optional_word_flags = value.parse::().ok()? + } "centered_label_baseline_tools" => { centered_label_baseline_tools = value.parse::().ok()? } @@ -709,6 +736,8 @@ pub fn parse_aggregate_footer(scoreboard: &str) -> Option { wrapped_command_flags, command_pattern_tools, command_pattern_flags, + usage_optional_word_tools, + usage_optional_word_flags, centered_label_baseline_tools, centered_label_baseline_flags, }) diff --git a/xtask/src/coverage/mod.rs b/xtask/src/coverage/mod.rs index 468324bd..0ac8f1e2 100644 --- a/xtask/src/coverage/mod.rs +++ b/xtask/src/coverage/mod.rs @@ -212,6 +212,8 @@ fn row(tool: &str, flags: usize, pct_flags_with_text: Option, status: &'sta wrapped_command_samples: Vec::new(), command_pattern_count: 0, command_pattern_samples: Vec::new(), + usage_optional_word_count: 0, + usage_optional_word_samples: Vec::new(), centered_label_baseline_count: 0, centered_label_baseline_samples: Vec::new(), status, diff --git a/xtask/src/coverage/render_markdown.rs b/xtask/src/coverage/render_markdown.rs index a205d88e..edabd51c 100644 --- a/xtask/src/coverage/render_markdown.rs +++ b/xtask/src/coverage/render_markdown.rs @@ -147,6 +147,16 @@ fn command_pattern_sample_section_markdown(rows: &[Row]) -> String { ) } +/// Markdown twin of [`usage_optional_word_sample_lines_text`]. +fn usage_optional_word_sample_section_markdown(rows: &[Row]) -> String { + sample_section_markdown( + rows.iter() + .flat_map(|r| r.usage_optional_word_samples.iter()), + "\n**Usage-optional-word-table findings** (sample — see \ + `xtask/src/usage_optional_word_table.rs`):\n\n| sample |\n|---|\n", + ) +} + /// The one-paragraph-per-family prose block for the four command-table /// detectors (S-104/S-103/S-141/S-149), factored out of /// [`render_markdown`] to stay under its own line ceiling (AGENTS.md §2). @@ -389,6 +399,7 @@ pub(super) fn render_markdown(rows: &[Row], aggregate: &Aggregate) -> String { out.push_str(&ragged_command_sample_section_markdown(rows)); out.push_str(&wrapped_command_sample_section_markdown(rows)); out.push_str(&command_pattern_sample_section_markdown(rows)); + out.push_str(&usage_optional_word_sample_section_markdown(rows)); out.push_str(¢ered_label_baseline_sample_section_markdown(rows)); // The same machine-readable footer the text format carries, wrapped in // an HTML comment so it stays invisible when rendered but parseable by diff --git a/xtask/src/coverage/render_text.rs b/xtask/src/coverage/render_text.rs index 6cfb447b..ae1d69a8 100644 --- a/xtask/src/coverage/render_text.rs +++ b/xtask/src/coverage/render_text.rs @@ -224,6 +224,14 @@ pub(super) struct Row { /// A few of this row's own findings, pre-formatted, mirroring /// [`Self::wrapped_command_samples`]. pub(super) command_pattern_samples: Vec, + /// [`crate::usage_optional_word_table`]'s own measurement: count of + /// this tool's bare-`Usage:`-block rows abbreviating a subcommand word + /// with a bracket suffix, whose word never reached the tree (atlas + /// S-167). + pub(super) usage_optional_word_count: usize, + /// A few of this row's own findings, pre-formatted, mirroring + /// [`Self::command_pattern_samples`]. + pub(super) usage_optional_word_samples: Vec, /// [`crate::centered_label_baseline::detect_tree`]'s own measurement: /// count of this tool's shallower rows, right after a centered ALL-CAPS /// group label, whose leading word never reached the tree as a @@ -381,6 +389,7 @@ pub(super) fn render_text(rows: &[Row], aggregate: &Aggregate) -> String { out.push_str(&ragged_command_sample_lines_text(rows)); out.push_str(&wrapped_command_sample_lines_text(rows)); out.push_str(&command_pattern_sample_lines_text(rows)); + out.push_str(&usage_optional_word_sample_lines_text(rows)); out.push_str(¢ered_label_baseline_sample_lines_text(rows)); out.push_str(&fingerprint_lines(rows)); out @@ -653,6 +662,17 @@ fn command_pattern_sample_lines_text(rows: &[Row]) -> String { ) } +/// Twin of [`single_dash_sample_lines_text`] for +/// [`crate::usage_optional_word_table`]. +fn usage_optional_word_sample_lines_text(rows: &[Row]) -> String { + sample_lines_text( + rows.iter() + .flat_map(|r| r.usage_optional_word_samples.iter()), + "# usage-optional-word-table findings (sample — judge the false-positive rate \ + yourself):\n", + ) +} + /// Twin of [`single_dash_sample_lines_text`] for /// [`crate::centered_label_baseline`]. fn centered_label_baseline_sample_lines_text(rows: &[Row]) -> String { diff --git a/xtask/src/coverage/score.rs b/xtask/src/coverage/score.rs index ba7466c3..c46a1d47 100644 --- a/xtask/src/coverage/score.rs +++ b/xtask/src/coverage/score.rs @@ -172,18 +172,14 @@ pub(super) fn score_one(tool: &str) -> Row { } _ => (0, Vec::new()), }; - // Fourth read of the same already-fetched capture, still zero probes - // — `crate::commandtable`'s shape is visible in the text the sweep - // already has, exactly like the three detectors above. + // Fourth read of the same capture, zero probes — `crate::commandtable`. let command_table_count = match (probe.root_help_text(), result.root.as_ref()) { (Some(raw), Some(root)) if !raw.trim().is_empty() => { crate::commandtable::detect(&raw, root).missing.len() } _ => 0, }; - // The two remaining families of the three that share the `short && - // !long && value_name` fingerprint, read off the same capture on the - // same pass and costing the same zero additional subprocess spawns — + // The other two of the three `short && !long && value_name` families — // see `split_family_counts`. let ( single_dash_split_count, @@ -200,10 +196,7 @@ pub(super) fn score_one(tool: &str) -> Row { wrapped_command_count, wrapped_command_samples, ) = ragged_family_detector_counts(probe.root_help_text(), result.root.as_ref()); - let (command_pattern_count, command_pattern_samples) = - command_pattern_counts(probe.root_help_text(), result.root.as_ref()); - let (centered_label_baseline_count, centered_label_baseline_samples) = - crate::centered_label_baseline::score_counts(probe.root_help_text(), result.root.as_ref()); + let pattern_word = command_table_family_counts(probe.root_help_text(), result.root.as_ref()); Row { tool: tool.to_string(), tiers: tiers_label, @@ -240,10 +233,12 @@ pub(super) fn score_one(tool: &str) -> Row { ragged_command_samples, wrapped_command_count, wrapped_command_samples, - command_pattern_count, - command_pattern_samples, - centered_label_baseline_count, - centered_label_baseline_samples, + command_pattern_count: pattern_word.command_pattern_count, + command_pattern_samples: pattern_word.command_pattern_samples, + usage_optional_word_count: pattern_word.usage_optional_word_count, + usage_optional_word_samples: pattern_word.usage_optional_word_samples, + centered_label_baseline_count: pattern_word.centered_label_baseline_count, + centered_label_baseline_samples: pattern_word.centered_label_baseline_samples, status: status.label, fingerprint: build_fingerprint(result.root.as_ref()), } @@ -785,25 +780,68 @@ fn format_command_pattern_sample(finding: &command_pattern_table::Finding) -> St ) } -/// [`command_pattern_table::detect`], run over one tool's already-captured -/// text and tree — same zero-additional-probe reasoning as -/// [`ragged_family_detector_counts`], kept as its own function since this -/// family has no sibling to share a tuple return with. -fn command_pattern_counts(raw: Option, root: Option<&CommandNode>) -> (usize, Vec) { +/// One usage-optional-word-table finding, rendered as a single +/// audit-section line. +fn format_usage_optional_word_sample( + finding: &crate::usage_optional_word_table::Finding, +) -> String { + format!("{:?} (name {:?}) missing", finding.display, finding.name) +} + +/// [`command_table_family_counts`]'s return: one `(count, samples)` pair +/// per command-table-shaped family it bundles, named rather than +/// positional so its call site in [`score_one`] stays a single short +/// binding instead of a multi-line tuple destructure (AGENTS.md §2's line +/// ceiling). +#[derive(Default)] +struct CommandTableFamilyCounts { + command_pattern_count: usize, + command_pattern_samples: Vec, + usage_optional_word_count: usize, + usage_optional_word_samples: Vec, + centered_label_baseline_count: usize, + centered_label_baseline_samples: Vec, +} + +/// [`command_pattern_table::detect`], [`crate::usage_optional_word_table::detect`] +/// and [`crate::centered_label_baseline::score_counts`], run over one +/// tool's already-captured text and tree — same zero-additional-probe +/// reasoning as [`ragged_family_detector_counts`], bundled into one struct +/// return for the same line-count reason as that function. +fn command_table_family_counts( + raw: Option, + root: Option<&CommandNode>, +) -> CommandTableFamilyCounts { let (Some(raw), Some(root)) = (raw, root) else { - return (0, Vec::new()); + return CommandTableFamilyCounts::default(); }; if raw.trim().is_empty() { - return (0, Vec::new()); + return CommandTableFamilyCounts::default(); } - let report = command_pattern_table::detect(&raw, root); - let samples = report + let pattern_report = command_pattern_table::detect(&raw, root); + let pattern_samples = pattern_report .findings .iter() .take(FAMILY_DETECTOR_SAMPLES_PER_ROW) .map(format_command_pattern_sample) .collect(); - (report.finding_count(), samples) + let word_report = crate::usage_optional_word_table::detect(&raw, root); + let word_samples = word_report + .findings + .iter() + .take(FAMILY_DETECTOR_SAMPLES_PER_ROW) + .map(format_usage_optional_word_sample) + .collect(); + let (centered_label_baseline_count, centered_label_baseline_samples) = + crate::centered_label_baseline::score_counts(Some(raw), Some(root)); + CommandTableFamilyCounts { + command_pattern_count: pattern_report.finding_count(), + command_pattern_samples: pattern_samples, + usage_optional_word_count: word_report.finding_count(), + usage_optional_word_samples: word_samples, + centered_label_baseline_count, + centered_label_baseline_samples, + } } /// One centered-label-baseline finding, rendered as a single audit-section diff --git a/xtask/src/detector/detectors_misc.rs b/xtask/src/detector/detectors_misc.rs index 84c4540b..191a6b40 100644 --- a/xtask/src/detector/detectors_misc.rs +++ b/xtask/src/detector/detectors_misc.rs @@ -570,3 +570,44 @@ impl Detector for CommandPatternTable { crate::command_pattern_table::self_checks() } } + +/// `usage-optional-word-table` (`crate::usage_optional_word_table`, atlas +/// S-167): a bare `Usage:` block's own rows repeat the tool's name plus +/// one command word abbreviated with a bracket suffix (`lldb-server`'s +/// `v[ersion]`), which reads as ordinary usage text unless the tree +/// already carries each word as a subcommand with its source spelling. +pub(crate) struct UsageOptionalWordTable; + +impl Detector for UsageOptionalWordTable { + fn name(&self) -> &'static str { + "usage-optional-word-table" + } + fn family(&self) -> Option<&'static str> { + // No label in the closed `DEFECT_FAMILIES` set names this specific + // shape; docs/design.md §13.1e rule 6 makes that a legitimate + // `NotEvaluable` answer rather than a forced nearest match. + None + } + fn describes(&self) -> &'static str { + "a bare `Usage:` block whose rows repeat the tool's own name plus one command word \ + abbreviated with a single bracket suffix (`v[ersion]`), missing from the tree as a \ + subcommand" + } + fn hits(&self, evidence: &ToolEvidence<'_>) -> Vec { + crate::usage_optional_word_table::detect(evidence.raw, evidence.root) + .findings + .iter() + .map(|f| format!("{:?} (name {:?}) missing", f.display, f.name)) + .collect() + } + fn scope(&self) -> Scope { + Scope { + claim: "a bare `Usage:` heading whose rows are ` []`, \ + optionally followed by a lone bracket group, only", + known_exclusions: &[], + } + } + fn self_checks(&self) -> Vec { + crate::usage_optional_word_table::self_checks() + } +} diff --git a/xtask/src/detector/mod.rs b/xtask/src/detector/mod.rs index 15c03797..ee55ddf8 100644 --- a/xtask/src/detector/mod.rs +++ b/xtask/src/detector/mod.rs @@ -748,6 +748,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(UsageOptionalWordTable), ] } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 6f6e31e3..c1cb9899 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -37,6 +37,7 @@ mod status; mod tail_operand; mod transition; mod usage_only_value_name; +mod usage_optional_word_table; mod wrapped_command_continuation; mod wrapped_prose; diff --git a/xtask/src/usage_optional_word_table.rs b/xtask/src/usage_optional_word_table.rs new file mode 100644 index 00000000..248352fe --- /dev/null +++ b/xtask/src/usage_optional_word_table.rs @@ -0,0 +1,233 @@ +//! The `usage-optional-word-table` detector (atlas S-167): a `Usage:` +//! block's own rows repeat the tool's name plus one command word +//! abbreviated with a bracket suffix (`lldb-server`'s `v[ersion]`, +//! `g[dbserver]`, `p[latform]`), which a parser reading the block as an +//! ordinary usage synopsis folds into `usage` instead of recovering as +//! subcommands. Independent of `mandible-extract`'s own recognizer +//! (`help_text::sections::usage_optional_word`): this module re-derives +//! the raw shape from the captured bytes alone and checks the tree for +//! absence, the same separation every other detector in this crate keeps. + +use mandible_core::CommandNode; + +/// `token` reads as one command word abbreviated by a single bracket +/// group opened right after one leading lowercase letter (`v[ersion]`). +/// Mirrors `mandible-extract`'s own `optional_abbrev_word` shape test, +/// kept as an independent copy since that crate cannot import this one. +fn looks_like_optional_abbrev_word(token: &str) -> bool { + let mut chars = token.chars(); + let Some(lead) = chars.next() else { + return false; + }; + if !lead.is_ascii_lowercase() { + return false; + } + let rest = &token[lead.len_utf8()..]; + let Some(inner) = rest.strip_prefix('[').and_then(|s| s.strip_suffix(']')) else { + return false; + }; + !inner.is_empty() && inner.chars().all(|c| c.is_ascii_lowercase()) +} + +/// The recovered word (`v[ersion]` -> `version`) for a token +/// [`looks_like_optional_abbrev_word`] admits. +fn abbrev_word_name(token: &str) -> String { + let mut chars = token.chars(); + let lead = chars.next().expect("checked non-empty by the caller"); + let rest = &token[lead.len_utf8()..]; + let inner = rest + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .expect("checked by looks_like_optional_abbrev_word"); + format!("{lead}{inner}") +} + +/// True when `first` is `name` itself or names `name` under a different +/// spelling (a full path, or `name`'s own dotted stem) — the same +/// tolerance `mandible-extract`'s `starts_with_tool_name_spelled_differently` +/// keeps, reimplemented narrowly here. +fn names_the_tool(first: &str, name: &str) -> bool { + let basename = first.rsplit('/').next().unwrap_or(first); + basename == name || basename == name.split('.').next().unwrap_or(name) +} + +/// One raw row this detector's own grammar recognizes, whose recovered +/// name is missing from the tree. +pub struct Finding { + pub name: String, + pub display: String, +} + +pub struct Report { + pub findings: Vec, +} + +impl Report { + pub fn finding_count(&self) -> usize { + self.findings.len() + } +} + +const MIN_ROWS: usize = 2; + +/// Whether `root`'s own subcommands already carry a node named `name` +/// with `display_name` (or a bare `name` when the row carried no bracket +/// suffix — never this shape, since every row here does) matching +/// `display`. +fn tree_attests(root: &CommandNode, name: &str, display: &str) -> bool { + root.subcommands + .iter() + .any(|c| c.name == name && c.display_name.as_deref() == Some(display)) +} + +pub fn detect(raw: &str, root: &CommandNode) -> Report { + let lines: Vec<&str> = raw.lines().collect(); + let Some(heading_idx) = lines + .iter() + .position(|l| l.trim().eq_ignore_ascii_case("usage:")) + else { + return Report { + findings: Vec::new(), + }; + }; + let mut rows: Vec<(String, String)> = Vec::new(); + let mut i = heading_idx + 1; + while let Some(&line) = lines.get(i) { + let t = line.trim(); + if t.is_empty() { + break; + } + let mut words = t.split_whitespace(); + let Some(first) = words.next() else { + break; + }; + if !names_the_tool(first, &root.name) { + break; + } + let Some(word) = words.next() else { + break; + }; + if !looks_like_optional_abbrev_word(word) { + break; + } + if !words.all(|w| w.starts_with('[') && w.ends_with(']')) { + break; + } + rows.push((abbrev_word_name(word), word.to_string())); + i += 1; + } + if rows.len() < MIN_ROWS { + return Report { + findings: Vec::new(), + }; + } + let findings = rows + .into_iter() + .filter(|(name, display)| !tree_attests(root, name, display)) + .map(|(name, display)| Finding { name, display }) + .collect(); + Report { findings } +} + +// ---------------------------------------------------------------------- +// Self-checks +// ---------------------------------------------------------------------- + +use crate::detector::{Expect, SelfCheck}; +use mandible_core::{Provenance, Source}; + +pub(crate) const LLDB_SERVER_USAGE: &str = "Usage:\n lldb-server v[ersion]\n lldb-server g[dbserver] [options]\n lldb-server p[latform] [options]\nInvoke subcommand for additional help\n"; + +fn node(name: &str) -> CommandNode { + CommandNode::new(name, Provenance::single(Source::HelpText)) +} + +pub(crate) fn self_checks() -> Vec { + vec![ + SelfCheck { + name: "lldb-server's own bytes, a bare tree missing all three subcommands", + why: "the defect itself: a tool whose only structure is a bare `Usage:` block \ + reads as ordinary usage text, not subcommands, unless the tree already \ + carries the three abbreviated words", + expect: Expect::Fires(3), + raw: LLDB_SERVER_USAGE.to_string(), + root: node("lldb-server"), + }, + SelfCheck { + name: "a correctly repaired tree", + why: "once every row's own word reaches the tree with its source spelling as \ + `display_name`, the detector has nothing left to report", + expect: Expect::Silent, + raw: LLDB_SERVER_USAGE.to_string(), + root: { + let mut root = node("lldb-server"); + let mut version = node("version"); + version.display_name = Some("v[ersion]".to_string()); + let mut gdbserver = node("gdbserver"); + gdbserver.display_name = Some("g[dbserver]".to_string()); + let mut platform = node("platform"); + platform.display_name = Some("p[latform]".to_string()); + root.subcommands = vec![version, gdbserver, platform]; + root + }, + }, + SelfCheck { + name: "an ordinary usage line with real flags, not this shape", + why: "a genuine usage synopsis (`Usage: foo [OPTIONS] `) carries content on \ + the `Usage:` line itself and must never be misread as this bare-heading \ + shape", + expect: Expect::Silent, + raw: "Usage: foo [OPTIONS] \n".to_string(), + root: node("foo"), + }, + SelfCheck { + name: "ar's modifier-table shape, never mistaken for this one", + why: "`r[ab][f][u]` opens a SECOND bracket group right after the first closes, \ + which this detector's own single-bracket-group grammar refuses; ar's shape \ + reaches the tree through the unrelated `commands:`-heading path, never a \ + bare `Usage:` block", + expect: Expect::Silent, + raw: "Usage:\n ar r[ab][f][u]\n ar m[ab]\n".to_string(), + root: node("ar"), + }, + SelfCheck { + name: "a single row is too cheap a coincidence to act on", + why: "one abbreviated word alone could be a coincidence; the floor is two, the \ + same floor the parser itself uses", + expect: Expect::Silent, + raw: "Usage:\n foo v[ersion]\n".to_string(), + root: node("foo"), + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fires_on_lldb_servers_own_bytes_against_a_bare_tree() { + let report = detect(LLDB_SERVER_USAGE, &node("lldb-server")); + assert_eq!(report.finding_count(), 3); + } + + #[test] + fn every_self_check_holds() { + for case in self_checks() { + let expected = case.expect.expected_hits(); + let report = detect(&case.raw, &case.root); + assert_eq!( + report.finding_count(), + expected, + "{}: expected {} finding(s), got {:?}", + case.name, + expected, + report + .findings + .iter() + .map(|f| &f.display) + .collect::>() + ); + } + } +} From b45df4e2bc9214e7f695b0e15fcdb2300a6036d8 Mon Sep 17 00:00:00 2001 From: Sadig Akhund Date: Sat, 12 Sep 2026 20:36:45 +0400 Subject: [PATCH 03/10] core: same-spelling merge stays one row, non-lvm2 duplicates revert Same-spelling entities still collapse to one merged flag; only that one flag's value_name/choices resolution changed. A new leading-value recognizer's own duplicate-value allowance is now scoped to its own recoveries only, so ssh-keygen, fc-validate, iptables-apply, ip6tables-apply and pod2man revert to their pre-fbf1822 trees. docs/shapes.md S-147. Co-Authored-By: Claude Fable 5.1 --- corpus/fc-validate/audit-seed2/expected.snap | 14 --- .../ip6tables-apply/audit-seed2/expected.snap | 7 -- .../iptables-apply/audit-seed2/expected.snap | 7 -- corpus/pod2man/5.01/expected.snap | 7 -- corpus/ssh-keygen/9.6p1/expected.snap | 56 --------- corpus/ssh-keygen/audit-seed2/expected.snap | 56 --------- docs/shapes.md | 29 +++-- mandible-core/src/entity.rs | 22 ++-- mandible-core/src/merge.rs | 114 +++++++++--------- .../src/help_text/sections/mod.rs | 58 ++++++++- .../src/help_text/sections/usage.rs | 81 ++----------- xtask/src/corpus/mod.rs | 4 +- xtask/src/corpus/refill_contract.rs | 10 +- xtask/src/corpus/runner.rs | 10 +- 14 files changed, 161 insertions(+), 314 deletions(-) diff --git a/corpus/fc-validate/audit-seed2/expected.snap b/corpus/fc-validate/audit-seed2/expected.snap index 5530ac42..a53a8f97 100644 --- a/corpus/fc-validate/audit-seed2/expected.snap +++ b/corpus/fc-validate/audit-seed2/expected.snap @@ -47,20 +47,6 @@ flags: provenance: sources: - help-text -- spellings: - - -i - value_name: index - value_kind: Required - provenance: - sources: - - help-text-synopsis -- spellings: - - --index - value_name: index - value_kind: Required - provenance: - sources: - - help-text-synopsis provenance: sources: - help-text diff --git a/corpus/ip6tables-apply/audit-seed2/expected.snap b/corpus/ip6tables-apply/audit-seed2/expected.snap index 7040bd2e..dcba4f4d 100644 --- a/corpus/ip6tables-apply/audit-seed2/expected.snap +++ b/corpus/ip6tables-apply/audit-seed2/expected.snap @@ -45,13 +45,6 @@ flags: provenance: sources: - help-text -- spellings: - - -t - value_name: timeout - value_kind: Required - provenance: - sources: - - help-text-synopsis provenance: sources: - help-text diff --git a/corpus/iptables-apply/audit-seed2/expected.snap b/corpus/iptables-apply/audit-seed2/expected.snap index 253b1294..3966e14f 100644 --- a/corpus/iptables-apply/audit-seed2/expected.snap +++ b/corpus/iptables-apply/audit-seed2/expected.snap @@ -45,13 +45,6 @@ flags: provenance: sources: - help-text -- spellings: - - -t - value_name: timeout - value_kind: Required - provenance: - sources: - - help-text-synopsis provenance: sources: - help-text diff --git a/corpus/pod2man/5.01/expected.snap b/corpus/pod2man/5.01/expected.snap index 2674bc31..5134eb7a 100644 --- a/corpus/pod2man/5.01/expected.snap +++ b/corpus/pod2man/5.01/expected.snap @@ -198,13 +198,6 @@ flags: provenance: sources: - help-text -- spellings: - - --section - value_name: manext - value_kind: Required - provenance: - sources: - - help-text-synopsis provenance: sources: - help-text diff --git a/corpus/ssh-keygen/9.6p1/expected.snap b/corpus/ssh-keygen/9.6p1/expected.snap index 16140f20..a507040d 100644 --- a/corpus/ssh-keygen/9.6p1/expected.snap +++ b/corpus/ssh-keygen/9.6p1/expected.snap @@ -115,13 +115,6 @@ flags: provenance: sources: - help-text-synopsis -- spellings: - - -f - value_name: keyfile - value_kind: Required - provenance: - sources: - - help-text-synopsis - spellings: - -P value_name: old_passphrase @@ -149,13 +142,6 @@ flags: provenance: sources: - help-text-synopsis -- spellings: - - -P - value_name: passphrase - value_kind: Required - provenance: - sources: - - help-text-synopsis - spellings: - -l provenance: @@ -228,13 +214,6 @@ flags: provenance: sources: - help-text-synopsis -- spellings: - - -M - value_name: screen - value_kind: Required - provenance: - sources: - - help-text-synopsis - spellings: - -I value_name: certificate_identity @@ -256,13 +235,6 @@ flags: provenance: sources: - help-text-synopsis -- spellings: - - -n - value_name: principals - value_kind: Required - provenance: - sources: - - help-text-synopsis - spellings: - -V value_name: validity_interval @@ -309,34 +281,6 @@ flags: provenance: sources: - help-text-synopsis -- spellings: - - -Y - value_name: match-principals - value_kind: Required - provenance: - sources: - - help-text-synopsis -- spellings: - - -Y - value_name: check-novalidate - value_kind: Required - provenance: - sources: - - help-text-synopsis -- spellings: - - -Y - value_name: sign - value_kind: Required - provenance: - sources: - - help-text-synopsis -- spellings: - - -Y - value_name: verify - value_kind: Required - provenance: - sources: - - help-text-synopsis provenance: sources: - help-text diff --git a/corpus/ssh-keygen/audit-seed2/expected.snap b/corpus/ssh-keygen/audit-seed2/expected.snap index 16140f20..a507040d 100644 --- a/corpus/ssh-keygen/audit-seed2/expected.snap +++ b/corpus/ssh-keygen/audit-seed2/expected.snap @@ -115,13 +115,6 @@ flags: provenance: sources: - help-text-synopsis -- spellings: - - -f - value_name: keyfile - value_kind: Required - provenance: - sources: - - help-text-synopsis - spellings: - -P value_name: old_passphrase @@ -149,13 +142,6 @@ flags: provenance: sources: - help-text-synopsis -- spellings: - - -P - value_name: passphrase - value_kind: Required - provenance: - sources: - - help-text-synopsis - spellings: - -l provenance: @@ -228,13 +214,6 @@ flags: provenance: sources: - help-text-synopsis -- spellings: - - -M - value_name: screen - value_kind: Required - provenance: - sources: - - help-text-synopsis - spellings: - -I value_name: certificate_identity @@ -256,13 +235,6 @@ flags: provenance: sources: - help-text-synopsis -- spellings: - - -n - value_name: principals - value_kind: Required - provenance: - sources: - - help-text-synopsis - spellings: - -V value_name: validity_interval @@ -309,34 +281,6 @@ flags: provenance: sources: - help-text-synopsis -- spellings: - - -Y - value_name: match-principals - value_kind: Required - provenance: - sources: - - help-text-synopsis -- spellings: - - -Y - value_name: check-novalidate - value_kind: Required - provenance: - sources: - - help-text-synopsis -- spellings: - - -Y - value_name: sign - value_kind: Required - provenance: - sources: - - help-text-synopsis -- spellings: - - -Y - value_name: verify - value_kind: Required - provenance: - sources: - - help-text-synopsis provenance: sources: - help-text diff --git a/docs/shapes.md b/docs/shapes.md index ad8e4642..62f78001 100644 --- a/docs/shapes.md +++ b/docs/shapes.md @@ -2756,17 +2756,26 @@ entry's `tools` field and nothing else. It does not get a new entry. - tools: lvcreate; a merge-step fix, so any tool whose node reaches `mandible_core::merge::merge_entity_bucket` with a same-spelling, same-value-kind bucket naming different literal values is covered -- handling: Fixed. `lvcreate` reaches this bucket once per invocation - form, each form naming its own value for `--type`. `merge_entity_bucket` - picked one form's `value_name` by authority, so the rendered row showed - `--type linear` beside the `raid1`/`mirror` form's own `choices`, - dropping `striped`, `raid10`, `snapshot` and `thin` outright. It now - unions every distinct value name across the bucket, in first-appearance - order, joined the way `choices` already joins for display. Maintainer- - absent default, recorded in docs/design.md §16. `must_value_name` passes +- handling: Fixed, twice. `lvcreate` reaches this bucket once per + invocation form, each form naming its own value for `--type`. + `merge_entity_bucket` picked one form's `value_name` by authority, so + the rendered row showed `--type linear` beside the `raid1`/`mirror` + form's own `choices`, dropping `striped`, `raid10`, `snapshot` and + `thin` outright. A first fix unioned every distinct value name into + `value_name` itself; ruled confusing (docs/design.md §16) since one + flag then showed two lists. The bucket now renders one placeholder and + one unioned `choices` list: every + literal lowercase value, from a form's own `value_name` and from any + `choices` it already carried, joins `choices`; a capitalised token + stays the placeholder name; no placeholder is fabricated when every + form named a literal. `mandible_extract`'s stanza-head recovery also + now reads a leading flag's own bare usage-form value (`--type raid`, + not just the bracketed `[ --type x ]` rows), so `--type` now carries + thirteen values, not five. `must_value_name`/`must_attach_choices` pass vacuously on the raw, unrefilled tree; `must_value_names_after_root_refill` - (`corpus/README.md`) simulates the real app's own root refill and is the - field that actually states the claim. + and `must_choices_after_root_refill` (`corpus/README.md`) simulate the + real app's own root refill and are the fields that actually state the + claim. - fleet: `same-spelling-fold-loss` (`xtask/src/detector/same_spelling_fold_loss.rs`), widened to also flag two same-identity entities that both take a value but name it diff --git a/mandible-core/src/entity.rs b/mandible-core/src/entity.rs index 632f770e..729d1ed4 100644 --- a/mandible-core/src/entity.rs +++ b/mandible-core/src/entity.rs @@ -188,20 +188,14 @@ pub struct Choice { pub description: Option, } -/// Whether `value_name` reads as one clean literal enumerated value -/// (`"raid1"`, `"thin-pool"`) rather than a placeholder name (`"Number"`, -/// `"VG"`) or a grammar fragment glued onto a neighboring flag -/// (`blkid`'s `"[--match-tag"`, a docopt value spec restated whole, -/// `"y|n"`). The rule the S-147 follow-up ruling states (2026-09-07 -/// queue, `docs/design.md` §16): a capitalised token is a placeholder -/// name and stays out of a merged `choices` list. Narrowed to one word of -/// lowercase ASCII letters, digits and hyphens: any other punctuation -/// (`|`, `[`, `<`, a leading dash) means this token is notation, not an -/// enumerated member, and letting it through would union a restated value -/// spec into `choices` alongside the real values it already restates. -/// Shared by `merge::merge_entity_bucket` (bucket-wide reclassification) -/// and `mandible_extract`'s stanza-head literal-value recovery, so both -/// sides of the merge agree on one rule. +/// Whether `value_name` is one clean literal enumerated value (`"raid1"`, +/// `"thin-pool"`), not a placeholder (`"Number"`) or notation glued onto +/// a neighboring flag (`blkid`'s `"[--match-tag"`, a restated value spec +/// `"y|n"`). docs/design.md §16's S-147 follow-up: a capitalised token +/// stays out of a merged `choices` list. One word of lowercase ASCII +/// letters, digits and hyphens only; any other punctuation is notation, +/// not an enumerated member. Shared by `merge::merge_entity_bucket` and +/// `mandible_extract`'s stanza-head recovery, so both agree on one rule. pub fn is_literal_choice_value(value_name: &str) -> bool { !value_name.is_empty() && value_name diff --git a/mandible-core/src/merge.rs b/mandible-core/src/merge.rs index 2e5e0806..4e082f51 100644 --- a/mandible-core/src/merge.rs +++ b/mandible-core/src/merge.rs @@ -333,38 +333,21 @@ fn entity_identity(e: &Entity) -> (EntityKind, String) { (e.kind, key) } -fn merge_entity_bucket(mut bucket: Vec) -> Entity { - if bucket.len() == 1 { - return bucket.pop().expect("len checked"); - } - - // The spelling halves are resolved **independently**, exactly as they - // were when they were four separate `Flag` fields, and only then - // reassembled into a `spellings` vec. Picking a whole `Spelling` by - // authority instead would silently couple them: a high-authority - // source that omits the `[no-]` a lower-authority one documented would - // start erasing the negatability, which no field-level rule here has - // ever done. - let short = bucket.iter().find_map(|f| f.short()); - let long_name = pick_option( - bucket - .iter() - .map(|f| (&f.provenance, f.long_spelling().map(|s| &s.name))), - Axis::Structural, - ); - // One dash or two, and negatability, are facts about how the tool - // spells this option: a single source that saw it is enough, because - // no other source can have seen the same flag spelled the other way. - let negatable = bucket.iter().any(|f| f.negatable()); - let single_dash = bucket.iter().any(|f| f.single_dash()); - // Every distinct `value_name` the bucket's forms carry, in - // first-appearance order. Computed before the fields it gates so - // every branch below can share it; a bucket with zero or one - // distinct name is the ordinary case and never reaches either - // disagreement branch. +/// Resolve a bucket's merged `value_name` and `choices` together +/// (docs/design.md §16's S-147 follow-up). Split out of +/// [`merge_entity_bucket`] to stay under `clippy::too_many_lines`. +/// +/// Every distinct `value_name` the bucket's forms carry decides which of +/// three shapes applies: zero or one distinct name is ordinary (no +/// disagreement); every name literal (`is_literal_choice_value`) is +/// `--type`'s own shape, one placeholder plus one unioned `choices`; a +/// mix of a real placeholder with something else (`tar`'s `--rsh-command +/// COMMAND` beside its own default `/usr/bin/rsh`) keeps the +/// union-into-`value_name` behavior S-147 already shipped fleet-wide. +fn resolve_value_name_and_choices(bucket: &[Entity]) -> (Option, Vec) { let distinct_value_names: Vec<&str> = { let mut v: Vec<&str> = Vec::new(); - for f in &bucket { + for f in bucket { if let Some(name) = f.value_name.as_deref() { if !v.contains(&name) { v.push(name); @@ -374,39 +357,21 @@ fn merge_entity_bucket(mut bucket: Vec) -> Entity { v }; let has_any_choices = bucket.iter().any(|f| !f.choices.is_empty()); - // S-147's own follow-up (ruled 2026-09-07 "queue", docs/design.md §16) - // narrows what used to be one blanket rule into two: a bucket whose - // *every* distinct name is a literal enumerated value (`is_literal_ - // choice_value`) is `--type`'s own shape and gets the new one- - // placeholder, one-unioned-choices treatment; a bucket that mixes a - // real placeholder name with something else (`tar`'s `--rsh-command - // COMMAND` beside its own default `/usr/bin/rsh`, `gcc`'s `--format - // FORMAT` beside its own default `gnu`) is not `--type`'s shape and - // must keep the union-into-`value_name` behavior S-147 already - // shipped fleet-wide, unchanged, or a control tool's row moves for a - // ruling that was never about it. let all_literal_disagreement = distinct_value_names.len() > 1 - && distinct_value_names.iter().all(|n| is_literal_choice_value(n)); + && distinct_value_names + .iter() + .all(|n| is_literal_choice_value(n)); let single_literal_beside_choices = distinct_value_names.len() == 1 && has_any_choices && is_literal_choice_value(distinct_value_names[0]); - let (value_name, choices) = if all_literal_disagreement || single_literal_beside_choices { - // One placeholder, one unioned choice list. `lvcreate` reaches - // this bucket once per invocation form, each naming its own - // literal value for `--type` (`linear`, `striped`, `raid10`, - // ...) — a highest-authority single winner used to render one - // form's value name beside another form's `choices`, silently - // dropping every other form's name. Every distinct literal - // value across the whole bucket, from a form's own `value_name` - // and from any `choices` it already carries, unions into one - // `choices` list, in first-appearance order, joined the same way - // `choices` already joins for display (spec §9.2, "values: - // raid1, mirror"). Every name here is already known literal - // (both branches above require it), so `value_name` is always - // `None` — never fabricated from the flag's own spelling. + if all_literal_disagreement || single_literal_beside_choices { + // One placeholder, one unioned choice list, first-appearance + // order, joined the way `choices` already joins for display + // (spec §9.2, "values: raid1, mirror"). Every name here is known + // literal, so `value_name` stays `None` — never fabricated. let mut choices: Vec = Vec::new(); let mut choice_names: Vec<&str> = Vec::new(); - for f in &bucket { + for f in bucket { if let Some(name) = f.value_name.as_deref() { if is_literal_choice_value(name) && !choice_names.contains(&name) { choice_names.push(name); @@ -438,7 +403,9 @@ fn merge_entity_bucket(mut bucket: Vec) -> Entity { // nothing to disagree about. Exactly the pre-S147 resolution. ( pick_option( - bucket.iter().map(|f| (&f.provenance, f.value_name.as_ref())), + bucket + .iter() + .map(|f| (&f.provenance, f.value_name.as_ref())), Axis::Structural, ), pick_vec( @@ -446,7 +413,34 @@ fn merge_entity_bucket(mut bucket: Vec) -> Entity { Axis::Prose, ), ) - }; + } +} + +fn merge_entity_bucket(mut bucket: Vec) -> Entity { + if bucket.len() == 1 { + return bucket.pop().expect("len checked"); + } + + // The spelling halves are resolved **independently**, exactly as they + // were when they were four separate `Flag` fields, and only then + // reassembled into a `spellings` vec. Picking a whole `Spelling` by + // authority instead would silently couple them: a high-authority + // source that omits the `[no-]` a lower-authority one documented would + // start erasing the negatability, which no field-level rule here has + // ever done. + let short = bucket.iter().find_map(|f| f.short()); + let long_name = pick_option( + bucket + .iter() + .map(|f| (&f.provenance, f.long_spelling().map(|s| &s.name))), + Axis::Structural, + ); + // One dash or two, and negatability, are facts about how the tool + // spells this option: a single source that saw it is enough, because + // no other source can have seen the same flag spelled the other way. + let negatable = bucket.iter().any(|f| f.negatable()); + let single_dash = bucket.iter().any(|f| f.single_dash()); + let (value_name, choices) = resolve_value_name_and_choices(&bucket); let value_kind = bucket .iter() .map(|f| f.value_kind) @@ -1331,7 +1325,7 @@ mod tests { ); } - /// docs/shapes.md S-147 follow-up (ruled 2026-09-07 "queue"): `lvcreate` + /// docs/shapes.md S-147 follow-up: `lvcreate` /// reaches the merge bucket once per invocation form, and three of its /// forms each name a different literal value for `--type`. A /// single-winner pick used to render one form's value name (`linear`) diff --git a/mandible-extract/src/help_text/sections/mod.rs b/mandible-extract/src/help_text/sections/mod.rs index 091585a0..c4558908 100644 --- a/mandible-extract/src/help_text/sections/mod.rs +++ b/mandible-extract/src/help_text/sections/mod.rs @@ -104,6 +104,13 @@ pub struct ParsedHelp { /// regex, or was dropped for lack of an owning heading/flag. Surfaced /// so `extract_node` can mark the node's provenance as a guess. pub saw_unattributable_content: bool, + /// Flags recovered by `recover_stanza_head_leading_flag_value` + /// specifically, mirrored here (also pushed into `flags`) so the + /// usage-block scan's own dedup can tell "a leading-value recovery of + /// this shape already named a different literal" apart from any other + /// reason a spelling might already be present in `flags`, and relax + /// only for that one shape. See docs/shapes.md S-147. + leading_value_recoveries: Vec, /// Names already accepted into `subcommands`, tracked alongside it so /// [`ParsedHelp::try_push_subcommand`] can reject duplicates in O(1) /// instead of an O(n) scan of `subcommands` per candidate (which would @@ -150,6 +157,37 @@ fn is_ignorable_heading(heading: &str) -> bool { lower.starts_with("example") || lower.contains("report bugs") } +/// True when `candidate` names a literal value distinct from every one of +/// `existing`'s entries — meant to be called only with `existing` set to +/// [`ParsedHelp::leading_value_recoveries`], never the whole document's +/// flags, so this only ever overrides a duplicate this same leading-value +/// shape produced. `lvchange`'s `-M|--persistent` is the case: a stanza +/// head names it `y`, an unrelated paren-alternation row names it `n`; +/// without this the row scan's own dedup would drop `n` outright, +/// replacing a value that used to render rather than letting both reach +/// `merge_entity_bucket`'s union. Refused when the matching recovery is a +/// plain boolean or carries an abbreviation-bracket spelling — narrower +/// scoping alone does not rule out a same-shape false positive from +/// another stanza head. See docs/shapes.md S-147. +fn usage_flag_names_a_new_literal_value(candidate: &Entity, existing: &[Entity]) -> bool { + let Some(name) = candidate.value_name.as_deref() else { + return false; + }; + if !is_literal_choice_value(name) { + return false; + } + let mut same_spelling = existing + .iter() + .filter(|f| flag_spelling_already_present(candidate, std::slice::from_ref(f))) + .peekable(); + same_spelling.peek().is_some() + && same_spelling.all(|f| { + f.value_name.as_deref() != Some(name) + && f.value_kind != ValueKind::None + && !f.spellings.iter().any(|s| s.abbrev.is_some()) + }) +} + /// True when `heading` positively names a section whose rows describe CLI /// flags. Used only to leave an otherwise-contained examples/reporting /// region at the *same* indentation — same-indent text inside a worked @@ -947,13 +985,25 @@ fn emit_heading_block( // Size[m|UNIT] VG`) is not, so the fallback only runs when the // first recognizer stays silent, never both, since either would // otherwise recover the same leading flag twice. See S-147. - let recovered = recover_stanza_head_flag(heading, tool_name) - .or_else(|| recover_stanza_head_leading_flag_value(heading, tool_name)); - if let Some(mut flag) = recovered { + if let Some(mut flag) = recover_stanza_head_flag(heading, tool_name) { + if let Some(label) = stanza_label.clone() { + flag.group = Some(label); + } + if st.result.flags.len() < MAX_RECOVERED_ENTRIES { + st.result.flags.push(flag); + } + } else if let Some(mut flag) = recover_stanza_head_leading_flag_value(heading, tool_name) { if let Some(label) = stanza_label.clone() { flag.group = Some(label); } if st.result.flags.len() < MAX_RECOVERED_ENTRIES { + // Tracked separately from `result.flags` so the usage-block + // dedup below (`usage_flag_names_a_new_literal_value`) can + // tell "a leading-value recovery of this exact shape + // already named a different literal" apart from any other + // reason a spelling might already be present, and relax + // only for the former. See S-147. + st.result.leading_value_recoveries.push(flag.clone()); st.result.flags.push(flag); } } @@ -1729,7 +1779,7 @@ fn parse_body( break; } if !flag_spelling_already_present(&flag, &result.flags) - || usage_flag_names_a_new_literal_value(&flag, &result.flags) + || usage_flag_names_a_new_literal_value(&flag, &result.leading_value_recoveries) { result.flags.push(flag); } diff --git a/mandible-extract/src/help_text/sections/usage.rs b/mandible-extract/src/help_text/sections/usage.rs index 7a08a712..3f2e02cc 100644 --- a/mandible-extract/src/help_text/sections/usage.rs +++ b/mandible-extract/src/help_text/sections/usage.rs @@ -214,27 +214,13 @@ pub(super) fn recover_stanza_head_flag(heading: &str, tool_name: Option<&str>) - /// bare literal value and further required flags follow on the same line /// — `lvcreate --type raid -L|--size Size[m|UNIT] VG`, where /// [`looks_like_stanza_head_flag`] refuses because `-L|--size` reads as a -/// second flag token. [`recover_stanza_head_flag`] only ever recovers a -/// head naming exactly one flag; this recovers just the leading flag and -/// its literal value, leaving the line's other flags to whichever path -/// already documents them (an option table, or their own bracket row). -/// -/// Gated to a literal, lowercase value ([`is_literal_choice_value`]) so -/// this never fabricates a placeholder from a positional operand a -/// stanza head happens to write next to its own flag (`VG`, `Size[m| -/// UNIT]`) — only an enumerated literal like `raid`/`thin-pool` qualifies. -/// `merge::merge_entity_bucket` folds every literal value this and -/// [`recover_stanza_head_flag`]'s bracket-row siblings recover into one -/// `choices` list. See docs/shapes.md S-147. -/// -/// Gated to [`is_literal_choice_value`] — one word of lowercase ASCII -/// letters, digits and hyphens, nothing else, the shape `raid1`/ -/// `thin-pool` carry. Refused for any docopt notation glued onto the word -/// (`blkid`'s `[--match-tag`, a bracketed *reference* to a further flag -/// on the same head, not a value at all): a bracket, angle bracket, pipe -/// or leading dash means this token is punctuation the generic value walk -/// must not mistake for an enumerated literal. See docs/shapes.md S-089, -/// S-147. +/// second flag token. Recovers just the leading flag and its literal +/// value; the line's other flags are documented elsewhere. Gated to +/// [`is_literal_choice_value`] so a positional operand (`VG`, `Size[m| +/// UNIT]`) or glued docopt notation (`blkid`'s `[--match-tag`) is never +/// mistaken for one. `merge::merge_entity_bucket` folds every literal +/// value this and [`recover_stanza_head_flag`]'s bracket-row siblings +/// recover into one `choices` list. See docs/shapes.md S-089, S-147. pub(super) fn recover_stanza_head_leading_flag_value( heading: &str, tool_name: Option<&str>, @@ -257,7 +243,10 @@ pub(super) fn recover_stanza_head_leading_flag_value( if spec.spellings.is_empty() { return None; } - let mut flag = Entity::new(EntityKind::Flag, Provenance::single(Source::HelpTextSynopsis)); + let mut flag = Entity::new( + EntityKind::Flag, + Provenance::single(Source::HelpTextSynopsis), + ); flag.spellings = spec.spellings; flag.value_name = Some(value_tok.to_string()); flag.value_kind = ValueKind::Required; @@ -1064,54 +1053,6 @@ pub(super) fn flag_spelling_already_present(candidate: &Entity, existing: &[Enti }) } -/// True when `candidate` names a real, literal value distinct from every -/// existing entity that already shares its spelling — the S-147 shape, -/// not the ordinary duplicate [`flag_spelling_already_present`] exists to -/// drop. `lvchange`'s `-M|--persistent` reaches this: a stanza head -/// (`recover_stanza_head_leading_flag_value`) names it `y`, an unrelated -/// paren-alternation row names it `n`; without this check the stanza -/// head's own entity, already in `result.flags` by the time the usage -/// scan runs, would make `flag_spelling_already_present` drop `n` -/// outright, replacing a value that used to render with a different one -/// instead of letting both reach `merge_entity_bucket`'s own union. -/// -/// Refused when any existing same-spelling entity is a plain boolean -/// (`ValueKind::None`): `ssh-keygen`'s own `-F hostname [-lv] [...]` -/// bundles `-lv` into two boolean switches (`-l`, `-v`), and without this -/// guard a value-attaching heuristic elsewhere in this same document can -/// glue a stray literal onto `-l`'s spelling, which this override would -/// then wrongly admit as a second, "disagreeing" form instead of the -/// fabrication it is. A flag documented as boolean anywhere never also -/// gets a literal value here. -/// -/// Also refused when any existing same-spelling entity carries an -/// abbreviation-bracket spelling (`Spelling::abbrev`): `ip`'s own -/// `-b[atch] [filename]` is one single-dash long option abbreviated to -/// its first letter, already recovered correctly elsewhere as `-b[atch]` -/// with value `filename`. A later, unrelated pass in this same document -/// reads the bracket's own inner text as if `-b` took a bare value -/// `atch`, and without this guard that misparse would be admitted as a -/// second, "disagreeing" form rather than refused as the same flag read -/// twice. See docs/shapes.md S-147. -pub(super) fn usage_flag_names_a_new_literal_value(candidate: &Entity, existing: &[Entity]) -> bool { - let Some(name) = candidate.value_name.as_deref() else { - return false; - }; - if !is_literal_choice_value(name) { - return false; - } - let mut same_spelling = existing - .iter() - .filter(|f| flag_spelling_already_present(candidate, std::slice::from_ref(f))) - .peekable(); - same_spelling.peek().is_some() - && same_spelling.all(|f| { - f.value_name.as_deref() != Some(name) - && f.value_kind != ValueKind::None - && !f.spellings.iter().any(|s| s.abbrev.is_some()) - }) -} - /// Push the flag(s) one synopsis token names: either a bundle of /// single-character boolean switches, one [`Flag`] per member, or — for /// every other shape — the single flag [`parse_flag_spec`] reads. The diff --git a/xtask/src/corpus/mod.rs b/xtask/src/corpus/mod.rs index 8415accb..3a6d56a0 100644 --- a/xtask/src/corpus/mod.rs +++ b/xtask/src/corpus/mod.rs @@ -344,8 +344,8 @@ pub(crate) struct ContractMeta { /// claim for a same-spelling merge bucket: it walks the raw, /// unrefilled tree, where `--type`'s several invocation forms are /// still separate entities and `.find()` sees only the first one's - /// own (possibly empty) `choices`. S-147 follow-up (ruled 2026-09-07 - /// "queue", docs/design.md §16): a bucket whose forms disagree about + /// own (possibly empty) `choices`. S-147 follow-up + /// (docs/design.md §16): a bucket whose forms disagree about /// a value name unions every literal one into `choices`, and this is /// the only field that can see the union actually happened. See /// `refill_contract.rs`. diff --git a/xtask/src/corpus/refill_contract.rs b/xtask/src/corpus/refill_contract.rs index 5c3bb0f0..934237cc 100644 --- a/xtask/src/corpus/refill_contract.rs +++ b/xtask/src/corpus/refill_contract.rs @@ -93,7 +93,11 @@ pub(crate) fn check_must_value_names_after_root_refill( /// `must_choices_after_root_refill`'s own `CONTRACT WEAKENED` lines, the /// twin of [`weakened_lines`] above for the `choices` half of S-147's /// follow-up ruling. -pub(crate) fn choices_weakened_lines(label: &str, b: &ContractMeta, n: &ContractMeta) -> Vec { +pub(crate) fn choices_weakened_lines( + label: &str, + b: &ContractMeta, + n: &ContractMeta, +) -> Vec { let mut lines = Vec::new(); for (flag, base_names) in &b.must_choices_after_root_refill { match n.must_choices_after_root_refill.get(flag) { @@ -118,8 +122,8 @@ pub(crate) fn choices_weakened_lines(label: &str, b: &ContractMeta, n: &Contract /// The `choices` twin of [`check_must_value_names_after_root_refill`]: /// asserts every named literal survives in the refilled flag's `choices` -/// list, not its `value_name`. S-147's own follow-up ruling (2026-09-07 -/// "queue") moves a same-spelling bucket's disagreeing literal values +/// list, not its `value_name`. S-147's own follow-up ruling +/// (docs/design.md §16) moves a same-spelling bucket's disagreeing literal values /// out of `value_name` and into one unioned `choices` list, and /// `must_attach_choices` alone cannot see that union: it walks the raw, /// unrefilled tree, where `.find()` sees only the first invocation diff --git a/xtask/src/corpus/runner.rs b/xtask/src/corpus/runner.rs index 5db9bc9c..e430e3cb 100644 --- a/xtask/src/corpus/runner.rs +++ b/xtask/src/corpus/runner.rs @@ -271,10 +271,12 @@ fn run_fixtures( root.as_ref(), ), ); - contract_failures.extend(super::refill_contract::check_must_choices_after_root_refill( - &fixture.meta.contract, - root.as_ref(), - )); + contract_failures.extend( + super::refill_contract::check_must_choices_after_root_refill( + &fixture.meta.contract, + root.as_ref(), + ), + ); let is_xfail = fixture.meta.xfail.as_ref().is_some_and(|x| x.broken); // A missing `expected.snap` is legal only for a fixture still From 82d414052070ab980b6850c724e1a466862b3dc2 Mon Sep 17 00:00:00 2001 From: Sadig Akhund Date: Sun, 13 Sep 2026 01:02:58 +0400 Subject: [PATCH 04/10] extract: one command node per distinct leading word of a pattern row A command table's multi-word pattern rows collapse to one node per leading word, each row's pattern appended to that node's usage list, admitted only at the block's own name column. New contract field must_not_contain_subcommands pins round 8's nine fabricated names. docs/shapes.md S-141. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 + corpus/fail2ban-client/1.0.2/expected.snap | 342 ++++++++++++++++++ corpus/fail2ban-client/1.0.2/meta.toml | 11 +- corpus/lldb-server/18.1.3/expected.snap | 26 ++ docs/shapes.md | 38 ++ .../src/help_text/sections/emit.rs | 76 ++++ .../src/help_text/sections/mod.rs | 29 ++ xtask/src/corpus/contract.rs | 33 ++ xtask/src/corpus/mod.rs | 42 +++ xtask/src/detector/commands.rs | 44 +-- xtask/src/existence.rs | 62 +++- 11 files changed, 668 insertions(+), 37 deletions(-) create mode 100644 corpus/fail2ban-client/1.0.2/expected.snap create mode 100644 corpus/lldb-server/18.1.3/expected.snap diff --git a/CHANGELOG.md b/CHANGELOG.md index a6bbc073..2f03f2ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ 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-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, keeping the source spelling, instead of folding into the usage block (`mandible lldb-server`). +- [S-141] A command table's multi-word row (`set loglevel `, `restart [--unban] [--if-exists] `) now reaches the tree as its own command's usage form instead of being dropped, without reintroducing round 8's nine fabricated names (`mandible fail2ban-client`). ## [0.7.0] - 2026-09-05 diff --git a/corpus/fail2ban-client/1.0.2/expected.snap b/corpus/fail2ban-client/1.0.2/expected.snap new file mode 100644 index 00000000..8925dfe0 --- /dev/null +++ b/corpus/fail2ban-client/1.0.2/expected.snap @@ -0,0 +1,342 @@ +name: fail2ban-client +usage: +- 'Usage: fail2ban-client [OPTIONS] ' +positionals: +- name: COMMAND + required: true + provenance: + sources: + - help-text +flags: +- spellings: + - -c + - --conf + value_name: + value_kind: Required + description: configuration directory + provenance: + sources: + - help-text +- spellings: + - -s + - --socket + value_name: + value_kind: Required + description: socket path + provenance: + sources: + - help-text +- spellings: + - -p + - --pidfile + value_name: + value_kind: Required + description: pidfile path + provenance: + sources: + - help-text +- spellings: + - --pname + value_name: + value_kind: Required + description: name of the process (main thread) to identify instance (default fail2ban-server) + provenance: + sources: + - help-text +- spellings: + - --loglevel + value_name: + value_kind: Required + description: logging level + provenance: + sources: + - help-text +- spellings: + - --logtarget + value_name: + value_kind: Required + description: logging target, use file-name or stdout, stderr, syslog or sysout. + provenance: + sources: + - help-text +- spellings: + - --syslogsocket + value_name: auto| + value_kind: Required + provenance: + sources: + - help-text +- spellings: + - -d + description: dump configuration. For debugging + provenance: + sources: + - help-text +- spellings: + - --dp + - --dump-pretty + description: dump the configuration using more human readable representation + provenance: + sources: + - help-text +- spellings: + - -t + - --test + description: test configuration (can be also specified with start parameters) + provenance: + sources: + - help-text +- spellings: + - -i + description: interactive mode + provenance: + sources: + - help-text +- spellings: + - -v + description: increase verbosity + provenance: + sources: + - help-text +- spellings: + - -q + description: decrease verbosity + provenance: + sources: + - help-text +- spellings: + - -x + description: force execution of the server (remove socket file) + provenance: + sources: + - help-text +- spellings: + - -b + description: start server in background (default) + provenance: + sources: + - help-text +- spellings: + - -f + description: start server in foreground + provenance: + sources: + - help-text +- spellings: + - --async + description: start server in async mode (for internal usage only, don't read configuration) + provenance: + sources: + - help-text +- spellings: + - --timeout + description: timeout to wait for the server (for internal usage only, don't read configuration) + provenance: + sources: + - help-text +- spellings: + - --str2sec + value_name: + value_kind: Required + description: convert time abbreviation format to seconds + provenance: + sources: + - help-text +- spellings: + - -h + - --help + description: display this help message + provenance: + sources: + - help-text +- spellings: + - -V + - --version + description: print the version (-V returns machine-readable short format) + provenance: + sources: + - help-text +provenance: + sources: + - help-text + confidence: 0.5 +children_filled: true +subcommands: +- name: start + summary: starts the server and the jails + usage: + - start + group: 'Command:' + provenance: + sources: + - help-text + heading_attested: true +- name: restart + summary: restarts the server + usage: + - restart [--unban] [--if-exists] + group: 'Command:' + provenance: + sources: + - help-text + heading_attested: true +- name: reload + summary: reloads the configuration without restarting of the server, the option '--restart' activates completely restarting of affected jails, thereby can unban IP addresses (if option '--unban' specified) + usage: + - reload [--restart] [--unban] [--all] + - reload [--restart] [--unban] [--if-exists] + group: 'Command:' + provenance: + sources: + - help-text + invocation_attested: true +- name: stop + summary: stops all jails and terminate the server + usage: + - stop + group: 'Command:' + provenance: + sources: + - help-text + heading_attested: true +- name: banned + summary: return jails with banned IPs as dictionary + group: 'Command:' + provenance: + sources: + - help-text + heading_attested: true +- name: status + summary: gets the current status of the server + usage: + - status [FLAVOR] + group: 'Command:' + provenance: + sources: + - help-text + heading_attested: true +- name: ping + summary: tests if the server is alive + group: 'Command:' + provenance: + sources: + - help-text + heading_attested: true +- name: echo + summary: for internal usage, returns back and outputs a given string + group: 'Command:' + provenance: + sources: + - help-text + heading_attested: true +- name: help + summary: return this output + group: 'Command:' + provenance: + sources: + - help-text + heading_attested: true +- name: version + summary: return the server version + group: 'Command:' + provenance: + sources: + - help-text + heading_attested: true +- name: set + summary: 'sets logging level to . Levels: CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG, TRACEDEBUG, HEAVYDEBUG or corresponding numeric value (50-5)' + usage: + - set loglevel + - set logtarget + - set dbfile + - set dbmaxmatches + - set dbpurgeage + - set addignoreip + - set delignoreip + - set ignorecommand + - set ignorecache + - set addlogpath ['tail'] + - set dellogpath + - set logencoding + - set addjournalmatch + - set deljournalmatch + - set addfailregex + - set delfailregex + - set addignoreregex + - set delignoreregex + - set findtime