A universal, interactive TUI reference for CLI tools, in Rust.
mandible gitopens an explorable tree of every command, subcommand, and flaggithas — with descriptions, not just names — plus a search bar that finds the flag you half-remember.
This document is the design reference and the build guide. Every claim about the outside world in this document has been measured on a real machine; measurements are collected in Appendix A and cited inline as [M-n]. When a measurement contradicts an assumption, the measurement wins.
Revision 3. Revision 3 deletes the vendored spec catalog and the on-disk cache, and reorganizes --help parsing around the framework that generated the text (§7 Tier A′, §7 Tier B, §11). Revision 2's changes from revision 1 are in Appendix B.
- Product definition & non-goals
- Vision & UX flow
- The core challenge: universal CLI introspection
- The intermediate representation
- The extraction model: authority, laziness, cost
- Execution safety policy
- Extraction tiers, in detail
- Crate & workspace architecture
- TUI design
- Search
- No cache
- Implementation roadmap
- Testing & the coverage harness
- Dependency table
- Packaging & distribution
- Open risks & honest caveats
- Investigated and deferred: local NL search
mandible is a reference browser, not a command builder. The user's journey is: "I know roughly what I want; show me the flag and its exact spelling, then let me copy it." Everything in this spec is subordinate to that.
The invariant that defines the project:
The mandible repository will never contain per-tool logic. No
if tool == "docker", no vendored per-tool patch file, no tool-name-keyed special case in any tier. Tool-specific knowledge lives in exactly two places: (a) third-party structured catalogs we consume wholesale as data, and (b) user-local override files that are never checked into this repo.
If a change would violate that invariant, the correct fix is to improve a general parser, add a general extraction tier, or accept the gap and show it honestly in the UI. This is the whole reason the tiered design exists; a single exception starts the erosion.
Non-goals (stated so they don't quietly creep in):
- Executing the tool being documented. mandible never runs a user's command for them.
- Being a shell completion engine. Carapace and friends already do that.
- Natural-language command synthesis. See §17 for why this is deferred, not planned.
- Perfect fidelity. The pipeline is best-effort by construction; the UI's job is to make the confidence level legible, not to hide it.
$ mandible git
opens a full-screen TUI:
╭─ search ─────────────────────────────────────────────────────────────────────╮
│ › squash │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ git ──────────────────────╮╭─ git › rebase ─────────────────────────────────╮
│ ▾ git ││ │
│ ▸ add ││ Reapply commits on top of another base tip │
│ ▸ commit ││ │
│ ▾ rebase ● ││ DESCRIPTION │
│ ▸ --onto ││ If <branch> is specified, git rebase will │
│ ▸ stash ││ perform an automatic git switch <branch> │
│ ▸ merge ││ before doing anything else. … │
│ ││ │
│ ││ FLAGS │
│ ││ -i, --interactive Make a list of commits │
│ ││ about to be rebased │
│ ││ --autosquash Auto-move fixup! commits │
│ ││ -S, --gpg-sign[=KID] GPG-sign commits │
│ ││ │
│ ││ carapace · structure ✓ · prose ✓ │
╰────────────────────────────╯╰────────────────────────────────────────────────╯
↑↓ move → expand / search y copy ? help q quit
Layout. Vertical: search bar (3 rows) / body (fill) / status bar (1 row).
Body horizontal: tree pane / detail pane. The tree pane is
Constraint::Min(24) and the detail pane fills the remainder — not a
percentage split. At 80 columns a 35% tree pane leaves ~20 usable cells after
borders and depth indentation, which is not enough for a name plus a summary
[M-7]. Below 60 columns total, drop summaries from tree rows entirely; below 50,
the panes stack vertically and the detail pane is toggled with Tab.
Design principles.
- One accent color, used sparingly — the selected row, and flag names in the detail pane. Everything else neutral: dim gray for hints and summaries, default foreground for names. No color as decoration.
- Consistent indentation — each tree depth is exactly 2 cells. Expanding
changes only the chevron glyph (
▸/▾), never the row's horizontal layout. This matters for mouse hit-testing (§9) and for not making the eye re-track. - Breadcrumbs in the detail pane header, always showing the full path
(
git › rebase), so context survives scrolling. - Provenance is legible, not decorative. The footer of the detail pane names the contributing sources and whether structure and prose each came from a trusted source. This is a trust calibration device, and it must be accurate (see §4's per-field provenance) or it is worse than nothing.
- Rounded borders (
BorderType::Rounded), consistent 1-cell padding, no nested boxes.
Flags are not tree rows. git alone carries 2,999 flags [M-1]; putting them
in the tree makes the tree useless. Flags live in the detail pane. They are still
independently searchable and addressable (§4 NodeRef, §10), which is what
users actually need — the tree is for structure, search is for flags.
Interaction model.
| Key | Action |
|---|---|
↑/↓, k/j |
Move tree selection |
→/Enter/l |
Expand (triggers lazy extraction if the subtree is unfilled) |
←/h |
Collapse, or jump to parent if already collapsed |
/ |
Focus search |
Esc |
Leave search, keeping the filter pinned; Esc again clears it |
Tab |
Move focus between tree and detail pane (detail pane scrolls with ↑↓) |
t |
Verbatim view: re-probe this node and show the tool's own --help output instead of the parse |
y |
Copy: the selected flag's spelling, or the node's full command path |
? |
Keybinding overlay |
r |
Re-extract this tool. Preserves expansion, selection, filter and view mode; abandons the in-flight warming cascade and restarts it |
q, Ctrl-C |
Quit |
| Mouse | Click row selects; click chevron toggles; wheel scrolls the pane under the cursor |
y is not a nice-to-have. Looking up a flag in order to type it is the terminal
step of the core journey, and a reference tool that can't hand you the string
makes you retype it.
t is the escape hatch for the one failure mode the rest of this document's
honesty machinery cannot signal. §7 Tier B's staged degradation labels a node it
could not parse, and the confidence cap marks a thin parse as a guess, but a
grammar that misreads a layout and produces a plausible tree is
indistinguishable from one that read it correctly — that is exactly what the
fabricated-subcommand regressions ([M-8], apt-get, git bisect) looked like from
the inside, and each was caught by a human reading the tool's real output beside
ours. Rather than reserve that check for whoever runs the coverage harness, t
puts it one key away for every user, on every node.
It re-probes rather than retaining raw text on every node: retention costs
megabytes across a warmed tree to serve one node at a time, and a retained copy
would show what the tool said at startup rather than what it says now — the same
staleness argument that removed the cache (§11). Rule 0 of §6 applies unchanged:
pkill --help is shown, because that shape is measured harmless, but an
interactive request does not widen what may be run — pkill something --help
stays refused here exactly as it is in the extraction pipeline.
There is no universal, machine-readable standard through which CLI tools expose their own structure. Unlike OpenAPI for HTTP APIs, nothing equivalent ships with most binaries. This is a fact about the ecosystem, not a gap to architect around; any design that pretends otherwise degrades into per-tool patches.
What exists is a fragmented set of partial mechanisms. Their measured coverage:
| Mechanism | Reality on a real machine |
|---|---|
| carapace-spec catalog (declarative YAML, hundreds of tools) | 740 tools, 48,224 flag descriptions in the vendored snapshot. git 279 nodes / 2,999 flags / 2,979 with prose; docker 162/836/836; gh 249/1,061/1,061 [M-1]. Zero subprocesses, works on Windows. |
cobra __complete (Go: kubectl, docker, gh, helm) |
Works, and is version-accurate. Flags require a second probe with "-" [M-2]. The empty-word probe returns subcommands plus the command's own ValidArgsFunction output — live user data at a leaf — so only fully-described candidate lists may be read as subcommands [M-2a]. Descriptions are terse. Cost: one subprocess per node per probe [M-3]. |
clap CompleteEnv (COMPLETE=zsh <tool>) |
Near-absent in the wild, and since removed as a source. ripgrep errors; cargo prints ordinary help [M-4]. Detection had no protocol-guaranteed signal, so on a PATH sweep it matched ten tools of which none were clap — see §7 Tier E. |
<tool> completion bash|zsh |
Common. But bash scripts typically compute candidates at runtime ($(git ls-files)), so static parsing recovers less than it appears. zsh _arguments blocks are the description-bearing form and the higher-value target. |
man pages (mdoc(7) semantic, man(7) prose) |
Prose-rich, structure-poor, and absent on many systems: this test container has 31 man1 pages and none for git or curl [M-5]. libmandoc is not a shipped library on Linux [M-6]. |
--help |
Universal, and the only thing every tool has everywhere. Also the messiest: output may go to stderr and the exit code may be non-zero [M-8]. |
The honest design goal is therefore a tiered pipeline that merges the best available source per field, and degrades visibly rather than silently.
The key architectural consequence, and the thing that makes "no monkeypatching" real: the universal parser is not any one technique — it is the fact that every technique normalizes into one schema. The TUI, the search index, and the cache never know which tier produced a field. That is what lets a Tier 6 be added next year without touching the UI.
/// mandible-core: the shared schema every extraction tier must produce.
pub struct CommandNode {
pub name: String,
pub aliases: Vec<String>,
pub summary: Option<Text>, // one-line hint
pub description: Option<Text>, // long-form prose
pub usage: Vec<Text>, // raw usage patterns, kept verbatim
pub flags: Vec<Flag>,
pub positionals: Vec<Positional>,
pub subcommands: Vec<CommandNode>,
pub examples: Vec<Example>,
pub hidden: bool,
pub deprecated: Option<Text>, // Some(reason) when deprecated
/// True when this node's children are known-complete. False means the
/// subtree has not been extracted yet (see §5, lazy extraction).
pub children_filled: bool,
pub provenance: Provenance,
}
pub struct Flag {
pub short: Option<char>,
pub long: Option<String>,
pub value_name: Option<String>, // "FILE" in `--output FILE`
pub value_kind: ValueKind,
pub choices: Vec<Text>, // `--format {json|yaml|table}`
pub repeatable: bool,
pub required: bool,
pub hidden: bool,
pub deprecated: Option<Text>,
/// True when inherited from an ancestor (cobra persistent / carapace
/// `persistentflags`). Rendered in a separate, dimmed group.
pub inherited: bool,
/// Display grouping from the source, e.g. tar's "Main operation mode".
pub group: Option<String>,
pub description: Option<Text>,
pub default: Option<Text>,
pub env_var: Option<String>,
pub provenance: Provenance,
}
pub enum ValueKind { None, Required, Optional }
pub struct Positional {
pub name: String,
pub required: bool,
pub variadic: bool,
pub description: Option<Text>,
pub provenance: Provenance,
}
pub struct Example { pub command: Text, pub explanation: Option<Text> }Every string that originates outside this process is a Text, never a
String. Text is a newtype whose only constructor sanitizes:
impl Text {
/// The ONLY way to build a Text from tool output. Strips C0 control
/// characters and ANSI/OSC escape sequences, resolves backspace-overstrike
/// (`_\bX` and `X\bX`, as emitted by rendered man pages), expands tabs to
/// spaces, collapses runs of whitespace, normalizes newlines, and truncates
/// to a hard cap.
pub fn sanitize(raw: &str) -> Text;
}This is an IR invariant, not a widget concern. A single \n inside a
ratatui Span shifts cells and eats a pane border; the previous
implementation attempted to fix this twice at the widget layer and reverted both
times. The fix has to be at the boundary where untrusted text enters the IR,
because there are three consumers (tree, detail pane, clipboard) and each would
otherwise need its own defense. Widgets are permitted to assume Text is clean.
Text retains paragraph breaks (\
10BC8
n\n) for the detail pane's description
rendering; the tree pane collapses to a single line at render time.
The raw pane (key t, §2) deliberately does not go through
Text::sanitize. Its whole job is showing the tool's own bytes, and
sanitize's whitespace-collapsing and paragraph-unwrapping are exactly what
would destroy the column alignment a reviewer needs to judge the parser
against. A second constructor, Text::sanitize_preserving_layout, exists for
this one path: it strips ANSI/OSC/DCS escapes, stray carriage returns, and
other C0 controls (a raw terminal escape or a lying \r could scramble the
reader's terminal, so this much neutralization is not optional even for a
"raw" view), and expands tabs to spaces at 8-column stops, because ratatui
gives a bare \t zero display width and leaving it unexpanded would misalign
columns rather than preserve them. It does not collapse whitespace, trim, or
unwrap paragraphs, and it is truncated to the same MAX_TEXT_CHARS bound as
sanitize. Text::sanitize itself, and every use of it on the path that
feeds the IR, is untouched; only mandible-extract's help_text::raw_help*
functions call the preserving variant. The two constructors are verified
apart: diffing the raw pane against independently captured --help output
for du (column alignment) and curl --help all (large output) came back
byte-identical.
One consequence worth knowing when comparing the pane to your own terminal:
mandible probes tools by absolute resolved path, so a tool that echoes its
own argv[0] prints Usage: /usr/bin/du in the pane and Usage: du in a
shell where du was found via PATH. That difference is correct: it is
what the tool actually received as argv[0] in each case, not a defect in
either the probe or the pane.
The raw pane also displays stdout and stderr both, labelled, even though §7 Tier B's parser reads only one of the two per its own rule. See that section for why the two paths differ.
pub struct Provenance {
/// Which sources contributed to this item, ordered by contribution.
pub sources: SmallVec<[Source; 2]>,
/// Set only when a heuristic tier produced this item.
pub confidence: Option<f32>,
}
pub enum Source {
NativeDynamic { protocol: &'static str }, // "cobra-dunder-complete"
KnownSpec { provider: &'static str }, // "carapace", "withfig"
CompletionScript { shell: &'static str },
ManPage { format: ManFormat }, // Mdoc | Man
HelpText, // a structured block: table, .TP, ...
HelpTextSynopsis, // a usage line — spellings, never prose ([M-15], §13.1b)
UserOverride,
}Revision 1 attached one Provenance to a node while merging fields
independently. After a three-tier merge the node's badge names whichever tier
landed first, while the flag descriptions underneath may come from a different
tier entirely — the badge lies. Since the badge exists specifically as a
trust signal, an inaccurate one is worse than none. Provenance therefore lives on
CommandNode, Flag, and Positional individually, and the detail pane's footer
summarizes: carapace + help-text · structure ✓ · prose ✓.
pub enum NodeRef {
Command(Vec<String>), // ["git", "rebase"]
Flag { path: Vec<String>, key: FlagKey }, // ["git","rebase"] + --interactive
}Paths are name-based, which is fine for commands but insufficient for search
results, which must be able to point at a flag (§10). NodeRef is the single
addressing type used by search, the clipboard, and the cache.
Resolution walks subcommands by exact name match at each level. It must not
contain a "skip any segment equal to the current node's name" shortcut — that
silently mis-resolves a subcommand sharing its parent's name.
Revision 1 merged with "first tier in priority order wins," which is only correct if priority equals fidelity. It does not: the tier with the best structure is frequently not the one with the best prose [M-1, M-2]. Each source therefore declares two authority levels, and merge resolves per field against the relevant one:
pub struct Authority {
/// Trust for names, nesting, arity, which flags exist.
pub structural: u8,
/// Trust for descriptions, summaries, examples.
pub prose: u8,
}| Source | structural | prose | Why |
|---|---|---|---|
UserOverride |
255 | 255 | Explicit user intent always wins |
NativeDynamic |
200 | 40 | Version-accurate structure; terse or absent prose |
CompletionScript |
150 | 30 | Accurate names; prose rarely present |
KnownSpec (carapace) |
120 | 200 | Curated and prose-rich, but a snapshot that can lag the installed version |
ManPage |
60 | 180 | Excellent prose, weak/partial structure |
HelpText |
80 | 120 | Always available; both axes heuristic |
Merge rules:
- A field is taken from the contributing source with the highest authority on that field's axis. Ties break toward the earlier contributor.
None/empty never displaces a value, regardless of authority.- Flags unify by alias pairing, not by long-name equality alone. Sources
legitimately emit a flag's short and long forms as separate items —
gh __complete pr -returns--repoand-Ras distinct rows with identical descriptions [M-2]. Pairing runs before merge: within a node, items whose descriptions match exactly and whose short/long slots are complementary unify into oneFlag. Revision 1'ssame_flagcould never unify these and would render one flag twice. - Subcommands merge recursively by name.
children_filledis the logical OR of contributors.
Building a cobra tool's tree by recursive probing is not cheap:
docker 255 nodes, 232 subprocess spawns, 10.5 s (depth-capped at 3; docker is deeper)
gh 196 nodes, 182 spawns, 11.6 s
~40–65 ms per spawn [M-3]
That is with one probe per node. A correct cobra implementation needs two (subcommands, then flags [M-2]), so ~20–25 s, and uncapped depth is worse. This is the single largest UX risk in the project and revision 1 did not mention it.
A whole-tree extract() forecloses the only real fix. The trait is therefore
node-scoped:
pub trait ExtractionTier: Send + Sync {
fn name(&self) -> &'static str;
fn authority(&self) -> Authority;
/// Cheap, side-effect-free check: can this tier plausibly handle `tool`?
/// Must obey §6 (execution safety). Result is cached per run.
fn detect(&self, tool: &ResolvedTool) -> bool;
/// Extract exactly one level: the node at `path`, its flags, its
/// positionals, and the *names* of its direct subcommands. Do not recurse.
fn extract_node(&self, tool: &ResolvedTool, path: &[String]) -> Result<CommandNode>;
/// False when the source is already fully in memory (e.g. carapace), in
/// which case the runner requests the whole tree in one call and there is
/// nothing to defer.
fn is_incremental(&self) -> bool { true }
}The runner:
- Renders immediately, from a stub root carrying only the tool's name. The
TUI does no extraction before its first frame — resolving the name on
PATHis a filesystem lookup with no spawn. (Revision 3 extracted the root synchronously here; that cost ~1.1s forghand ~0.7s fordockerbefore anything was drawn.) - Queues the root for a background fill, then cascades: every completed fill queues the children it just discovered, walking the whole reachable tree on a bounded pool, cancelled on quit and capped at 4096 nodes.
- On expand, a node not yet filled is queued at the front of that same
mechanism; nodes still in flight render as
⋯ loadingrows.
Warming covers the whole tree, not one level ahead. Revision 3 warmed only one depth past whatever the user had expanded. That kept the spawn count minimal but had two costs that outweighed it: an unexpanded node is invisible to search (the index can only hold what has been extracted), and a node that renders empty with nothing explaining that it needs a keypress reads as a bug rather than as laziness. Filling everything in the background is the same total work spread over idle time, and it is what makes a search over the whole tree honest.
This is not a return to §5.1's eager extraction: nothing blocks startup or a keystroke, and the pool is bounded. The distinction that matters is not how much gets extracted but what the user waits for — and the answer is nothing.
Background fills never expand the node they fill. Expansion is user intent. When every node is warmed, auto-expanding on arrival unfolds the entire tree and buries the user in rows they never asked for.
Pool sizing is one worker per core, clamped to [2, 8]. An earlier
revision deliberately oversubscribed (available_parallelism * 4, clamped
[4, 32]) on the theory that a warming job spawns a child and then blocks on
it, costing no CPU of its own. The theory holds for the typical small C tool
and was measured false exactly where warming is heaviest: a docker
invocation burns 70–100ms of real CPU per spawn (Go runtime startup plus a
daemon round trip), so 16 concurrent probes on a 4-core machine pegged every
core for the duration of the warm — reported by a real user as the tool
maximizing their CPU for minutes. One probe per core keeps the machine
responsive; the cost is a slower warm on cheap-probe trees, paid in
background time nobody is waiting on, and the expand path still jumps the
queue so the visible tree fills as fast as before.
Non-incremental sources (carapace) return their full subtree at step 1; they cost nothing, so there is no reason to defer them.
A tier that fails on one node must not invalidate the tier. The runner records
per-node, per-tier status and keeps whatever merged. TierStatus is surfaced in
the ? overlay and in mandible --doctor <tool>, so "why is this flag missing"
is answerable without a debugger.
The runner errors only when no tier produced a root node.
mandible --report <TOOL> assembles a paste-ready bug report: mandible's
own version, the target tool's version when recoverable, the --doctor
diagnostic (reusing its report-building code rather than re-deriving it), and
a raw --help capture, followed by the repository's issues URL. It goes
through the same sanctioned probe chokepoint every other tier uses and adds
no new argv shape. The honest limitation: a tool's version is scraped
best-effort from the same --help banner already captured (clap's own
template opens with "<name> <version>", which this recognizes), but most
tools never print a version in --help at all, so the report usually asks
the person filing it to paste <tool> --version themselves. Recovering it
automatically would mean issuing a --version probe, which is a new argv
shape against §6 rule 2's closed list and has deliberately not been taken.
mandible --review <SEED> (with --audit-dir, default audit) opens
the audit review loop (§13.1c) inside the real TUI: it walks
audit/<SEED>.toml's pending entries in file order, opening each tool
exactly as mandible <tool> would (same tree, same lazy fill, same raw
pane), and saves a verdict to the manifest immediately after every
confirmation, never batched, so a killed session resumes at the next pending
entry with everything answered so far intact.
mandible runs other people's binaries. This is the part of the design that can damage a user's machine, and it gets its own section and its own tests.
Rules, binding on every tier:
-
Never invoke a bare binary. Revision 1's clap probe ran
<tool>with no arguments to see whether it honoredCOMPLETE=. Running an arbitrary binary bare is how you launch a REPL, block on stdin, start a daemon, or trigger a tool whose no-arg default is an action. (That probe has since been removed entirely — §7 Tier E — but the rule stands for every tier: an argv is never empty.) Note that rule 2a is the necessary companion: counting arguments is not enough, because an empty argument satisfies this rule while being the opposite of inert.1a. A framework protocol's words are a bare invocation to a tool that does not speak it.
__complete,completion <shell>and-- <partial>are subcommand invocations only in the framework that defines them. Fired speculatively at an arbitrary binary they are ordinary positionals, and rule 1's prohibition applies in substance even though the argv is non-empty and sits on rule 2's list. The list is not wrong; its premise is narrower than it reads. Every shape on it was validated against tools that parse argv — the measurement was "does a getopt program act on this?", and the answer was no. A program that ignores argv and starts anyway is outside that premise entirely, and there is nothing about a shape that can tell you which kind of program is on the other end.Both incidents this section records are that same failure, twice:
wall __completebroadcast a word to every terminal on a machine, and<tool> completion zsh/bashleft 437 daemons running (rule 4's measurement). Neither was a bad shape; both were a right shape sent to the wrong program.So a protocol word requires prior evidence that the tool speaks the protocol, from the tool itself, before any argv is constructed. Tier E gates
__completeon thespf13/cobramarker read out of the compiled binary (§7 Tier E). Tier C gatescompletion <shell>on either that same cobra marker — cobra registers the command itself, possibly hidden, so bytes are the only evidence available — or the tool's own root--helpnamingcompletion/completionsas a command (§7 Tier C). Evidence is read from the artifact or from the tool's own output, never from its name: a per-tool list of who may be probed is §1's forbidden knowledge with a safety label on it, and the never-probe list (rule 0) stays what it is — closed, small, and about programs that act on machine state — rather than becoming the dumping ground for every tool that misbehaves.This is also where the leverage is. Framework detection identifies a minority of tools, which reads as a coverage gap until you notice what it implies here: the overwhelming majority of ~2,300 speculative protocol probes per sweep were fired at tools that could never have answered them. That majority is the daemon leak, the PTY-canary abort, and two thirds of sweep time — one defect with three symptoms, not three defects.
-
Only inert argv shapes. A tier may invoke a tool only as:
__complete <words...>,completion <shell>,--help,-h,help [<words...>],<words...> --help/-h(a subcommand path's own probe,HelpLongForPath/HelpShortForPath),<words...> --help <word>(rule 2b, below), or-- <partial>underCOMPLETE=. Any other shape requires a spec amendment. TheCOMPLETE=shape is currently unused — no tier constructs it since Tier E's clap probe was removed — and it is retained on the type only so removing a public enum variant is not forced into a patch release.2a. No empty argument the tool could read as its first positional. Rule 1 only counts arguments, and an empty string satisfies it while being the opposite of inert.
--is the option terminator essentially every getopt program discards, so<tool> -- ""delivers an empty string as the tool's first positional — and a program whose first positional is a pattern reads that as match everything. Measured:pkill -- ""terminated every process in a private PID namespace, pkill included. This was the actual mechanism behind the machine reset that motivated rule 0, which masked it for thirteen named tools while the same argv still went to the rest of PATH.Enforced at the
run_inertchokepoint, not at call sites, so no tier can reintroduce it. Exactly one empty argument is permitted: cobra's completion word, which is protocol-required (docker __completewithout it fails with "requires at least 1 arg(s)") and is never the first positional — the__completesentinel precedes it, and a non-cobra tool rejects that word rather than acting on it. So the rule is checkable: an empty element is allowed only behind a guard word, never straight after--.2b.
InertArgv::HelpExpand— the truncation-confession follow-up (WS5, approved amendment).curl --helpends its own output:This is not the full help, this menu is stripped into categories. Use "--help category" to get an overview of all categories. For all options use the manual or "--help all".That is a truncation confession — the tool telling the reader, in its own words, that what was just printed is not the complete document. Measured:
curl --helprecovers 12 flags;curl --help allrecovers 258, and mandible was reporting the 12-flag document asokat full confidence. It is a convention, not a curl quirk (ffmpeg -h long/-h full,git help -a,gcc --help=<class>are the same genus), so this is a new, general argv shape rather than a curl special case — which is exactly why rule 2's closed list needs a new member, not amandible- extractpatch that quietly bypasses it.A new shape needs an amendment because it is new argv the crate can construct, not because it is dangerous by any measure this section already tracks: it is refused by rule 0 exactly like every other non-
["--help"]shape (run_inert's ownargv.args() != ["--help"]check needs no change to cover it), it cannot produce an empty argument (rule 2a —wordis checked non-empty before this shape is even constructed, see below), and it is bounded by the same timeout, output cap, and scratch-directory redirect as every other probe. What rule 2 exists to police is the shape, and this is a shape nothing on the closed list already covers:--helpfollowed by a second, tool-supplied word.InertArgv::HelpExpand { words, word }renders as[..words, "--help", word](mandible-extract/src/exec/policy.rs). Three constraints, all enforced inhelp_text::confessionandHelpTextTier, not left to caller discipline:wordcomes from the tool's own printed directive, never a prose heuristic and never a fabricated word.help_text::confession:: detect_directivesrecognizes a closed, content-keyed grammar — a quoted"--help <word>"/"-h <word>"shape, the word bare and the quote immediately closing right after it (curl's own"--help all") — never keyed on the tool's name, so it fires identically for any tool that happens to print the same convention.wordis copied verbatim from what matched; nothing about the word is invented, guessed, or derived from the tool's identity.--helpprecedesword. So a getopt that stops at the first non-option (BSD/busybox-style, unlike glibc's permuting one) still reaches--helpbefore ever consideringwordas a positional — this can never degrade into a bare positional some other getopt routes elsewhere, the exact hazard rule 2a exists to close for a different shape (-- ""). Puttingwordfirst was never on the table for this reason alone.- Expansion is followed at most once, never chained. A confession
detected inside an expanded document is not looked at: the probe
that fetches the expanded text returns it as-is, with no second call
back into
detect_directiveson that result. This is structural, not a depth counter that could be miscalibrated — the function that issues the one follow-up probe simply never recurses into itself.
Interaction with rule 0 (the never-probe list) and the attestation gate (
heading_attested, this section's closing paragraph onHelpTextTier). Neither conflicts with this amendment, and both are worth saying explicitly rather than leaving a reader to wonder:- Rule 0 wins unconditionally.
HelpExpand's rendered argv is never exactly["--help"](it is always[..words, "--help", word],wordnon-empty), sorun_inert's existing check refuses it for every tool on the never-probe list with no code change and no special case — apkill-named tool that confesses is refused the expansion exactly as it is refused every other non---helpshape. - A directive-sourced word is structurally attested by construction.
The attestation gate exists to stop a fabricated word — one a
grammar guessed at from layout — from becoming argv (this section's
closing paragraph). A confession's
wordis not fabricated and is not a subcommand name any grammar inferred: it is copied verbatim from text the tool itself already printed, in response to a probe that was itself already attested (the root by construction, or a subcommand path that already passed the gate to be probed at all). Sowordneeds no separate attestation check — it inherits the attestation of the probe that produced it, the same way the root's own--helpprobe is exempt from the gate because it is the name the user typed, not a word any parser invented.
Scope, deliberately narrow. Only the single-word "expand to one complete document" shape ships (
help_text::confession's closedFOLLOWABLE_WORDSvocabulary,allonly for now). curl's other directive,--help category, is detected (soincompletestill fires honestly) but not followed: following it returns a menu of category names, not flags, and turning that into real recovery needs enumerating each category as its own probe — a materially bigger feature (--help category→ N probes) this amendment does not cover and does not partially build.A confession that is detected but not followed — an unrecognised word/shape, a failed follow-up probe, or a rule 0 refusal — caps the node's status at
incomplete(§13.1's status ladder:ok > incomplete > low-confidence > verbatim > no-tier) rather than reporting a confidentokon a document the tool's own text already said was truncated.Detection-only extension: two more shapes (WS5's own genus list, finally recognized). curl was the specimen this rule was built from, but it was never the only one named — this rule's own genus list, above, already cited
ffmpeg -h long/-h fullandgcc --help=<class>. Neither is curl's quoted"--help <word>"shape, sohelp_text::confession::detect_directivesdid not see either one, and both tools were measured reporting a confidentokover a document their own text already said was incomplete — ffmpeg 91 flags at 97% described, gcc 43 flags at 95% described. Two grammar additions close that, detection only:- ffmpeg's shape is unquoted, inside a flag-table row rather than
prose:
-h long -- print more options.help_text::confession:: match_unquoted_table_rowrecognizes<flag> <word> -- <description>, anchored to the trimmed line's start exactly as the quoted form is anchored to the character right after an opening quote — a bare--token must sit directly between the captured word and a non-empty description, which is what keeps it off an ordinary flag row (-h, --help show this help message and exit: a comma sits where this grammar requires a space) and off a distinct, longer flag name (--help-all: no space between--helpand-all). - gcc's shape is a flag definition, not an invocation example:
--help={common|optimizers|...}[,...].lists--helpitself as taking a value.help_text::confession::match_flag_value_rowrecognizes<flag>=<opener>..., requiring the character right after=to be one of{,[,<,(— the punctuation a class/placeholder enumeration opens with, never a bare word — so a hypothetical literal-valued row (--help=yes) or an optional-value row (--help[=FMT], where[sits before=, not after it) is never mistaken for it. The word recorded is the first class name ("common"), taken verbatim.
Both are safe to add without a rule 2 amendment, for the same reason detecting curl's shape was: detection only changes what gets recorded on the node (a
Confession), never what argv gets constructed. Rule 0'sargv.args() != ["--help"]check, the attestation gate, and every other execution-safety mechanism in this section are untouched, because nothing new is ever run.Following either is explicitly deferred, not shipped. Neither shape's word is added to
FOLLOWABLE_WORDS, and no newInertArgvvariant exists to construct the argv either would need: ffmpeg's own invocation is-h long(bare-h, no--helpprefix at all — notHelpExpand's[..words, "--help", word]shape), and gcc's is--help=common(one joined token, not--helpandallas two separate ones — also notHelpExpand's shape). Each is new argv this crate does not yet construct, so each needs its own rule 2 amendment and its own §6 deliberation before it can ship, exactly as this amendment itself was required for curl's--help all— that is deferred work (WS5b), not a gap in this change. Until then, both directives are recorded withfollowed: falseand cap status atincompletevia the ladder above — the same honest-but-incomplete outcome curl's own--help categoryalready gets. An undetected confession is a falseok; a detected-but-unfollowed one is honest, which is what this extension buys on its own. -
stdin is always
/dev/null. No tier may ever inherit or pipe stdin. -
Hard wall-clock cap, 2 s for
detect, 10 s forextract_node. On expiry kill the process group, not just the child — completion scripts spawn helpers, and killing only the direct child leaks them.And no probe is complete while its descendants are alive. The process group is necessary and not sufficient, for a reason the rule as written above missed: a program that daemonises leaves the group and the session on its own —
fork, parent exits, childsetsids — after which nothing about the survivor's group, session, or parent points back at the probe. Measured: 622 processes left behind on a developer box, the oldest five days old —blkmapd×148,rpc.idmapd×144,rpc.gssd×144, plussudo_logsrvdlistening on0.0.0.0:30343and[::]:30343,guacdon127.0.0.1:4822, andpam-auth-updateburning a full core for three days. Not a hang, which is why no timeout change could have helped: all 2,302probe-startlines in a traced sweep had a matchingprobe-done, so every probe involved returned normally and its child was already gone.run_inerttherefore reaps before returning, in two halves (mandible-extract/src/exec/reap.rs). The process marks itself a child subreaper (prctl(PR_SET_CHILD_SUBREAPER)), so an orphaned descendant is reparented to mandible rather than to init however many times it forked orsetsided — the kernel walks up to the nearest subreaper ancestor and neither session nor process group takes part in that walk. That reduces the candidate set to "our own children". A per-invocation token in the probe's environment, checked against/proc/<pid>/environ, is what then attributes a survivor to this probe: adoption alone cannot distinguish a leaked daemon from a child a concurrent probe legitimately owns, and a blunt "kill anything adopted" would be a new hazard rather than a fix. Killing is by pid, never by process group — an escapee's pgid is usually the direct child's pid, already waited on and free to have been recycled. Rounds and a wall-clock budget bound the work, so a process wedged in uninterruptible sleep costs milliseconds instead of turning this into a new way for extraction to hang. Linux only (/proc+PR_SET_CHILD_SUBREAPER); elsewhere the leak stands as the residual risk rule 8 already documents.This is the second layer, not the fix. The root cause is a probe that should never have been sent — see rule 1 and §7 Tier C's gate. Reaping contains what runs anyway, exactly as
exec::containmentdoes for a sweep, and must never become a reason to send a riskier argv.mandible-extract/tests/exec_policy.rs'sdouble_fork_escapemodule is the regression net: its shim starts asetsided daemon and exits 0, so the probe never reaches a timeout; the test asserts the daemon really did lead its own session (otherwise it would be proving the pre-existing group kill) and that/proc/<pid>is gone rather than holding a zombie. It fails without the reap. -
Bounded output. Read at most 8 MiB of stdout+stderr per invocation; a tool that streams forever must not exhaust memory. Reader threads (or a poll loop) are mandatory to avoid pipe deadlock on large output.
-
Sanitized environment, and a new session. Clear
LESS; setPAGER,MANPAGER,GIT_PAGER, andSYSTEMD_PAGERtocat(not merely clear them — absence is the weaker property, since several ecosystems read an unset pager variable as "go find one yourself" rather than "don't page"); setTERM=dumb,NO_COLOR=1,COLUMNS=100,LC_ALL=C.UTF-8. Spawn the probe as the leader of a brand-new session, not merely a new process group:process_group(0)alone leaves the child in the same session as mandible, so the session's controlling terminal is still reachable, and a descendant canopen("/dev/tty")to read and write it directly regardless of what stdin/stdout/stderr were redirected to.A user reported
mandible systemctlfreezing their entire TUI, with a pager observed.env_clear()used to leavePAGERmerely absent rather than set, andprocess_group(0)alone does not sever the controlling terminal — together, the working theory was that an absentPAGERlet a tool go findlessitself, which then opened/dev/ttydirectly for keyboard input (bypassing piped stdout entirely) and left termios changes on the tty device that a process-group kill cannot undo (termios state lives on the device, not the process). [M-17] measured that this specific theory does not hold forsystemctl: systemd's own pager gate checksisattyon its own stdout/stderr, whichrun_inertalways makes pipes, so no argv this crate constructs againstsystemctlever reaches the pager at all — a 74-verb sweep plus directstraceconfirmation thatlessitself never attempts/dev/ttyonce its own stdout is non-tty, even with a real controlling terminal available via the session. But [M-17] also confirmed, with a shim that does nothing but attemptopen("/dev/tty"), that the underlying mechanism the report pointed at is real and general, independent ofsystemctlor pagers specifically: underprocess_group(0)alone, a descendant can reach a real controlling terminal; spawning the probe in its own session (viapre_exec+setsid(), this crate's one auditedunsafe— seemandible-extract/src/exec/spawn.rs) makes that sameopenfail withENXIO.tests/exec_policy.rs'sdev_tty_hazardshim test is the regression net: it fails without the session fix and passes with it. The pager variables are kept set tocatanyway as defense-in-depth against a tool whose own pager gate is weaker than systemd's. -
Programs that signal processes or change machine state are invoked only as
<tool> --help.kill,pkill,killall,killall5,skill,xkill,fuser, and the system-state commandshalt,poweroff,reboot,shutdown,telinit,initmay be run with exactly that one argument vector. Every other shape —-h,help <word>,<word> --help,completion <shell>,__complete— is refused before anything is spawned.This began as a total ban, after a user reported
mandible pkillfreezing their machine badly enough to require a reset. Two later measurements reshaped it.The reason originally given was false. It held that rule 2's
<tool> <word> --helpshape makeskillall foo --helpkill everything namedfoo. On glibc, GNU getopt permutes arguments, so--helpis processed wherever it sits:pkill --help,pkill victim --helpandkillall victim --helpwere all measured killing nothing. The reset's real mechanism was rule 2a's empty argument.What the ban was silently protecting against is real, and was never written down:
-his not a help flag on these tools. Measured against systemd's multi-call binary, saved only by polkit because the probe ran unprivileged —halt -h,poweroff -h,reboot -handshutdown -heach attempted the real operation (-his the halt inshutdown -h now). mandible falls back to-hwhenever--helpfails, so that fallback alone would have rebooted a machine running as root.So the rule keeps what is measured harmless and refuses what is measured dangerous, instead of trading one for the other.
--helpyields real flag lists —pkill27 flags,killallandfuser16 each, all fully described; twelve of the thirteen went fromno-tiertookon the PATH sweep. Positional shapes stay refused because argument permutation is a glibc behaviour rather than a guarantee (BSD and busybox getopt stop at the first non-option), and because the background tree warmer would reach any subcommand a future parser change starts emitting, unasked.The general form of that last hazard — a fabricated word becoming argv for any tool, not just these — is now closed for the one place in this crate that constructs a positional
--helpprobe: Tier B's<word> --help/-h(HelpTextTier,mandible-extract/src/help_text/mod.rs) fires only whenNodeHints::heading_attestedis true — the word came from a recognized command heading (or the chain a heading started), never from layout alone. A non-attested node is not probed in any shape; the tier declines and records a per-node, per-tier failure (§5.3) rather than fabricating a probe or letting the tree silently gain an empty-but-successful node in its place. The root is exempt by construction (Runner::extract_full_forpassesheading_attested: truefor it, since it is the name the user typed, not a word any parser invented), so the ordinary<tool> --helproot probe is unaffected.mandible-extract/tests/exec_policy.rs's shim suite proves both halves: an attested word is still probed and its real flags recovered; a non-attested one reaches the tool's binary not at all — verified by running the same assertion against the pre-gate code path and watching it fail.This list stays anyway, and is not made redundant by that gate. The two close different gaps: the gate above governs when a word is trusted enough to become argv at all, while this list governs what these thirteen specific programs may be asked to do even with a trusted word —
--helpremains their only permitted shape regardless of provenance, because for them even a genuine, correctly-attested subcommand name is still a target (killall foo --helplooks safe by attestation and is refused anyway, sincefoonaming a real process is exactly the risk). The gate also inherits whatever a grammar's own heading-recognition gets wrong —heading_attestedis only as trustworthy as the rules inhelp_text/sections.rsthat set it, and those have needed several fixes (AGENTS.md §2's invariant table records more than one) — while this list is closed on a fact about the program itself, independent of any parser. Belt and suspenders, on two different axes.This is a safety rule, and is deliberately not the per-tool knowledge §1 forbids. §1 governs extraction — "if a tool renders badly, fix the general parser" — because such lists grow without bound and rot. This list is about what may be executed at all, is closed, and every entry shares one property that is a fact about the program rather than about its output format. The check lives in
exec::run_inert, which every tier goes through, so no tier can reach one of these by another route; a test asserts a shim namedpkillis never executed under any argv but--help, and is executed for that one — both halves matter, since silently refusing the permitted shape would quietly undo the coverage this rule now allows.A second attestation bit exists now, and this gate deliberately does not read it.
heading_attestedwas doing double duty — "is this word safe to probe" (this gate) and "is this name real evidence, not a fabricated phantom subcommand" (the coverage harness's structure-sanity check, spec §13.1). §7 Tier B's headingless-invocation-table recognizer (below, and its own subsection under §7 Tier B) needed a name to answer "yes" to the second question and "no" to the first — a table row is layout evidence about a document the tool printed, existence-checked against the raw text, but it is not a heading declaring "here is the command list", which is the specific evidence this gate exists to demand before a word becomes argv. Splitting the bit rather than reusing it is the decision recorded here:mandible_core::CommandNode::invocation_attestedis a second, independent field, set only by that recognizer. This gate (andprobe_help_text_reporting_flag/raw_probe_streams, its two call sites) readsheading_attestedonly and must never be widened to also acceptinvocation_attested— existence in the text is not the same claim as a heading declaring a command list, and the whole point of keeping the bit separate is that a table-row name never becomes probe argv. The coverage harness's detectors (xtask::status::count_suspicious,xtask::audit::is_attestation_gated_stub) accept either bit as evidence a node names a real command, so a headingless-table node is never mis-flagged as [M-10]'s fabricated-phantom-subcommand shape merely for being correctly withheld from probing. -
Never write. No tier may pass an argument that could name a file the tool would create or modify.
-
Redirect every writable location a probe might reach. Rule 7 is not sufficient, because some tools write unprompted on
--help. Measured: a coverage run overPATHcaused font-cache builders to write into mandible's working directory, andmysql_secure_installationto write a.my.cnfcontaining an empty root password [M-11]. Every probe therefore runs withCWD,HOME,TMPDIR,XDG_*, andXDG_RUNTIME_DIRpointed at a per-invocation scratch directory that is deleted afterwards. This is a general policy — never a per-tool exclusion list, which would violate §1. Full containment needs OS-level sandboxing (namespaces/seccomp); until then, document the residual risk rather than claiming the probe is inert.One subdirectory per variable, never one shared directory. They pointed at a single path once, which is a filesystem shape no real machine has — a tool writing
$XDG_CACHE_HOME/xand reading$HOME/xsaw one file — so every probe ran against an environment that cannot occur.The residual risk is now measured, not hypothetical. The timeout kills the probe's process group, which a child that calls
setsidleaves — so anything that daemonises survives it. A full-PATHsweep in CI loses roughly three shards in sixteen to the runner being reclaimed, and instrumenting each probe on both sides named the tools that started and never finished:chromedriver(starts a browser-driver server),vimtutor(launches vim),ghci(opens a REPL),syscount.bt(attaches kernel probes). The common property is not the tool but the behaviour:--helpis not what these programs do when they don't recognise it, and what they do instead outlives the process group we can reach.Exposure differs sharply by use, which is why this is a documented limit rather than a blocker: interactive use probes one tool and its subcommands, while the coverage harness runs ~1,500 arbitrary binaries in one process and is the only place the orphans accumulate.
One deliberate exception: toolchain-resolution variables. Redirecting
HOMEbreaks every version-manager shim, because they resolve the program they stand in for through it —mandible cargoreported "rustup could not choose a version of cargo to run" instead of cargo's help, and the same applied to pyenv, nvm, rbenv, asdf, sdkman and volta. A whole class of developer tooling was unusable, which is a poor trade for a containment boundary that these variables do not weaken much: each names a toolchain directory, not the user's home, so a misbehaving probe has a far narrower blast radius than$HOME.So
RUSTUP_HOME,CARGO_HOME,PYENV_ROOT,NVM_DIR,RBENV_ROOT,ASDF_DIR,SDKMAN_DIRandVOLTA_HOMEare passed through, whileHOMEitself stays redirected. Passing them through is not enough on its own: almost nobody sets them by hand, so the manager falls back to a documented path under the real$HOME— exactly what the sandbox replaces. The default is therefore materialised from the real home before the redirect, and only when that directory exists.This is a closed list of ecosystems, not of tools, which is what keeps it on the right side of §1: the knowledge is "how version managers locate toolchains", not "how cargo works". Adding an ecosystem is one entry; it never grows per tool.
-
Mask the redirect back out of the output. Rule 8 has a cost the other rules don't: it changes what tools say. A tool printing a
$HOME-derived default prints ours —docker --helpreported its config location as/tmp/mandible-exec-L3saJ8/.docker, a directory deleted moments later that never existed for the reader, with nothing on screen marking it as anything but docker's own documentation. The safety mechanism had become a source of confidently false documentation, which is the failure §7's whole degradation ladder exists to prevent when a parser causes it.Each scratch path is replaced with the variable that stood in for it (
$HOME/.docker), at the same boundary that applied the redirect, so every tier,--doctorand the verbatim view get it without knowing. Deliberately not the reader's real home directory: the tool never told us that, and filling in a blank is the same move as inventing structure, only smaller. It is how man pages write such defaults anyway, and it is identical on every machine, so a fixture captured from a real tool cannot bake in the capturing machine's paths.This is what rule 8's one-subdirectory-per-variable requirement buys. With a single shared directory there is no correct answer to write back, because
/tmp/…/.dockercould have come from any of seven variables.Matching is on this invocation's exact path, never a
/tmp/mnd-*pattern, so a temp path a tool legitimately prints is untouched. Every path is registered under both its logical and its canonicalized spelling, because a probe that resolves its own working directory prints the physical one: on macOS$TMPDIRsits under/var, a symlink to/private/var, so registering one form left the output readingcwd=/private$PWD— a mangled hybrid harder to spot than an unmasked path.Residual: a tool wraps its own help text at the
COLUMNSwe set, so a path split across two lines cannot be matched. The scratch prefix is kept short to make that rarer; it does not eliminate it.
A test asserts rules 1, 2, and 3 by running the full pipeline against a shim binary that logs its argv and environment, and failing on any invocation outside the allowlist.
Tiers are listed by the order they are attempted, which is now purely a cost
ordering — cheapest first. Conflict resolution is by Authority (§4.4), not by
attempt order. These are two different things and conflating them was
revision 1's central error.
Revision 2 ranked a vendored 739-tool carapace-spec snapshot first. Revision 3 deletes it, along with the vendoring script, the 11 MB payload, and the third-party data attribution it carried.
The reasoning, recorded so it is not re-proposed:
- It violated §1. A per-tool catalog is per-tool knowledge — the thing this project forbids — merely relocated from code into data. That it was somebody else's data did not make it not-per-tool.
- It could not be current. A snapshot is a point-in-time copy; the tool on the user's machine is not.
- It cost more than it bought. 11 MB of a 16 MB binary — the data outweighed all the code 4× — to raise flag-description coverage from a measured 87% (live parsing, 904 tools) to 99.5% on the 251 tools it happened to contain [M-12].
The replacement is not "lose those descriptions." It is parse by the framework that generated the help text, below.
The load-bearing insight of revision 3: help text is not written by hand, it is generated, and only a small closed set of generators exists. Per-tool knowledge is unbounded and forbidden; per-framework knowledge is bounded at ~15 entries and is the correct unit of parsing. A grammar fix for argparse improves every Python CLI ever written; a catalog entry improved exactly one tool until it went stale.
Measured on 1,563 executables with usable --help: three fingerprints cover 71%,
about a dozen cover ~80%, even with deliberately crude patterns [M-12].
What was implemented instead, and why the numbers disagree. [M-12] measured
recall — how much of a real machine crude patterns could plausibly reach. The
implementation went the other way and uses narrow, high-precision markers
(clap_builder in the binary, spf13/cobra, the literal GNU argp footer), which
identify ~17% of a real machine's tools rather than 71%.
That gap is deliberate, not a shortfall. A wrong framework silently applies the wrong grammar, and the tool has no way to tell you it did; an unidentified one falls back to the general engine and is honestly marked low-confidence. Given those two failure modes, precision is worth far more than recall.
It also cannot be closed by fingerprinting alone. The unidentified bulk is C
tools — LLVM, binutils, util-linux, iptables, gpg — and most do expose
getopt_long in their dynamic symbol table, so they are detectable. But a
getopt_long profile would be the general engine under another name: it would
parse nothing better, while lifting those tools out of the "unidentified"
confidence cap and raising both the detection rate and their confidence scores
for free. That is precisely the failure §13.1 warns about — a metric improved by
the thing it exists to detect. Widening a fingerprint is only worth doing
alongside a grammar that earns it, never to move the number.
Detection rate is therefore not a target. Coverage is: unidentified tools still
parse, and aggregate %flags_text sits around 96%.
Identify the framework in this order, most reliable first:
- From the artifact. For compiled binaries, scan embedded strings —
spf13/cobraappears 583× indockerand 283× ingh, unambiguously [M-13]. For scripts, read the shebang plus the import line (import argparse,require('commander'),use clap). This is ground truth, not inference. - From the help-text signature. Distinctive marker strings — argparse's
show this help message and exit, click'sShow this message and exit., cobra'sAvailable Commands:, GNU argp'sMandatory arguments to long options. - Unidentified — fall through to the generic layout parser.
Signature matching alone is fragile and must never be the only method: it missed
docker entirely, because docker prints Common Commands: rather than cobra's
usual Available Commands: [M-13]. That failure is exactly why artifact
fingerprinting leads.
--doctor reports the detected framework. This converts "mandible is wrong about
tool X" into "the argparse grammar mishandles Y" — a general, fixable bug report
instead of a per-tool complaint.
The primary tier. --help is the only source every tool has, everywhere, and
it is always current because it comes from the installed binary.
Parsing is dispatched on the framework identified in Tier A′. There is one grammar per framework, not one grammar for everything:
| frameworks | |
|---|---|
| Python | argparse, click, docopt |
| Rust | clap v2, clap v3/v4 |
| Go | cobra, urfave/cli, stdlib flag |
| Node | commander, yargs, oclif |
| JVM / .NET | picocli, System.CommandLine |
| C / POSIX | GNU argp & getopt_long, BSD-terse, busybox |
| PHP / Ruby | Symfony Console, OptionParser / Thor |
Each grammar knows its framework's exact section headings, row layout, value syntax, and continuation rules — so it parses precisely rather than guessing. This is the "one well-engineered parser, built once" principle applied at the right granularity: once per generator, not once per tool, and not once for the whole world.
Degradation is staged, and never fabricates:
- Framework identified → its grammar, high confidence.
- Unidentified → the generic layout parser below, marked low-confidence.
- Generic parse yields nothing structurally plausible → render the raw help
text verbatim, labelled
unparsed, with the framework shown as unknown.
Step 3 is a feature, not a failure. A tool that conforms to no convention is
displaying its help the way its author intended; showing that text untouched is
honest and useful. It is also strictly better than the alternative already
shipped and fixed once here: inventing 39 subcommands for tar out of wrapped
description lines [M-10]. Never fabricate structure. Degrade to verbatim.
The generic fallback parser (step 2) is built with winnow:
-
A
Usage:line grammar. Usage lines have a learnable grammar — this is whatdocoptformalized:[OPTIONS],<required>,[optional],...for repetition,|for alternatives,{a|b|c}for choices. The block is entered at any of three shapes (help_text::sections, fix/usage-synopsis): an ordinaryusage:/Usage:line anywhere in the document; the Cfprintf(stderr, "%s: Usage: ...", argv[0])idiom — the tool's own name,": ", thenusage:(starts_with_name_prefixed_usage;nfsidmap'snfsidmap: Usage: nfsidmap [-vh] ...); and, only when neither of those appears anywhere in the document and only in the lines before the document's real body starts, an unlabelled synopsis — the tool's own name at a word boundary, plus positive usage-grammar notation (one of the three group delimiters above) in the remainder, and not read as an English sentence (looks_like_unlabeled_synopsis_line;wpa_cli --helpopenswpa_cli [-p<path to ctrl sockets>] ...with no label at all). The third shape is the risky one — a name match alone is not evidence of a synopsis ("tar is an archiving program..."also starts withtar) — so it requires both signals together, and is bounded to where a synopsis actually belongs rather than searched for anywhere in the document.A related, general fix landed alongside this (fix/usage-synopsis):
mandible_core::strip_escapes— previously private, invoked only per field insideText::sanitizeat final display time — is now also run once over the whole raw document inhelp_text::sections, before any heading, indentation or column gap is computed. An escape sequence surviving into that layout analysis corrupts it in ways that have nothing to do with display:systemd-creds --helpwrites its ownCommands:heading as�[0mCommands:(a colorizing library's reset code glued directly onto the heading text), and left in place the escape and the heading's first two characters fuse into one alphanumeric run,0mCommands, whichmentions_commands_word(rule 1's literal heading test, above) never recognizes — the tool's real six-commandCommands:block silently failed to become subcommands at all. Measured on a full-PATHsweep isolating this change alone: it moves exactly two tools (systemd-creds,varlinkctl, both recovering aCommands:/command list previously lost to this exact corruption) and nothing else — 0 other status transitions, 0 other flag or subcommand changes fleet-wide, so escape bytes were not otherwise distortingleading_whitespace/find_multi_space_gapbyte offsets for any other tool on the measured machine'sPATH.systemd-sysextandsystemd-confextremain unfixed by this: their command rows sit directly under the description prose with no heading at all, which rule 1 above forbids recognizing without a new, separately-measured recognizer this change does not attempt. -
A colon as the spec/description separator, and a usage-block continuation that is prose, not more grammar (fix/colon-separator).
sg_emc_trespass --helpwrites its usage line as[-d] [-hr] [-s] [-V] DEVICEfollowed immediately by two indented English sentences, then option rows separated by a colon instead of a column gap or=:-d : output debug(spaced),-hr: Set Honor Reservation bit/-V: print version string then exit(glued). Two independent defects, both fixed here:help_text::sections::find_colon_separator_gap, a new fallback infind_description_gap's chain (only ever consulted after both the 2+-space and=-token fallbacks find nothing), modelled onfind_equals_separator_gapbut tighter — a colon is far more common in ordinary prose than a bare=— recognizing a lone:token (spaced) or a token whose own trailing character is:with the rest of that token still spec-shaped (glued:-hr:,-V:, never a heading-shaped word likeOptions:, which ends the scan instead).- The usage-block loop's "more indented than base indent" branch used to
treat any such continuation as more synopsis with no content check
at all; it now ends that one line (not the whole block —
mdadm --helpinterleaves a one-line description under each of sevenmdadm --mode ...forms) when it reads asis_prose_sentence, which itself gained a...-is-not-a-period clause after a fleet sweep showed the new call site misreadingnumactl's andmkfontscale's real ellipsis-terminated usage fragments as prose and losing 19 and 3 flags respectively.
A companion fix to
extract_positionalsstopped a token right after a self-closed bracket group ([-V]) from being read as that flag's argument — recoveringsg_emc_trespass'sDEVICE,scsi_ready'ssg3-utilsfamily's<device>+,lzgrep/xzgrep'sPATTERN, andrenice'spriority/pid— deliberately scoped to a tool's one primary, labelled invocation line rather than fleet-wide, because the unscoped version recovered real operandsxtask's existence oracle cannot yet attest (a same-name-repeated alternate form underjps/jstat; the unlabelled-synopsis conventiondbus-cleanup-sockets/dbus-run-session/lvreduceuse). The oracle's ownclean_usage_token(xtask/src/existence.rs) separately gained a+to its trim set, on the same footing as the.in...it already trimmed, so<device>+matches the tier's own correctly-recovereddeviceinstead of reporting it invented. Fleet existence-fabrication count on a full-PATHsweep of this machine: 130 → 124 tools, zero flags or subcommands lost anywhere. -
A docopt bracket-group flag row (fix/lvm-bracket-rows). LVM's own help emitter (
vgck,vgextend,vgrename, and the wholelv*/vg*/pv*family) writes one flag per physical line as a whole[...]group —[ -d|--debug ],[ --commandprofile String ],[ -A|--autobackup y|n ],[ --metadatasize Size[m|UNIT] ]— never a--prefixed row and never a{...}alternation, with a bare, unlabelled synopsis head (vgckalone, orvgextend VG PV ...) carrying no bracket notation of its own; every bit of notation evidence lives on the rows that continue it. Three gaps combined to leave the whole familyverbatim:- The unlabelled-synopsis entry point (previous bullet) requires notation
evidence on the invocation line itself, which a bare
vgckline never has.looks_like_unlabeled_synopsis_line's check is now supplemented, only at this one call site, by a lookahead: a bare own-name line followed immediately by a[ -x|--long ... ]-shaped row also starts the block. grammar::looks_like_flag_startdeliberately never accepts a leading[— widening it would endlsof's usage-block continuation ([-F [f]]) one line early and lose the six flags documented only there, since that same predicate doubles as the usage block's own terminator. A new, narrower, row-level predicate,grammar::looks_like_bracket_flag_row/bracket_flag_row_content— a whole physical line that is exactly one[...]group whose content starts with-— is consulted only where a flag-table row or a synopsis-continuation row is recognized (flags_block_start,scan_flags_block,extract_usage_flags), never at the usage-block-continuation call site. The recovered content is handed to the existingparse_flag_specunchanged:|is already an alias separator, and its own alias-vs-value hazard guard (take_rest_value_token'salias_followscheck, built forsg_sanitize's--count=OC|-c OC) already reads-A|--autobackup y|n's value as the whole choice listy|n, not three fabricated alternatives.sections::leading_whitespacecounted raw characters, so a single leading tab (LVM's own indentation convention for these rows) measured narrower than a heading written with two leading spaces — the "content indented more than its heading" gate failed independently of the point above. Now expands a leading tab to the next multiple of 8 columns (the ordinary terminal tab-stop convention), unchanged for the fleet's dominant space-only-indentation case.
A fourth guard closes a hazard measured during review: not every
[...]row naming two flags is LVM's shape.ethtool --helpwrites[ --all-groups | --groups [eth-phy] [eth-mac] [eth-ctrl] [rmon] ]— an alternation between two different flags, only one of which carries its own operands, not one flag with an alias list and a shared value.bracket_flag_row_contentrefuses any row whose apparent alias run does not actually end at its first whitespace gap (a bare|reappearing there means the row is this shape), rather than fabricate--all-groups's value from--groups's bracketed operands while dropping--groupsoutright.Recovers 19 flags for
vgck, 30 forvgextend, 21 forvgrename, and ~1,400 fleet-wide across thelv*/vg*/pv*family on a full-PATHsweep, purely from the shape above — no tool name is ever consulted. The tab-stop correction is general rather than LVM-specific and, measured on the same sweep, also repaired a pre-existing, unrelated defect in threesquashfs-toolsbinaries andsotruss: a deeply-tab-indented description-continuation line that happened to start with a dash after trimming (sotruss's-o, --outputcontinuation literally reads-f is also used) fell insidescan_flags_block::ENTRY_INDENT_TOLERANCEunder raw character counting and was misread as a new, fabricated flag entry; the real terminal-column count does not. LVM's own help text never describes an individual flag inline (only--longhelp/the man page does), so every one of these tools' status honestly readslow-confidencerather thanokonce their real, correctly-spelled flags are counted against that fact — a change in what the status ladder can see, not a parsing regression. - The unlabelled-synopsis entry point (previous bullet) requires notation
evidence on the invocation line itself, which a bare
-
The existence oracle learned the three synopsis-entry shapes the three fixes above taught the parser (fix/oracle-unlabeled-synopsis). A measurement fix, not a parser fix:
xtask/src/existence.rs'ssynopsis_linesscanned for only an ordinaryusage:/or:marker, so every operand the parser now correctly recovers from a line without one — the C"%s: Usage: ..."idiom, an unlabelled synopsis opening with the tool's own name, and LVM's bare own-name line whose notation sits on the next physical line — was reported as invented, exactly the gap thefix/colon-separatorbullet above named by hand (dbus-cleanup-sockets/dbus-run-session/lvreduce, "the oracle cannot yet attest"). The oracle now re-exports and reuses the parser's own three predicates (starts_with_name_prefixed_usage,looks_like_unlabeled_synopsis_line,starts_with_tool_name+looks_like_bracket_flag_row) rather than a second, drifting copy of each —mandible-extract/src/help_text/mod.rs's re-export block carries the rationale each time, the same discipline this project has already paid once for skipping (spec §13.1c's K2 table).A second, independent bug surfaced only once these lines became visible:
option_list_slot's placeholder rule looked only at a synopsis's first slot, which is where every previously-measured tool's flag-list stand-in happened to sit.gh's unlabelled<command> <subcommand> [flags]puts its stand-in last — the rule would have excluded the genuine leading operandcommandas a second, wrong placeholder had it fired unconditionally alongside the correct exclusion offlags. Fixed by trying the parser's own five-word vocabulary (sections::OPTION_LIST_PLACEHOLDERS, reused via a newis_option_list_placeholderre-export) first, regardless of position, and falling back to the positional rule only when nothing on the line already matched it — never both at once.Measured on a full-
PATHsweep: 154 → 52 existence-fabrication tools, below the round's own 66-tool starting point (14 tools flagged even beforefix/usage-synopsis/fix/colon-separator/fix/lvm-bracket-rowslanded turned out to be the identical instrument gap), with the tool set newly fabricating relative to that starting point empty — every fabrication this round's parser work appeared to add was this gap, not a real invention. Zero flag, subcommand, or status movement on the same sweep, as expected of a change confined toxtask. This detector has no labelled member in the audit (spec §13.1e: "not one reviewer reported a fabricated subcommand or flag spelling"), so its 9 hand-built self-check cases (xtask detector self-check --detector existence) are its only calibration evidence, not a fleet-wide confusion matrix. -
A layout-driven section parser for
Options:/Flags:/Commands:blocks. Group lines by leading-whitespace runs and indentation depth, so-v, --verbose Enable verbose outputtokenizes structurally as (short, long, description) regardless of the exact spacing — which varies between tools but is consistent within a tool. Detect the description column once per block and apply it to the block. -
Preserve section headings as
Flag::group.tar --helpgroups 171 flags under headings like "Main operation mode";git --helpgroups commands under "work on the current change". Discarding that grouping is the difference between a scannable pane and an undifferentiated wall. -
Never invent subcommands. This tier shipped a bug where wrapped description continuation lines and enum value lists were parsed as commands:
targained 39 phantom subcommands named "treat them as errors" and "extracting (default)",dd40,less65 [M-10]. Fabricated structure is strictly worse than missing structure — a user cannot tell it is wrong. Four binding rules:- A command block must be introduced by a recognized heading
(
Commands:,Subcommands:,Available Commands:,SUBCOMMANDS, or a git-style group heading). Layout alone is never sufficient evidence.tar --helphas no such heading, so the correct answer is zero subcommands. - A line sitting at the description column with nothing at the name column is a continuation of the previous row, never a new row.
- A candidate command name must look like one:
^[a-z][a-z0-9_.-]*$, no whitespace. "treat them as errors" fails;commitpasses. - An indented list nested under a flag is that flag's
choices, not subcommands —gnu/oldgnu/pax/posixundertar --format=are enum values, and the IR already has a field for them.
- A command block must be introduced by a recognized heading
(
-
Confidence must fall when the grammar is guessing. The same bug was reported as
okat100% described, because invented nodes inflate the metric rather than depressing it. Any block that yields names failing rule 3, or a node with no flags, no children, and a non-identifier name, must reduce confidence and mark the toolsuspiciousin the coverage scoreboard (§13.1). -
Recurse for subcommands. Revision 1 parsed only the root, which for
gityields subcommand names and zero subcommand flags. Recursion is per-node under §5.2 laziness:<tool> <sub> --helpruns when that node is expanded. -
Read stdout and stderr, and do not require exit 0. Measured:
openssl --helpwrites 0 bytes to stdout and 2,908 to stderr;ip --helpexits 255 with output only on stderr [M-8]. Both are exactly the "older Unix utility" this tier exists for. The rule for which of the two the parser reads is not "stdout if non-empty, else stderr": that rule shipped first and was wrong.openssl cmp --helpprints twoCMP info: ...diagnostic lines to stdout and its entire ~60-line help to stderr, so it handed the parser the banner and threw the document away, reachable by roughly 150 openssl subcommands in the same shape. The rule now judges each stream on its own with a help-shaped-output check (looks_like_help_output, D1.3.1) and parses whichever stream looks like help:stdout empty? stderr empty? picks yes yes stdout (empty; nothing to pick) yes no stderr, the only stream available no yes stdout, the only stream available no no, stdout help-shaped stdout, regardless of stderr no no, stdout not help-shaped, stderr help-shaped stderr no no, neither help-shaped stdout, the default when there is nothing to prefer Ties (both streams help-shaped) break toward stdout, the conventional stream for a well-behaved tool's
--help. The streams are never concatenated for the parser: merging a diagnostic preamble into the document is how banner text becomes fabricated flags.pick_streaminmandible-extract/src/help_text/mod.rscarries the full truth table and reasoning above its definition.This is the parsing path only. The verbatim/raw pane (key
t, §2) shows both streams, labelled, independent of what the parser chose to read. A reviewer checking the parser's work needs to see the diagnostic lines the parser correctly discarded, not just the document it kept. See §4.1 for the raw pane's own sanitization rule, which is deliberately different from the IR's.Measured, full PATH, 2,240 tools joined, before and after the fix: 0 flag-count losses across any tool, 169 flags gained across 11 tools (every one from zero), 13 tools moving
verbatim→ok,verbatim_count321 → 308. -
Attach
confidence: f32derived from how much of the output the grammar actually consumed, and surface it. Being honest about a best guess is better UX than presenting heuristic output with man-page confidence.
Headingless invocation tables. Rule 1 above requires a recognized
heading before a bare-word block becomes subcommands. btrfs --help has a
real command table with no heading at all — a blank line, then dozens of
btrfs balance start [options] <path> / one-indent-deeper-description rows
running to the end of the document (corpus/btrfs/audit-seed2). Layout
alone is still never sufficient evidence (rule 1's own reasoning), but a row
that repeats the tool's own name is a different kind of evidence than bare
layout: it is the tool describing its own invocation forms, in its own
voice, which is exactly what a heading would otherwise be vouching for.
The recognizer (help_text::sections::scan_headingless_invocation_table,
tried in parse_with_profile's main section loop immediately after a
headingless flags block is ruled out, before the line is read as a
candidate heading) admits a run of rows only when all of:
- Repetition — at least two name-row/deeper-description-row pairs (the
same shape test
nested_entry_table_starts_atalready uses to end a flags block early). One row is as likely to be a stray example as a table. - Every row starts with the tool's own name at a word boundary. This
is the substitute for rule 1's heading requirement, and it is also what
supplies the nesting:
btrfs device add ...reads as childdevice, grandchildadd, from the tokens after the tool's name. - Existence attestation — every emitted name is checked to occur literally, as a whole token, in the raw text (spec [M-10]'s lesson, checked explicitly rather than assumed true by construction).
- Name shape — only the leading run of
is_command_name_shapedtokens after the tool's name contributes anything; the first flag-shaped, bracketed, or placeholder-shaped token ends the run. This is what refuses pngfix's--strip=[none|crc|...]:and pod2man's--guesswork=rule[,rule...]value lists (already kept out of this path entirely — neither line starts with the tool's own name) and anExamples:-style block oftar -cf archive.tar filesrows (-cfis flag-shaped, so the run is empty).
Emission is two levels deep, matching the row's own leading run capped at
two tokens: the first token is a direct child, the second (if present) a
child of that child — grandchildren go no deeper. A row's description
belongs to the deepest name in its run, and a run of consecutive sibling
rows sharing one following description block (btrfs's device delete /
device remove pair) all take that shared description. Every recovered
node's name is deduplicated by identity, same as emit_subcommands.
Every node this recognizer produces is invocation_attested: true,
heading_attested: false — see rule 0's closing paragraph above (§6) for
why the two are kept separate and what each one governs.
Headed command tables with no column-gap or dash separator. Rule 1
requires a recognized heading; the two shapes above additionally require a
column gap or a - separator to split a row into name and description.
Two real tools' command tables have a recognized heading but neither: wpa_cli --help's commands: block separates name and description with =
instead (status [verbose] = get current WPA/EAPOL/EAP status, and a
handful of rows — a real inconsistency in the tool's own text — with no
separator at all), and apt-ftparchive --help's Commands: table has no
description per row at all, just the command name followed by its own
positional operands (sources srcpath [overridefile [pathprefix]]), with
its first row sharing the heading's own physical line
(corpus/apt-ftparchive/audit-seed2, promoted from [xfail]).
help_text::sections::scan_bare_command_table reads a row's name as only
its leading name-shaped token, never a run of them (unlike the
headingless recognizer's two-token run above): apt-ftparchive's sources srcpath names one command, sources, with srcpath — itself
name-shaped — as its own operand, never a second command or a
grandchild. A = separator (found the same way find_dash_separator
finds -) gives the description; its absence leaves the node honestly
undescribed rather than guessing where the name ends and prose begins.
split_heading_inline_row recovers the special case of a heading sharing
its own line with the table's first row, using
is_section_heading_line to tell a real (if unconventional) heading
label from an ordinary sentence that merely ends in a colon.
Three admission guards, each added after a real fabrication surfaced during development against the live fleet, not written down in advance:
- The bail-out. Refuses (
None) the whole block outright if any row has a real column gap or a-separator — both already-working shapes this recognizer must never compete with, let alone win against by running first. This is also what keeps a bare-word block that legitimately usesname = valuefor something that is not a command list (wpa_supplicant's owndrivers:block,nl80211 = Linux nl80211/cfg80211— seefind_equals_separator_gap's own doc comment) safe on the rare tool where that heading would otherwise qualify. recognized, nevercommand_mode. Gated on the current heading itself mentioning "command(s)", not on inheriting acommand_modechain the wayallow_dash_separator(issue #3) deliberately does.fail2ban-client --help's realCommand:table nests column-aligned rows whose descriptions wrap onto their own more-indented lines; the engine's "not actually a heading, rewind" path re-examines each such row as a fresh pseudo-heading oncecommand_modeis stuck on from the real heading many rows earlier, and a wrapped continuation block that ends up reachable on its own passes every other guard here purely because ordinary English is indistinguishable from a command name by shape alone —of,the,restarting,optionall became "subcommands" offail2ban-clientthis way before this guard was added. None of those pseudo-headings themselves mention "command", so requiringrecognizeddirectly closes it while still admitting both real fixtures (each literally recognized at the point this is tried).- The floor counts distinct names, not qualifying rows.
trash-put --helpcloses with an ordinary sentence, "...use one of these commands:", that satisfies the genericmentions_commands_wordtest exactly as written (the word "commands" does appear), followed by a two-line worked example of invoking a different program twice (trash -- -foo,trash ./-foo). Both lines pass every guard above, but they name the same thing, and a real command table's whole evidentiary claim is that it lists several different commands — so the floor is distinct qualifying names, not raw qualifying rows.
Every node this recognizer produces is invocation_attested: true, heading_attested: false, for the same reason as the headingless table
above — with an extra note specific to this shape: these tables belong to
C daemons and daemon-control clients whose "commands" are runtime control
verbs (wpa_cli terminate, wpa_cli quit) rather than argv subcommands in
the clap/cobra sense, and such programs commonly ignore a trailing
--help and just execute the verb, so even a correctly-attested name here
is a heading_attested-shaped risk rule 0's gate exists to withhold.
For tools not in a catalog that support <tool> completion bash|zsh|fish
(clap, cobra, click, oclif, and many hand-rolled CLIs): generate the script, then
parse it with a real shell grammar, not regex. Parsing never executes it,
which is the safety property that matters when processing untrusted output.
- Crate choice:
brush-parser. Revision 1 selectedconch-parser, which is unmaintained and today emits "contains code that will be rejected by a future version of Rust" on build [M-9]. Maintenance, not licensing, is the risk here. Avoidyash-syntax(GPLv3 — statically linking it would oblige the whole binary under GPL). - Prioritize zsh
_argumentsover bash._argumentsblocks carry'-v[enable verbose output]'— spelling and description in one structure. Bash completion functions carry only spellings, and typically compute candidates at runtime ($(git ls-files),_get_comp_words_by_ref), so static parsing recovers substantially less than revision 1 implied. Request zsh first, bash as fallback. - Walk
complete -F/compgen -Wregistrations andcase "$prev" inbranches as typed AST nodes.
Measured cost on a full-screen program with no completion subcommand
(task #14, 2026-08-13): vim.basic extracts in ~21.25s, a ~200x outlier
against the fleet, even though its 45 flags all parse correctly and Tier B's
own probe/parse together cost single-digit milliseconds. Isolated with
CompletionScriptTier::detect() called directly against the real binary:
20.03s, which is the whole story. vim.basic has no completion
subcommand, so probe_and_extract_flags's two sequential probes — argv
["completion", "zsh"], then ["completion", "bash"] (spec's own request-
zsh-first-bash-fallback order above) — each land on vim's ordinary "open
these two file names" behavior: it enters its normal full-screen editor
session (confirmed by the alternate-screen-buffer escape sequence,
\x1b[?1049h, appearing in the captured stdout) instead of erroring out on
an unrecognized subcommand. Given no controlling terminal (run_inert's
sandboxing gives every probe its own session, spec §6 rule 6), it neither
produces the completion output Tier C wants nor exits — it just sits until
each probe's own PROBE_TIMEOUT (10s) kills it. Two probes × 10s ≈ the
entire measured 21.25s; parse time is not involved at all, and there is no
superlinear behaviour in help_text::sections's parser to fix here — this
is pure probe time, correctly bounded by the existing timeout rather than
hanging forever.
This is a shape, not a vim special case: any interactive full-screen
program that (a) has no completion subcommand and (b) does not validate
an unrecognized positional before entering its main loop pays the same
2×PROBE_TIMEOUT tax from this tier's detection probe alone.
Gated on evidence, and that is the fix. The two changes considered when
the paragraph above was written — shortening PROBE_TIMEOUT, or an
alt-screen-escape early exit — were both rejected as unmeasured changes to
exec/, and both are still rejected. Neither was the right question. The
cost is not that the probe waits too long; it is that the probe should
never have been sent: completion zsh is a framework protocol's words,
and sending them to a program that does not speak the protocol is §6 rule
1a's bare invocation. The same probe was independently measured leaving
437 daemons running (§6 rule 4) and tripping the sweep's PTY canary via
docker-proxy binding 0.0.0.0:-1. One defect, three symptoms.
CompletionScriptTier therefore constructs a completion <shell> argv only
given prior evidence, from the tool itself, that the subcommand exists:
- Tier A′'s artifact scan already found the
spf13/cobramarker. cobra registers acompletioncommand itself from v1.2 on, whether or not the author mentions it, and it may be marked hidden — so for a cobra binary the compiled bytes are the only evidence there is. Free: a memoized file read, no subprocess. - or the tool's own root
--helpnamescompletion/completionsas the first token of a line — the shape of a command-table row, which is how every framework that ships the command advertises it.--helpis the argv Tier B already sends to every tool unconditionally, so this adds no shape and no new exposure, and it is capped at rule 4's own 2 sdetectcap. Anchoring on the first token is the whole grammar, and it is what keeps a flag row (--completion <shell>), prose ("generate the shell completion script") and a usage synopsis (Usage: tool completion <shell>) from counting as evidence.
The answer is memoized per binary, exactly as framework identity is; the
script itself is not, so a refresh (r) re-fetches the payload as before.
Measured on one box with coverage --tools, before → after, every other
column of the scoreboard byte-identical:
| tool | before | after |
|---|---|---|
vim.basic |
20 304 ms | 287 ms |
bashbug |
20 657 ms | 49 ms |
jconsole |
40 081 ms | 22 065 ms |
docker-proxy |
239 ms | 221 ms |
jconsole's remainder is Tier B's own --help + -h pair, not this tier;
the ~2 s it gains is the evidence probe on a tool whose --help never
returns, which is the worst case this gate has and is paid once per binary.
The honest cost. A tool with a real completion subcommand that is
hidden from --help and is not cobra loses this tier. No such tool was
found: across 19 hand-picked real tools — gh, docker, rustup,
zoxide, cargo, rg, starship, npm, pip3, curl, tar, git,
ls, sh, blkmapd and the four above — --doctor output is
byte-for-byte identical before and after, and Tier C contributed to none
of them either way (cobra's own zsh script uses _describe/compadd, not
_arguments, so even docker, whose script does contain _arguments,
recovers nothing today). That is a sample, not a fleet measurement: a
full-PATH recall comparison is the orchestrator's, and if it finds a tool
this gate costs, the finding belongs here rather than in a workaround.
Not built. Measured before building ([M-14]), re-scoped after a second measurement contradicted its headline case ([M-16]), and deliberately off by default — see "Trigger and default" below. This section records what a correct implementation would be, so the next attempt does not re-derive it.
Two sub-cases of very different quality:
mdoc(7)pages use semantic macros —.Flfor a flag,.Arfor an argument,.Nm/.Cmfor command names — so the AST genuinely distinguishes "this is a flag" from "this is prose." Real structure, not inference.man(7)pages are typeset prose with weak semantic tagging. Section boundaries (NAME,SYNOPSIS,OPTIONS,EXAMPLES) extract reliably; individual flag/description pairs need the same heuristics as Tier B.
Do not regex the rendered output of man <tool>, and do not parse mandoc -T tree — the OpenBSD manual documents that format as unstable and explicitly says
not to write parsers against it. There is no -T json.
Implementation: a pure-Rust subset parser, not libmandoc FFI. Revision 2
specified libmandoc via bindgen, and that is superseded. libmandoc is not
a shipped library on Linux [M-6], so it would mean vendoring mandoc's source and
building it with cc — and #![forbid(unsafe_code)] rules out the FFI
regardless. [M-14] measured what a subset parser would actually need: target
man(7) .TP/.IP + .B, with .It Fl for mdoc (only ~20 of the relevant
pages are mdoc, so an mdoc-first plan aims at the wrong majority). Do not gate
on an OPTIONS section — bash, ps and tmux document options under
DESCRIPTION, and that gate alone cost 28 tools; gate on the tag line
beginning with a flag, which is also what excludes examples (ps tags its
examples with .TP).
Man pages are generated too, and that decides the design. help2man,
asciidoc/docbook→man, mdoc and hand-written roff partition this space the same
way clap/cobra/argparse partition help text — the Tier A′ insight, one tier
down. So the first step is a generator survey with a go/no-go per generator,
not a parser. [M-16] is why this is not optional: git's 184 git-*.1 pages
contain zero .TP macros — asciidoc emits bold-run paragraphs instead — so a
.TP-targeting parser recovers nothing from the tool revision 2 named as this
tier's motivating case. git is therefore not the headline case; its flags
are reachable far more cheaply through -h ([M-16]) and, at the root, through
a usage-synopsis grammar. The honest remaining value is [M-14]'s measured
.TP set: ssh (52 entries against 0 today), bash (162 against 18), ps,
tcpdump, mdadm.
Multi-page discovery is still required for the tools that do benefit: a tool's
structure is spread across <tool>-<sub>.N siblings, found via MANPATH/man -k.
Trigger and default — this reconciles a contradiction this spec used to carry. Revision 2 positioned this tier as a prose backfill merged by authority (structural 60 / prose 180, §4.4), i.e. enriching parses that already succeeded. [M-14]'s own conclusion says the opposite: fire only as a zero-confidence fallback, which "keeps staleness away from the ~1,500 tools that already parse and avoids authority-merge questions entirely." Those are different tiers, and the disagreement sat unresolved in this document.
Resolved in [M-14]'s favour (maintainer decision, 2026-08-11):
- Zero-confidence fallback only. This tier fires only where the help-text
tiers produced nothing usable. It never enriches a parse that already
succeeded — so a tool like
git restore, which yields 16 flags from-h, is never touched by it. - Off by default, opt-in if built. Enrichment-by-merge is the more invasive reading and is not the default behaviour.
- Rationale, and it is a UX judgement as much as a technical one: a man page is a different document from the tool's own help, written at a different time, and silently blending the two makes a pane that no longer corresponds to anything the tool actually prints. That is the cleanliness cost, and it is the same objection [M-14] recorded as "avoids authority-merge questions entirely."
Where it does fire, per-field provenance labels the prose man, so a reader
can always see that a description came from a page rather than from the
binary.
Highest structural authority, lowest cost-efficiency. Attempted last for cost reasons — it is the only tier that spawns a process per node — but it wins structural conflicts (§4.4) because it reflects the version actually installed.
Gated on prior evidence, never speculative (2026-08-12). This tier only
constructs a __complete argv for a tool whose own compiled bytes already
identify it as cobra, via Tier A′'s identify_from_artifact. It used to ask
every tool on PATH, because asking was the only way to find out who answered.
Reported from real use: probing wall that way broadcast the literal text
__complete to every logged-in terminal on the reporter's machine, because
wall treats an unrecognized first positional as the message to send. That is
the same shape as pkill -- "" under §6 rule 2a, an argv that is inert for
nearly every tool and an action for one family, and it is the second time that
shape has caused a real-world side effect. A containment list
(exec::spawn::HELP_ONLY_PROBE) closes the measured cases. It cannot close the
general one, because it can only ever name tools somebody has already been
bitten by. The gate closes the general one.
Measured, full PATH, before and after, 2,248 tools joined: no status
transitions, no flag-count gains, no flag-count losses, and an identical
aggregate (pct_flags_with_text 94.83 both sides). Tools eligible for a
__complete probe fell from every tool swept, 2,302, to 5: docker,
dockerd, gh, git-lfs, ollama. So the speculative form was contributing
nothing to extraction while carrying the whole risk.
The cost of the gate is that a genuine cobra tool whose artifact check fails,
a stripped binary being the realistic case, loses this tier rather than being
probed anyway. Nothing on this machine's PATH was in that position. The
HELP_ONLY_PROBE entries stay in place regardless: an artifact fingerprint is
a heuristic, and defence in depth costs nothing here.
-
cobra
__complete(kubectl, docker, gh, helm, and much of modern infra): the protocol requires two probes per node. Flags require__complete <path> "-"[M-2]; revision 1 documented only the empty-word probe, and an implementation following it produced zero flags. Parse the trailing:Ndirective line; candidate lines arevalue\tdescription, with a=suffix marking value-taking flags.__complete <path> ""does not return subcommands only. Earlier revisions of this section said it did; that is wrong, and was only ever measured at nodes that have subcommands. cobra emits the node's real subcommands and then appends the command's ownValidArgsFunctionoutput, which is application code reading live state — so at a leaf the whole response is argument data.docker __complete stop ""returns the machine's running container names;docker __complete run ""returns image names [M-2].- A candidate list becomes subcommands only if every candidate in it carries
a non-empty description. cobra writes real subcommand rows as
name\tShortfrom its own formatter, while aValidArgsFunctionreturning a plain[]stringproduces bare rows — the only distinction the wire format offers. One undescribed candidate condemns the entire list, described entries included, because cobra marks no boundary between the two blocks. Measured across 631 real command paths ondockerandgh: every real subcommand admitted, every argument value refused [M-2a]. The deliberate cost is a real subcommand with an emptyShortsharing a list with argument values; Tier B still finds it, and per AGENTS.md §1 this is never relaxed for recall. - Depth cap (default 6) and a visited set keyed by the candidate list's hash: some tools echo root completions for unrecognized paths, which recurses forever.
- Alias detection:
gh cois "Alias for "pr checkout"" — recursing it duplicates an entire subtree. Detect theAlias forconvention and the case where a child's candidate set equals a sibling's, and record it inCommandNode::aliasesinstead of recursing.
-
clap
CompleteEnv(COMPLETE=<shell> <tool> -- <partial>): probed once, now removed. It was always marginal — measured opt-in and rare, withripgreperroring andcargoprinting ordinary help [M-4] — but it was removed for two concrete reasons rather than for rarity.It could not be spelled safely. With an empty partial it rendered as
<tool> -- "", and--is the option terminator essentially every getopt program discards, so the empty string arrived as the tool's first positional:pkill -- ""was measured terminating every process in a PID namespace (see §6 rule 2a). Spelled<tool> --instead it is harmless but wrong, because--is a no-op for most tools, which then print ordinary output that the shape heuristic reads as candidates — measured at 16 tools spuriously acquiring this tier, 8 of them flagged suspicious.And it never worked. Unlike cobra's
:Ndirective, clap's protocol has no self-identifying trailer, so detection was only ever a shape heuristic; on the PATH sweep it matched ten tools and none were clap (echo -- ""prints--, which starts with a dash and so "looked like" a flag). Re-adding it needs a way to confirm the protocol before trusting the response — gating on Tier A′ framework identification would supply one — and a spelling that never passes an empty first positional. -
argcomplete (Python): the
_ARGCOMPLETEenv-var convention. Same shape, lowest priority within this tier.
~/.config/mandible/overrides/<tool>.toml, merged with Authority { 255, 255 }.
This exists so the rare bad case has a clean exit; the pipeline never depends on
one existing.
Policy, binding: overrides are user-local and never vendored into this repository. This single rule is what actually enforces the §1 invariant — without it, the first hard tool gets an override committed to git and the per-tool patch pile begins.
mandible/ (workspace root)
├── mandible-core/ # IR, Text sanitization, Provenance, Authority, merge, NodeRef
├── mandible-extract/ # the tiered pipeline + runner
│ ├── known_specs/ # Tier A: carapace snapshot + index
│ ├── help_text/ # Tier B: winnow grammar
│ ├── completion_script/ # Tier C: brush-parser AST walking
│ ├── manpage/ # Tier D: pure-Rust roff subset [feature = "manpage"]
│ ├── native/ # Tier E: cobra `__complete` probes
│ ├── overrides/ # Tier F
│ └── exec/ # §6 policy: the ONLY place std::process is used
├── mandible-cache/ # on-disk cache, keying, invalidation
├── mandible-search/ # nucleo index over commands AND flags
├── mandible-tui/ # ratatui UI: tree, detail, search, overlay
├── mandible/ # the `mandible` binary
└── xtask/ # coverage harness, spec vendoring, packaging
mandible-extract/src/exec/ is the only module permitted to use
std::process. A #![deny]-style test greps the workspace for Command::new
outside that module and fails the build otherwise. Centralizing this is what makes
§6 auditable rather than aspirational.
Per-tier modules sit behind feature flags so Tier D — which only makes sense
where man pages exist, and which is off by default in any case (§7 Tier D) —
is not a hard requirement for a Windows user who wants Tiers A/B/C/E. Note the
original reason for gating it, a C toolchain for libmandoc, no longer
applies: §7 Tier D is a pure-Rust subset parser.
Default features: known-specs, help-text, completion-script, native.
Optional: manpage (C toolchain
10BC8
), withfig.
ratatui with the crossterm backend. Mouse support comes free, so
click-to-expand is a real affordance rather than a keyboard-only one.
Widgets are permitted to assume text is clean — see §4.1. All sanitization
happens at the IR boundary. This is a hard layering rule, and the reason is
empirical: the previous implementation hit border corruption while scrolling,
where description text overwrote the pane's │ border, and two attempts to fix
it inside the tree widget failed and were reverted. The cause is untrusted text
containing newlines, tabs, ANSI, or backspace-overstrike reaching a Span; a
widget-level fix can only ever patch one of the three consumers.
Belt-and-braces: the tree row builder still truncates to the pane's inner width
using display width (unicode-width), not byte or char count, because CJK
and emoji in descriptions are double-width and a char-count truncation
overflows the border by one cell per wide character.
Rendering rules.
- Tree rows are built at fixed column offsets:
[indent 2·depth][chevron 1][space 1][name][space][summary dim]. Fixed offsets make mouse hit-testing arithmetic rather than guesswork: chevron is hit whencol == 2·depth. - The flattened row list is cached and invalidated on expand/collapse, search change, or lazy fill. Rebuilding it per keypress (three times per event, as in the prior implementation) is wasted allocation that grows with tree size.
- The detail pane renders flags grouped by
Flag::group, with inherited flags in a final dimmed "Inherited" group, and hidden/deprecated flags suppressed unless toggled with.. - Scroll state is per-pane; the wheel scrolls the pane under the cursor.
Empty and degraded states are designed, not incidental: a node whose children
are still being extracted shows a subtle spinner row; a tool where only Tier B
fired shows the confidence in the footer; a tool no tier resolved shows the
per-tier status list with a suggestion to try --doctor.
No wrapping in the tree pane, ever. Row index ↔ node stays a bijection, which
keeps selection, scrolling, mouse hit-testing, and filtering arithmetic instead
of bookkeeping. Truncation costs nothing here because the detail pane shows the
full text on selection; a tree summary only has to disambiguate push from
http-push, which ~30 characters does.
╭ git ───────────────────────────────────────────╮
│▾ git the stupid content tracker │
│ add Add file contents to the ind… │
│ ▸ bisect Use binary search to find th… │
│ ▾ stash Stash the changes in a dirty… │
│ push save your local modification… │
╰────────────────────────────────────────────────╯
- Summaries align to a computed column, not
name + space. The column ismin(longest indent+name over the whole flattened row set, 40% of pane width). Compute over all rows, never the viewport — a viewport-derived column jumps as you scroll, which is worse than no alignment. It is stable until expand or collapse. - Truncate at a word boundary with
…. The ellipsis is a real signal that the detail pane has more; a mid-word cut just looks broken. - The name column never yields to the summary. A long name truncates the summary to nothing before truncating itself — you can navigate without summaries, never without names.
- Width ladder: full layout above 60 columns; names only below it (drop summaries rather than showing eight useless characters); stacked panes below 50.
The detail pane's flag list is a three-column table — spelling, value
placeholder, description. Three rather than two because a placeholder answers a
different question from a spelling (--env is what you type, list is what it
takes); run together as --env list they read as one token, while in their own
columns the list can be scanned down either one.
- The description column is one number for the whole list. Not a target the wide rows are allowed to miss. A column that most rows share and some rows don't is not alignment, it is noise that looks like alignment — and it is worse than no column at all, because the eye keeps trying to use it.
- A row too wide for the column hangs: its description starts on the next line, at the column. It never pushes the column right for itself, and the spelling is never truncated to force alignment (as in §9.1, names win). The row costs one extra line, which is the only cost nothing else has to pay.
- An outlier spelling is excluded from the measurement, not clamped to it. Clamping sets a column the outlier still misses; excluding lets it hang while every other row stays aligned. Threshold: a spelling wider than 45% of the pane does not get a vote.
- Below the width where the table can leave prose a readable amount of room (28 columns), stop pretending and stack: spelling and value on one line, description indented beneath. A table whose columns have eaten the pane breaks six words of prose across six lines; a stacked list at the same width reads normally. This is the same judgement as the tree's width ladder — degrade to a different layout deliberately rather than to a worse version of the same one.
One accent, spent only on information. Everything else is neutral.
| Element | Style |
|---|---|
| Node name | Default foreground |
| Selected row | Accent + reversed |
| Tree summary | Muted |
| Focused pane border | Accent; unfocused muted |
| Breadcrumb | Ancestors muted, leaf bold |
Section heading (DESCRIPTION, FLAGS) |
Bold muted |
| Flag spelling | Accent — the payload the user came for |
Value placeholder (<FILE>) |
Muted italic |
| Flag description | Default foreground |
| Inherited flag group | Entire group muted |
| Deprecated | Muted + a (deprecated) tag |
| Search match characters | Underline, within the name only |
| Provenance footer | Muted |
| Low confidence | Warning color — the one sanctioned exception to single-accent |
Four implementation rules that matter more than the palette:
- ANSI indexed colors, not RGB. Indexed colors resolve through the user's own terminal theme, so mandible looks native in Solarized, Gruvbox, or a light terminal with no detection logic. Hardcoded RGB looks wrong in half of them. The accent stays configurable.
- Prefer
DarkGrayoverModifier::DIMfor muted text. Several terminals ignoreDIMoutright and others render it nearly invisible — a portability trap that only manifests on someone else's machine. - Respect
NO_COLORandTERM=dumb, degrading to bold/reverse/underline only. A colour-depth ladder (truecolor → 256 → 16) is deliberately not implemented: it would require choosing specific RGB values, which the first rule above rules out. Named ANSI colours already work at every depth that has colour at all. - Highlight search matches.
nucleoreturns match indices for free; underlining matched characters is the difference between "the list changed" and "here is why this matched."
The rule: a glyph may only be used if there is something legible to fall back to. This is what separates the techniques mandible uses from the ones it refuses, and the distinction is not aesthetic — it is about how each fails.
| Technique | Fails on | Failure mode |
|---|---|---|
| Box-drawing, block elements | non-UTF-8 locale, bare Linux console | falls back to +-| |
| Colour (named ANSI) | NO_COLOR, TERM=dumb, no TERM |
falls back to bold/reverse |
| Bold, reverse, underline | almost nothing | — |
Italic, DIM |
many terminals silently ignore them | must never be the sole distinction between two kinds of text |
| Sixel / Kitty graphics | most terminals, most tmux, many SSH sessions | raw bytes on screen |
| Nerd Font icons | any machine without the patched font | □, meaning nothing |
Two properties decide it:
- Detectability.
NO_COLOR,TERMand the locale can be inspected. A terminal can be asked about its colour depth; it can never be asked what font it is using. That alone rules Nerd Fonts out permanently — Sixel is at least probe-able. - How it degrades. Losing colour loses emphasis; the text remains. Losing the font loses the meaning and leaves a box.
This matters more for mandible than for most TUIs because of where it gets
used: SSH'd into an unfamiliar machine, or inside a minimal container with
LANG unset, trying to work out a CLI you do not know. Polish that evaporates
exactly where the tool is most needed is not polish.
Implemented in mandible-tui/src/glyphs.rs: two glyph sets chosen at startup
from LC_ALL/LC_CTYPE/LANG, with MANDIBLE_ASCII=1 as an override for a
terminal that claims UTF-8 and renders it badly anyway. Enforced by a test that
renders a full frame over ASCII-only content and asserts no cell contains a
non-ASCII symbol — content from the tool itself is exempt, since reproducing a
tool's own text exactly matters more than any of this.
Markup handling is staged: Tier A prose is flattened to plain text today
(Text::sanitize_markdown). The better end state keeps parsed spans in the IR so
inline code and link labels can be styled rather than stripped — git's prose is
dense with both. Do this only once the plain-text path is stable.
nucleo — the matcher behind Helix. Faster than fuzzy-matcher/skim,
correct on Unicode graphemes, and designed to match on a background thread pool
so typing never blocks.
Index entries are NodeRefs, and flags get their own entries. Revision 1
folded flag names into the parent command's haystack, so searching --squash
selected git rebase rather than the flag. Since finding a flag is the product's
core job (§1), each Flag is its own index entry, with a haystack of
short + long + value_name + description and a NodeRef::Flag payload.
Selecting one selects the parent command and scrolls the detail pane to that flag.
Two match modes, name-only by default. Matching one combined haystack
(name + summary + description + flag value) is correct and looks arbitrary:
searching branch in git returns switch via "Switch branches", and since
only name matches are underlined, nothing on screen explains why that row is
there. / opens the box in name mode — command names and flag spellings
only — and pressing / again toggles wide mode, the combined haystack. The
search bar's title shows which is active. Name mode is the default because its
results explain themselves; wide mode finds more and is one keystroke away.
Name mode filters the index's own result set rather than maintaining a second
index, using a subsequence test so it can never reject something the fuzzy
ranking accepted for the same reason (gco → checkout still works).
Filtering preserves hierarchy. A flat result list rendered with
depth = path.len() - 1 produces indentation pointing at ancestors that aren't
on screen. Instead, matching a node force-expands its ancestor chain and the tree
renders normally with non-matching siblings hidden. This is also what makes the
spec's intent — pin the filter, then navigate the narrowed tree — actually
achievable; with a flat list, expand/collapse keys mutate state nothing reads.
Threading. Drive Nucleo::tick from the event loop's poll timeout, not from a
blocking spin inside the keystroke handler. A 50 ms synchronous deadline per
keystroke on the UI thread defeats the reason nucleo was chosen.
Ranking. Boost exact prefix matches on names above description matches, so
typing reb puts rebase above every command whose description contains
"rebase".
There is no on-disk extraction cache. Revision 2 specified one, keyed on binary identity plus a build-time source fingerprint. Revision 3 removes it.
Why it cannot be made correct. A cache key can only observe the things it hashes. Help output routinely changes while every hashed input stays identical:
dockergains subcommands when a plugin is installed — the docker binary is untouched.gitgains subcommands from anygit-*onPATH, and from aliases in~/.gitconfig.kubectlbehaves the same way with its plugins.
No fingerprint over the binary catches any of these. A cache that is usually fresh is a cache that will be confidently wrong at some point, and this project already shipped one staleness bug whose only symptom was a correct fix appearing not to work.
Why removing it is affordable. Lazy node-at-a-time extraction (§5.2) means a
launch only ever extracts the root: 179 ms for git, 221 ms for docker,
against the 10.5 s that eager whole-tree extraction cost [M-3]. That is well
inside the budget for a TUI a human then reads for seconds.
If it is ever reintroduced, the only acceptable design is revalidate-rather-than-guess: store a hash of the tool's root help output, and re-probe that single command on open (one subprocess, ~40 ms) to decide whether the cached tree is still valid. Guessing from file metadata is not acceptable.
Reordered from revision 1 by measured payoff. Tier A is 740 tools and 48k descriptions for zero subprocesses [M-1]; it is the fastest path to a product that is actually useful, and it still exercises the merge against Tier B.
| Phase | Scope | Exit criteria |
|---|---|---|
| 0 — foundation | Workspace; mandible-core (IR, Text::sanitize, Provenance, Authority, merge, NodeRef); mandible-extract/exec (§6); cache crate; packaging skeleton (LICENSE, NOTICE, README, CI) |
cargo test green; the exec-policy test passes; the "no Command::new outside exec/" test passes |
| 1 — Tier A + TUI | carapace snapshot + indexed loader; full tree/detail/status TUI; caching | mandible git, mandible docker, mandible gh render full trees with real descriptions in < 200 ms from cache and < 1 s cold |
| 2 — Tier B | winnow help-text grammar, recursive per-node, stdout+stderr, groups, confidence |
mandible curl, mandible tar, mandible openssl, mandible ip all produce useful trees; coverage harness reports its first scoreboard |
| 3 — lazy + search | Node-at-a-time runner, background warm, nucleo index over commands and flags, hierarchy-preserving filter |
mandible kubectl interactive in < 1 s; typing --squash selects the flag, not the command |
| 4 — Tier E + C | cobra two-probe protocol with depth cap/visited set/alias detection; clap CompleteEnv; zsh _arguments then bash |
A cobra tool absent from the catalog renders correctly; a completion-only tool renders correctly |
| 5 — Tier D + F | Pure-Rust roff subset parser (feature-gated, off by default), generator survey first, multi-page discovery; user overrides | A generator survey with go/no-go per generator; ssh and bash gain prose where they have none today ([M-14]). Not git — its pages carry zero .TP and its flags come from -h instead ([M-16]) |
| 6 — distribution | crates.io release, cargo-deb/cargo-generate-rpm, man page for mandible itself, shell completions |
cargo install mandible works; .deb and .rpm install cleanly |
Deliberately not on the roadmap: local NL search (§17).
This is the most important testing artifact in the project and revision 1 lacked
it. cargo xtask coverage runs extraction across every executable on PATH and
emits a scoreboard:
tool tier(s) nodes flags %flags_text ms status
docker carapace+help 162 836 100% 180 ok
curl help 1 241 96% 90 ok
openssl help 1 112 71% 140 ok (stderr)
somecli help 3 12 33% 60 low-confidence
weirdtool — 0 0 — 240 no-tier
The scoreboard is checked into the repo and diffed on every parser change,
and carries a literal accuracy: unmeasured line (see below) until an
instrument actually measures correctness rather than mere presence.
This is what makes "universal, no per-tool adjustment" measurable rather than
aspirational. Without it, every grammar tweak is evaluated against the one tool
you happened to be looking at, and there is no way to see that fixing tar
regressed xz. It is also the signal for when a tier has stopped earning its
complexity.
Regression gate: %flags_text aggregate and no-tier count may not worsen.
%flags_text is described / describable, not described / total — see
§13.1b's metric design rules for why the denominator excludes flags a source
could never have described in the first place.
%flags_text alone is not a quality signal, and trusting it hid two real
bugs, not one. The Tier B phantom-subcommand defect [M-10] reported tar as
ok at 100% described (this column's name at the time) while 39 of its 40
nodes were fabricated — invented nodes inflate the metric. The scoreboard
therefore also carries a structure-sanity column: count of nodes whose name
fails ^[a-z][a-z0-9_.-]*$, and count of nodes with no flags, no children, and
no summary. Any tool with a non-zero count is marked suspicious, and
suspicious is a gated metric exactly like no-tier.
lsof (corpus/lsof/4.95.0, [xfail]) is the second bug, and the reason
this column was renamed from %described to %flags_text. It scored 79%
"described" — every number above suggesting a good parse — while its options
table packs three flag+description pairs onto one physical line and the
generic parser reads only the first, so roughly three quarters of its
"described" flags actually carry a different flag's description.
%flags_text has only ever measured whether text is attached; it has never checked
whether that text is right, and %described was a name that let a reader
assume it did. %flags_text is the honest name for the same ratio, unchanged
in every other respect — see §13.1b. The misattribution detector
(xtask/src/misattribution.rs) is this project's first step toward an actual
correctness signal: a re-examination of text the pipeline already captures
(no new probes) that flags a flag description containing another flag's
literal spelling, attested at a column-aligned position elsewhere in the
tool's own raw help text — the exact shape of lsof's bug, generalized. It is
a heuristic with a measured, nonzero false-positive rate, reported in the
scoreboard's misattr column and misattribution_suspect_tools footer field,
and deliberately not gated: see that module's own doc comment for the
full rule, the false positives it had to be hardened against, and why a
brand-new detector must not fail a build the first time it runs. Until a
lower-false-positive accuracy instrument exists, every scoreboard also carries
a literal accuracy: unmeasured line, so a reader can never mistake
%flags_text for it again.
The "anti-fabrication oracle" this section originally called for turned out
to be two checks, not one — a distinction that cost a cycle to discover and
should not have to be rediscovered. Misattribution (above) answers "does a
description belong to the flag it's attached to?" — its victim is lsof's
column-bled options table. A second, independent question is "does everything
extracted actually occur in the tool's own output, or was it invented?" —
and its victim is [M-10], this project's worst shipped defect: tar gained 39
phantom subcommands named things like "treat them as errors" (a wrapped
continuation line mistaken for a new table entry), dd 40, less 65,
apt-get seven words lifted from its own description paragraph — every one
reported 100% "described," because a fabricated node's own fabricated flags
look exactly as described as a real node's. The existence detector
(xtask/src/existence.rs) is this check: every help-text-sourced subcommand
name and flag spelling the tier emitted must occur literally in the tool's own
raw captured text — a subcommand name additionally at a line-start-ish
position (the real, measured shape of a command-list entry; a bare substring
match alone is too weak against a fabricated name built from an ordinary
English word that also happens to appear once in unrelated prose). It compares
against pre-normalization spellings, not the IR's stored form: alias pairing
(mandible_core::merge::pair_aliases, e.g. gh's -R/--repo) means a
flag's two spellings need not sit together in the raw text, only each occur
somewhere in it; value stripping (--gpg-sign[=KID] stored as gpg-sign)
means a spelling is checked as a word-bounded prefix, not an exact token;
and a negatable boolean (--[no-]source, mandible_core::Flag::negatable)
is checked against its real bracketed raw form, never the bare --source that
never actually appears. Same properties as misattribution and for the same
reasons: it reuses misattribution::RecordingProbe (no new probes), is scoped
to Source::HelpText/Source::HelpTextSynopsis only (every other source —
Cobra __complete, a completion script, a native probe — is structural and
legitimately silent in help text), and is reported in the scoreboard's exist
column and existence_fabrication_tools footer field without being gated
— a brand-new detector with no fleet-wide baseline must not fail a build the
first time it runs (§13.1b).
The general lesson, worth stating because it will recur: a coverage metric that can be gamed by the failure mode it is meant to detect is worse than no metric, because it converts a silent bug into a confidently-reported success.
A GitHub Actions workflow reports, on every run, which frameworks mandible
supports and how well — rendered into the run's summary page via
$GITHUB_STEP_SUMMARY, which accepts markdown.
Two jobs, neither of which needs a long download or a multi-hour run:
- Framework matrix. Install roughly one representative tool per supported
framework (
ripgrep→clap,gh→cobra,httpie→argparse,black→click, acommanderpackage, a picocli jar, coreutils→argp, …) and assert that mandible (a) identifies the expected framework and (b) extracts a non-trivial tree. ~2–4 minutes with apt/pip/npm. - PATH sweep.
ubuntu-latestalready ships ~1,500 executables. Run the coverage harness over the runner's ownPATH— zero installation cost.
The summary table carries, per framework: tools detected, flags extracted,
% of flags with text (%flags_text), and pass/fail. The gate fails on
regressions in no-tier, suspicious, or framework-detection failures.
This is the natural home for the §13.1 scoreboard once it stops depending on whatever happens to be installed on a developer's laptop.
pct_flags_with_text (%flags_text in the scoreboard; named pct_described
until this rename — see below) was originally described / total. [M-15]'s
usage-synopsis flag grammar recovered 1,618 real flags — a usage line lists
spellings, never prose, by construction — and the ratio fell, 94.18% →
91.17%, because every recovered flag counted as "undescribed" against a
source that could never have described it. Recall was punished for
succeeding. That is a defect in the metric, not in the grammar, and it is the
same shape of mistake this project has now made five times:
- [M-10] — invented subcommand nodes inflated the ratio (
tarreportedokat 100% described while 39 of its 40 nodes were fabricated). Fixed by the structure-sanity column (above), not by trusting the ratio. - [M-16] —
verbatimconflated "the tool printed nothing this grammar can use" with "the tool rendered a man page," a fifty-times overestimate (verbatim_count=314againstman_shaped_count=6) if either reading were assumed from the other. Fixed by a dedicatedman_shapedcheck reading the captured text directly, never inferred fromverbatimalone. - A sweep-timing false transition —
waagent2.0was reportedok→verbatim, a downward transition that a per-tool status gate would have flagged red, on two coverage runs against identical code. Its elapsed time halved between them (41.9s → 21.4s), i.e. both runs sat near the 10s extract cap and machine load decided which side of it the tool landed on. A timing-derived status is a statement about the machine that happened to run it, not about the parser. - The [M-15] denominator defect — the ratio punished [M-15]'s recovered synopsis flags by counting them as undescribed against a source that structurally cannot supply a description.
- The name itself —
pct_described/%describedmeasured only whether a flag had text attached, never whether the text was right, and a name that says "described" reads as an accuracy claim it never earned.lsof(corpus/lsof/4.95.0) is the proof: it scored 79% "described" while roughly a quarter of its flags carried another flag's description entirely. See below.
Three rules, derived from the first four of those incidents rather than asserted in the abstract:
- A gated metric must be monotone under added true information. An
improvement that adds correct flags and loses nothing must never worsen
any number a gate reads.
pct_flags_with_text = described / totalviolated this the moment a source existed that could add flags but never descriptions. - Denominators are conditioned on what the source could have provided.
A flag whose only source cannot supply a description (spec [M-15]:
mandible_core::Source::HelpTextSynopsis) is excluded frompct_flags_with_text's denominator entirely — see [mandible_core::Provenance::describable] and [mandible_extract::ExtractionResult::describable_flag_count] — rather than counted as a description the grammar failed to find. The raw flag count is kept as its own, ungated column: a spelling-only flag is real information, just not part of a ratio to gate on. - A status derived under resource pressure (timeout-adjacent) is a statement about the machine, not the parser. Wall-clock-derived signals (a parse-time ceiling, a sweep that ran under contention) must not silently flip a correctness gate; a machine-load explanation should be distinguishable from an actual regression before either is reported as one.
Rule 3 is not scoped to the corpus gate — it applies to any wall-clock
assertion in the test suite. mandible-extract/src/help_text/sections.rs's
repeated_identical_banner_does_not_explode_into_duplicate_subcommands
(20,000 repetitions of a banner, guarding the same O(n²)-on-repetitive-input
class this module's MAX_RECOVERED_ENTRIES cap exists for) false-failed
twice under concurrent-compile load — 7.5s observed at load average 20+ on
4 cores, 4.3s alone, clean on a quiet re-run — with the parser unchanged
between runs, matching the waagent2.0 pattern exactly. Its timing
assertion was demoted to a non-blocking eprintln! warning (budget: 10s,
comfortably above every observed run) so a genuine reintroduction of the
blowup — which lands in seconds-to-minutes, not a borderline overage —
still prints loudly, while the correctness assertion (exactly 2 subcommands
recovered, not 40,000) stays a blocking assert_eq!. A second timing
assertion (mandible-extract/src/exec/spawn.rs's timeout_kills_process_group,
asserting a 200ms-timeout kill completes within 5s) was surveyed against
the same rule and left blocking: it carries a 25x margin already, is not
CPU-bound work competing for cores under contention (so is far less
exposed to the mechanism that broke the two cases above), has no observed
false failure, and is one of the few tests that directly exercises the
process-group-kill safety property in spec §6/§8 — demoting it trades a
safety check for convenience without evidence it flakes.
pct_flags_with_text is described / describable. Fleet-wide on this
aarch64 box (2,266 PATH tools), the redefinition returns it to
94.19% — within 0.01 of the 94.18% figure that predates [M-15]'s
synopsis-flag recovery — while the recovered flags remain fully counted in
the raw total (48,278, unchanged) and 204 tools move low-confidence →
ok with zero tools moving the other direction. See Appendix B.
A fifth rule, from the fifth incident: a metric's name is part of its
design, not decoration, and a name a reader could reasonably mistake for a
stronger claim than the metric makes is itself a defect — the same category
of bug as a wrong denominator, just harder to grep for. pct_described was
renamed to pct_flags_with_text (the scoreboard column: %described →
%flags_text) for exactly this reason: it changes nothing about how the
ratio is computed, only what it is honestly called. Every scoreboard also
now carries a literal accuracy: unmeasured line until an instrument
actually measures correctness — see §13.1's own note on the misattribution
detector, the first step toward one. xtask::coverage::parse_aggregate_footer
still reads a scoreboard's old pct_described= key for backward
compatibility; it never writes one. See Appendix B for the historical note on
the column-name change.
A sixth rule: a mass status promotion must carry its own spot-audit
stratum, sampled at random, not asserted from the aggregate that produced
it. Corpus-green (§13.2) plus a clean scoreboard sweep-diff (§13.1) prove
that nothing which worked before broke — every existing fixture still
passes and no tool transitioned to a worse status. They can never prove
that the tools newly promoted to ok are actually right, because neither
instrument looks at a single one of them with a human eye; both are
structurally blind to a defect the promoting change itself introduces. The
narrow lesson behind this rule is measured, not hypothetical: of the seed-2
audit's 13 forced-inclusion
entries — tools promoted low-confidence → ok by an earlier change and
never independently reviewed before being force-included into this audit —
only 1 of 13 (7.7%, §13.1c) held up as correct once a human actually
read the output. A same-sized ordinary random draw would not have missed
that badly by chance alone. Any change that promotes more than a handful
of tools to ok must therefore include a spot-audit of 5–10 randomly drawn
promoted tools, recorded in the audit manifest as its own stratum — the
same discipline §13.1d's frozen queue applies to the whole population and
§13.1e's calibration applies to a detector's fleet-wide count, now applied to
a promotion event specifically, before its count is trusted.
The mechanism now exists: xtask audit spot-audit --event <name> --promoted <tool,tool,...> --sample <n> --draw-seed <seed>. The draw is
reproducible, not hand-picked — --draw-seed mixed with --event (via
rng::stratum_seed, the same per-stratum seed mix the frozen queue's own
shuffle-stratification uses) seeds a Fisher-Yates shuffle over --promoted,
so the same event name and seed always draw the same tools. Each drawn tool
is tagged with Entry::spot_audit_event, reported by cmd_report as its
own spot-audit:<event> row, distinct from both the ordinary parse-status
strata and FORCED_INCLUSION_STRATUM — the gap this section originally
named: include_reason/forced-inclusion answers why a tool bypassed the
draw and tallies every such tool under one label regardless of reason,
which is wrong for a mechanism needing one row per promotion event. When
--promoted names fewer tools than --sample (the exact shape of the first
real case below), every promoted tool is drawn and the shortfall is stated
explicitly — never a silently smaller sample, never a padded count. A tool
already present in the manifest (the common case: a promotion event's
tools were usually already sampled by the ordinary draw, under a now-stale
pre-fix verdict) is tagged into the new stratum without its verdict, note,
or amendment history being touched — only xtask audit amend may correct
a verdict, never a draw.
First promotion event, backfilled: 942890d's synopsis short-flag-cluster
fix changed 5 of the 94 seed-2-audited tools (tcpdump, xfs_io, filefrag,
tmux, eqn — all gaining flags, none losing any), all already present in
audit/2.toml with pre-fix verdicts (wrong/incomplete, all four
non-tmux entries carrying the bundled-short-flag defect family the fix
addressed). xtask audit spot-audit --event bundled-short-flag-942890d --promoted tcpdump,xfs_io,filefrag,tmux,eqn --sample 8 --draw-seed 942890
drew all 5 (below the 5–10 target, reported as such) into
spot-audit:bundled-short-flag-942890d; the maintainer's 2026-08-13 TUI
re-inspection confirming all 5 now parse correctly is recorded as five
xtask audit amend corrections, each preserving the original verdict in
its amendment history. Headline: 35/86 (40.7%, 95% CI [30.9%, 51.3%]) ->
40/86 (46.5%, 95% CI [36.3%, 57.0%]) — denominator unchanged (the same 86
already-judged entries), only the numerator moves, since five tools already
counted as judged were legitimately re-verified correct after a real fix.
Misattribution and existence (§13.1) each re-examine text the pipeline
already captured, so both are still, structurally, comparisons against the
parser's own prior output. xtask audit and mandible --review are the
project's fourth testing instrument and the first to compare output against
independently established truth: a human reads the tool's own raw --help
text side by side with the parsed tree and judges whether the tree is right.
Subcommands (xtask audit <subcommand>): sample draws and persists a
sample; review is the interactive terminal loop; emit/ingest are its
non-interactive twin, since this project's CI has no tty (AGENTS.md §3.2):
emit writes every pending pair to a file for offline reading, ingest
reads a plain-text verdicts file back in; report renders accuracy;
fixtures turns a reviewed tool into a staged corpus/-shaped fixture, a
correct verdict becoming a real expected.snap and a wrong/incomplete
verdict becoming [xfail] with the reviewer's note as reason (--bless's
own reasoning, applied to a human read instead of an automated one).
mandible --review <SEED> (§5.3) is a fifth entry point onto the same
manifest, reviewing inside the real TUI instead of a terminal loop.
The draw is stratified, deterministic, and force-includable — via a
frozen queue, not a live re-sweep (§13.1d). xtask audit freeze sweeps
PATH once, classifies every tool by parse status (ok/low-confidence/
verbatim/no-tier/suspicious, whatever status::compute actually
produces for the population, not a fixed bucket set), and shuffle-stratifies
the result into an ordered queue (audit/queue.toml) so the sample's status
mix reflects the real population's. xtask audit sample then just advances
that queue's cursor by --sample tools — no re-probing, no
reclassification, at draw time. A tool can additionally be force-included
outside the queue draw, but only with a recorded reason
(audit/force-include.txt, <tool> <reason...> per line). An unconditional
inclusion with no stated reason is exactly the kind of unauditable claim
this instrument exists to rule out, so force-included entries are tallied
under their own forced-inclusion stratum in audit report rather than
blended into the random draw's numbers.
Verdicts are correct, incomplete, wrong, or skip. A wrong or
incomplete verdict must carry a note, enforced identically in both entry
paths (the TUI refuses to save a blank-note draft; xtask audit ingest
fails the line): for those two verdicts the note is the finding, and a bare
wrong with nothing recorded about what was wrong is useless to whatever
fix the audit is meant to feed. correct and skip do not require one,
since forcing prose out of a reviewer with nothing to add is how a review
loop starts collecting "n/a". skip is recorded, not omitted: a skipped
entry still occupies its slot and appears in audit report, just excluded
from the accuracy ratio.
Three pre-tagged known-defect classes are computed once at sample time
and shown to the reviewer before they record a verdict, so confirming is
"leave it alone" and overriding is one k1=/k2=/k3= token in the
verdict line or note:
-
K1: the GCC-family single-dash-long-option mis-parse. A flag like
-fdump-scosis stored as short flag-fwithvalue_namedump-scosinstead of as the long-form spelling it actually is. This is a real, measured parser defect, not a detector artifact, and is scheduled as grammar item 1 once the parser freeze the audit is running under lifts.K1's signature is a shape three defect families share, which is why the tag alone cannot stand in for a family label (§13.1e).
short.is_some() && long.is_none() && value_name.is_some()is produced by the GCC case (-pass-exit-codes→-p+ass-exit-codes), by a collapsed bundle (tmux's[-2CDlNuVv]→-2+CDlNuVv), and by a repeated-character flag (bpftrace's-vv→-v+ valuev, measured on all five.bttools in the seed-2 sample — the reviewer read these as "missing", since the TUI shows two-vrows and no-vv). All three sit underk1 = trueinaudit/2.toml. The families are distinguished by what the value text is: a long-option word, a run of distinct single-character flag letters, or a repeat of the flag's own letter. A detector for any one of them will fire on the other two unless it makes that distinction, which is precisely the kind of thing calibration surfaces and a fleet count alone hides.All three are now detected separately, and all three are repaired and ratchet-gated at zero.
bundled-short-flag(xtask/src/bundling.rs) was fixed at942890d;repeated-char-flag(xtask/src/repeated_char.rs) was fixed byhelp_text::sections::repair_repeated_character_flags; andsingle-dash-long(xtask/src/single_dash_long.rs) — the last and by far the largest, 132 tools and 8,784 flags — 17.6% of every flag mandible extracted, though 5.7% of tools (132 of 2,299); the flag-weighted share is inflated by a few very large option tables — was fixed byhelp_text::sections::repair_single_dash_long_options, which promotedcorpus/qemu-arm64-static/audit-seed2out of[xfail]and moved that detector to theREPAIREDverdict.Each repair uses the model
mandible_core::Flag::single_dashprovides — a long option spelled with one dash. The single-dash-long repair is a post-pass over the assembled node, not a change togrammar::parse_flag_spec, for the same reason the repeated-character one is: two of its seven conditions (the flag'sSource, and whether the reconstructed token occurs glued in the document) are unanswerable from a single flag-spec fragment. Its admission predicate is the detector's own seven conditions character for character, which is what makes the ratchet meaningful: the fix cannot claim anything the calibrated detector does not, so a fleet zero is reachable by repair and not by loosening.The families' hardest boundaries are documented in the detectors, not guessed. Each is a case where two shapes are identical and only one is a defect:
lessecho's real[-nn](a genuine-ntaking a number) has the repeated-character family's exact token shape and is a correct parse, separated only bylessechonever writing a bare-n; andcargo -Zscript,rpcgen -Dname,makewhatis -Tutf8have the single-dash-long family's exact shape and are correct parses, separated only by the uppercase flag letter the glued-value convention uses. Both boundaries are asserted as must-stay-silent self-checks rather than described in prose. -
K2: the existence detector's own tokenizer gap (
xtask::existence), not a parser defect. Closed. It was characterized on a full 2302-toolPATHsweep — 656 fabrications, hand-classified by the shape of their raw-text occurrence — and 613 of the 656 (93%) turned out to be detector artifacts of three kinds, all now fixed:n shape 359 a subcommand at an item position of a multi-column or comma-joined index ( busybox247,openssl112).line_start_wordsonly considered a line's first token;list_row_wordsnow reads a whole list row.200 the oracle read the wrong stream. RecordingProbecarried its own superseded copy ofhelp_text::pick_stream("stdout if non-empty"), so on every tool that banners to stdout and helps to stderr (mkfs.fat,tune2fs,btrfs-convert,xfs_scrub,encguess, …) it compared a correct tree against a version string. The decision is now exported frommandible-extractand imported, not restated.54 a long flag whose value spec is glued on with a word-shaped first character ( --perf-no_read_workqueue→long: "perf-no",value_name: "_read_workqueue").long_candidatesnow reconstructs it, asshort_candidatesalready did for-fdump-scos.The residual 43 are genuine: 42 are the short half of a flag alias
merge::pair_aliasesmis-merged (GCC's-f…rows paired with--param=…rows), and one is an invented short alias (dockerd's-h, whose help text documents only--help). Both are parser defects the oracle is correctly reporting, so K2 no longer explains anything and the pre-tag exists only to catch a regression.What the oracle still cannot see, and never could: it checks whether a reported spelling occurs, not whether the parse that produced it is right. A collapsed bundled short flag —
tmux's[-2CDlNuVv]read as a single flag-2taking a required valueCDlNuVv— occurs literally in the help text, so it attests cleanly while being badly wrong. Zero fabrications is not a claim of a correct parse. That belongs to the family-detector work, not to K2. -
K3: a subcommand stub whose help was never fetched, from either of two causes. Either the attestation gate permanently refused to probe a name that came from a native/cobra artifact rather than a recognized
--helpheading (never a "not yet fetched", a "cannot ever be fetched"), or the tool's subcommands simply carry no flags because their own help was never probed by the single-pass extraction the sample is drawn from.
audit report states accuracy per stratum with a Wilson 95% confidence
interval, never a bare percentage. This is the same discipline %flags_text's
own history (§13.1b) exists to enforce elsewhere. It also reports accuracy
under views with each known class excluded (K1-excluded, K2-excluded,
K3-excluded, and all three excluded together), so a reader can see how much
of the raw number is attributable to a known, already-scheduled cause versus
genuinely unexplained.
Scope, decided for the seed-2 run: the audit measures flag accuracy and
command/subcommand accuracy only. A node's own prose description and
usage-section formatting are explicitly out of scope and deferred, so
neither drives a verdict here. The boundary that keeps this consistent: a
flag's description attached to the wrong flag is in scope, because that
is flag data mis-attribution (the same shape lsof's bug was, §13.1); the
node's own prose description is not, because that is a different kind of
claim this audit is not yet reviewing.
Display-only findings are excluded from the accuracy denominator, never
from the record (task #28). A reviewer's wrong/incomplete verdict
sometimes lands on a defect that turns out to be mandible --review's TUI
mis-rendering a correct extraction — a wrapped usage synopsis, a width
that goes out of bounds — rather than the parser getting the tree wrong.
The maintainer's ruling: "those are not accuracy, those are probably UI
rendering issue. parsing was fine." skip cannot record this, because
skip means "the reviewer did not judge this tool," which is false here —
the defect was judged, and real. Instead, the display-only
[mandible_core::audit::DEFECT_FAMILIES] label (already part of the
closed family set) marks it, and [Entry::is_display_only] is what
xtask::audit::accuracy_over (and every view built on it — the K1/K2/K3
sensitivity table, every per-stratum row in audit report) excludes on:
the verdict, note, and fixture all stay exactly as recorded, and
audit report prints the excluded findings in their own dedicated
section plus an out-of-scope column per stratum, so the number can never
go quietly missing.
Like xtask::detector::Ground::BelowMemberThreshold a commit earlier this
week made structural for detector-scope exclusions, this exclusion is not
claimable by free-text assertion: display-only must be an entry's
only family (validate_families already requires it come from the
closed set, carry families_derived provenance, and sit only on a judged
defect) — a genuine parse-shape family riding alongside display-only on
the same entry blocks the exclusion rather than granting it, so two true
labels can never add up to laundering a real defect out of the
denominator.
audit/<seed>.toml is tracked. It is both the sample manifest and the
verdict record, a verdict written directly onto its own sample entry, so an
accuracy claim carries its evidence rather than
10BC8
depending on a file that
lived on one contributor's machine. audit/<seed>/fixtures/ is not
tracked: it is xtask audit fixtures' staging output, and
corpus/README.md's own workflow is to review a staged fixture by hand and
deliberately promote what's ready into corpus/, so the staging tree is
scratch by design.
The audit has not finished running. This section documents the instrument, not a result: no accuracy number is stated here, and none should be read into anything above. The result, and the final statement of scope, belong in Appendix A as [M-20], once the audit actually completes.
Why this exists. Before this design, xtask audit sample reclassified
the whole PATH population — probing every one of ~2,300 tools — on
every single draw, costing roughly twenty minutes each time. Worse,
because the strata were recomputed from whatever the parser happened to be
on the day of the draw, two draws taken weeks apart were stratifying
against two different definitions of "ok", so a grammar fix silently
redefined what an already-drawn audit sample run had even measured, and
successive draws were not directly comparable.
The fix: freeze the tool list once, walk a cursor through it.
xtask audit freeze sweeps PATH (or a pinned --tools list) exactly
once, classifies every tool, shuffle-stratifies the result with a recorded
seed, and writes the ordered queue to audit/queue.toml. xtask audit sample then only ever advances that queue's cursor by --sample tools
and merges the slice into a verdict file — no re-probing, no
reclassification, at draw time. This is deliberately not implemented by
cross-comparing already-reviewed tools against the current tool list at
draw time — no set-difference against "what's been done" computed
against a population that drifts as software is installed or removed. The
queue is ordered once, and a cursor advances through it; nothing about a
draw depends on which tools any verdict file has already recorded, which is
what makes repeated draws directly comparable and makes "same queue, same
cursor position" a deterministic, testable guarantee (xtask/src/queue.rs
tests this directly: the same (queue, cursor) pair always yields the same
tools, and successive draws never overlap).
Three additions from external review, all implemented:
- Freeze date and a population hash in the manifest.
queue.tomlrecordsfreeze_date(YYYY-MM-DD) andpopulation_hash— a stable FNV-1a fingerprint over the sorted, deduplicated tool list — so a queue can be identified and staleness detected.xtask audit freeze --checkre-hashes the currentPATHpopulation (a directory listing, no probing) and reports drift against the frozen hash without touching anything, the same "report, don't rewrite" shapecoverage --checkalready uses. - Shuffle-stratify at freeze time, not just concatenate strata. Each
stratum is independently seed-shuffled, then every item is given a
fractional rank (its position within its own stratum, normalized to
(0, 1)) and the whole population is merged by sorting on that fraction. Because every stratum's ranks are spread evenly across(0, 1), cutting the merged order at any point yields, from every stratum, very close to that same fraction of its own items — so any prefix of the frozen queue is itself a valid, proportionally stratified sample, not just the queue as a whole. (Concatenating shuffled strata instead — all of "ok", then all of "low-confidence" — would make the total order proportional but make an early prefix 100% one stratum, exactly the property this rules out.) - Freeze the captured raw help text alongside the tool list. This is
the change that actually matters: much of the twenty-minute cost was the
cost of probing (subprocess spawns and their timeouts), not of
classifying.
xtask audit freezepersists every(argv, output)pair each tool's extraction pass recorded — not just the root--helpcapture, but every probe a framework's protocol needed (cobra's two-probe shape included) — underaudit/queue-captures/.xtask audit reclassifyreplays those bytes through the real extraction pipeline viamandible_extract::exec::Transcript(the same replay seam the corpus regression runner uses) and recomputes every tool's stratum against the current parser, with noPATHsweep and zero subprocess spawns, running every tool's reclassification in parallel viarayon— measured during this batch's own build (a real, naive serial first attempt over a real 500-toolPATHslice on a 4-core evaluation machine took longer than the parallel live-probing freeze it was meant to replace, 135s versus freeze's own ~123s on the same population, which would have made an unqualified "fast" claim false; parallelizing recovered roughly half that, ~65s). The honest claim is therefore narrower than "seconds regardless of scale": what removing every subprocess spawn unconditionally buys is noPATHsweep and no probe-timeout cost: what's left is real CPU-bound work — parsing plus the native/cobra artifact tier's own binary-byte scan of each tool's on-disk executable — that scales with population size and available CPU cores, not with a probe count times a timeout. That is still a meaningful, measured win (roughly half the wall-clock of a live re-probe on this evaluation machine, with zero subprocess risk), and it is what removes the "later slices are drawn from stale strata" caveat entirely, rather than merely disclosing it: a stratum label can be kept current against a newer parser without ever re-sweepingPATH.
Storage: audit/queue.toml is tracked; audit/queue-captures/ is not.
Same convention audit/*.toml/audit/force-include.txt already set
(tracked) versus audit/*/fixtures/ (gitignored). queue.toml is small —
one line per tool, a name and a short stratum label — and is evidence for
a claim about how the queue was built, the same "a measurement's evidence
lives in git, not on one contributor's laptop" reasoning this section's own
Appendix A discipline already applies to audit/<seed>.toml. The captures
are real bulk (one small file set per frozen tool, on the order of several
thousand files for a full-PATH freeze) and, critically, machine-generated
content — exactly the category the fixture-promotion workflow
(corpus/README.md) already treats as something that must never land in a
tracked human-verdict file, a rule that has already cost this project a
cleanup once. Captures are regenerable by re-running xtask audit freeze
locally; a queue worth reusing across machines is expected to have its
captures rebuilt locally, not shipped in the repo.
--tools moved from sample to freeze. Before this design, xtask audit sample --tools <list> pinned a fixed, reproducible population for
tests and CI. Since sample no longer touches PATH at all — only
freeze does — the flag moved with the sweep it pins. sample's own
--seed changed meaning to match: it no longer seeds a draw (the draw's
only randomness is spent once, at freeze time, via its own --seed), it
only names which verdict file (audit/<seed>.toml) the slice is merged
into. Force-include is unaffected either way: it was already independent of
the population, and stays independent of the queue's cursor for the same
reason.
Honest caveats, stated rather than merely implied:
- A frozen population drifts from the machine's real installed tools over
time.
xtask audit freeze --checkdetects this cheaply, but detecting drift is not fixing it — a stale queue still reflects the tool set at freeze time until re-frozen. A frozen queue is a snapshot, not a live view of "everything on this machine right now," and any sample drawn from it should be read that way. - Reclassification updates a tool's reported stratum, never its
position in the queue. The shuffle-stratified order was computed
once, from the strata as they stood at freeze time; recomputing strata
later (
xtask audit reclassify --update) can change what stratum a tool is reported under without re-shuffling where it sits in the cursor order. A queue reclassified long after freezing may therefore no longer interleave in exact proportion to its current stratum composition, only to its composition at freeze time — a real drift, but a much smaller one than the staleness this design replaces, since the frozen order still visits the same tools in the same sequence regardless, keeping successive draws comparable. - Reclassification still depends on the tool binary resolving on
PATHat the same path. The native/cobra framework-detection tier (mandible_extract::framework::artifact) reads a binary's own bytes directly off disk to fingerprint it, not from the frozen capture — a tool uninstalled since freeze time will report a degraded stratum for a reason unrelated to any parser change. This is a file read, not a process spawn, so it never reintroduces the subprocess cost this design removes — but it is measurably not free either, and is plausibly a real share of the CPU-bound cost measured above (scanning a large on-disk binary for byte markers, once per tool, is real work). Either way, areclassifyreport is only purely "what changed in the parser" when the machine's installed tools are also unchanged since freeze.
No new execution-safety surface. xtask audit freeze issues exactly
the same probes xtask audit sample's old live sweep already issued, all
through the existing run_inert chokepoint (spec §6) — nothing about this
design broadens argv, adds a probe shape, or touches the never-probe list.
xtask audit reclassify spawns nothing at all: it is a pure replay of
already-captured bytes. The stratum a tool is classified into is still
computed by the same general, framework-keyed parser every other instrument
in this project uses (spec §1) — freezing the queue changes when
classification happens, never how.
A family detector generalizes one human finding across the fleet. The
audit (§13.1c) is slow and bounded: a human reads one tool's real output,
one tool at a time, and 94 of them took a full review session. A detector
takes the shape that human found — [-abcXYZ] collapsing into one flag
with the rest as a value — and asks whether it occurs on each of ~2,300
PATH tools, in seconds. xtask detector is the harness they register in
(xtask/src/detector.rs).
A family detector is not a correctness instrument and does not need to be. The audit remains the only instrument in this project that touches truth, because only there did a human compare output against the tool's own reality. A detector's claim is narrower: this same shape occurs here too.
That narrowness is exactly where the danger is. A detector produces a
confident fleet-wide number — "814 tools exhibit this defect" — and
nothing inside that number knows whether the detector fires on the defect it
names. This project has already shipped that mistake twice with metrics
(§13.1b): [M-10]'s fabricated tar nodes inflated %described, and
%flags_text carried a name that read as an accuracy claim it never earned.
A detector is the third instance of the same shape, and the rule that
follows is stated as a precondition rather than a recommendation:
A detector's fleet-wide number is not quotable until it has passed calibration against the human labels: it must fire on the known-bad tools and stay silent on the known-good ones. A detector that has not passed this check is measuring itself. One that has is an amplifier of a verified human judgment.
The labelled set, and why it is a weaker claim than the verdicts. A
verdict is correct/wrong/incomplete overall; it does not say which
defect family a wrong parse belongs to, and a detector for one family cannot
be calibrated against "wrong in general". mandible_core::audit::Entry
therefore carries families — a list from the closed DEFECT_FAMILIES set
— alongside families_derived, which records that those labels are a
machine reading of the reviewer's note plus the fixture evidence, not
the reviewer's own classification. The distinction is the same one
verdict_scope exists for and the same one §13.1b's fifth rule demands: a
claim must be labelled with its real strength. families_derived is an
Option<bool> and not a plain bool precisely so its absence cannot read
as "a human said so" — labels with no recorded provenance are refused, as
are labels on a correct or skip verdict, which would put a tool into a
detector's expected-fires set on a verdict saying nothing is wrong with it.
Families are shapes; tool names are data (§1). The set was derived from the seed-2 notes rather than drawn up in advance, and no family is in it without a reviewer's note behind it — a family with no labelled member calibrates nothing and only makes the set look more complete than the evidence supports.
A family name can turn out to cover more than one shape, and then it must be split rather than detected. Two names were taken up together because both looked like they were about how a bracketed or braced value spec is read; only one of them survived the reading.
brace-alternation-flag is one shape with three renderings, and the
detector and the fix are both single rules: a {...} or [...] group whose
|-separated members are bare flag spellings. cache_restore's
{-i|--input} <input xml file> reaches the grammar through an options
table, eqn's {-v | --version} through a spaced synopsis group, xfs_io's
[[-c|-C] cmd]... through a nested one; one predicate
(grammar::parse_flag_alternation) closed all three, and all three fixtures
flipped on the run that landed it.
value-name-mangled is not one shape. Its five labelled tools are at
least four unrelated defects, sharing only the symptom that value_name
came out wrong:
| tool | as written | what is actually wrong |
|---|---|---|
apt-ftparchive |
-s=? |
the =? placeholder convention |
expand |
-t, --tabs=N |
a second accepted value form documented only in the description prose |
pastebinit |
-b <pastebin> (default is 'dpaste.com') |
a trailing parenthetical default beside the value |
sg_sanitize |
--count=OC|-c OC |
an alias alternation whose members each restate the value |
update-xmlcatalog |
--root = the root XML catalog |
= used as a description delimiter, read as a value assignment |
A single detector over that list would fire on whatever the author happened
to encode and miss the rest, and its fleet number would name a population no
one could check — the precise failure §13.1e's precondition exists to
prevent, arriving through the label rather than through the detector.
The name should be split before any detector is built for it. Note that the
one entry adjacent to the alternation family, sg_sanitize's
--count=OC|-c OC, is deliberately refused by parse_flag_alternation's
member rule and asserted as a must-stay-silent self-check: nothing on its
shape says whether one value or two are meant, so claiming it would trade a
known miss for a possible fabrication.
unparsed-flag is not one shape either, and it dissolves for a second
reason worth stating separately: the name describes a symptom ("no flag
came out"), so anything that generalizes it is a recall counter rather than
a family detector — the thing brace-alternation-flag's own
MIN_ALTERNATIVES comment refuses to become. Its five labelled tools are
five dispositions, and three of them are already somebody else's:
| tool | as written | disposition |
|---|---|---|
cache_restore |
{-i|--input} <input xml file> |
brace-alternation-flag, already fixed; fixture green |
xfs_io |
[[-c|-C] cmd]..., [-adfinrRstVx] |
brace-alternation-flag + bundled-short-flag, already fixed; fixture green |
ip |
OPTIONS := { -V[ersion] | -s[tatistics] | … } |
unparsed-subcommand shape D, already declared NOT BUILT and excluded by witness in xtask::commandtable; its OBJECT and OPTIONS sets are one grammar, and the survivors are additionally single-dash-long (-V carrying the value "ersion") |
sg_dd |
a second synopsis paragraph after a blank line | singleton — the usage block ends at the blank line, losing --progress and --verify and nothing else |
pptpsetup |
pptpsetup --create <TUNNEL> …, no usage: label |
singleton — no usage: anchor, so no usage block exists and no synopsis flag is extracted |
The two singletons share no witness token, so no one predicate reaches both
and each fix would flip exactly one fixture. The pptpsetup shape is worse
than merely small: any predicate reading "the tool's own name leads a line
carrying flag-shaped tokens, with no usage: at line start" also claims
vgck, vgextend and vgrename — labelled verbatim-fallback, a
cross-family fire — and nfsidmap (nfsidmap: Usage: nfsidmap [-vh] …),
judged correct by an explicit maintainer decision that the audit note
records as not to be re-litigated. Anchoring the usage block on the tool's
own name would hand all four a synopsis they do not have today, re-opening a
signed-off verdict as a side effect of a different family's fix, with no
fleet measurement available to bound it. No detector was built, and that
is the finding — the same outcome as value-name-mangled above, reached
the same way.
unmodeled-help-shape is not one shape either, and it dissolves for a
third distinct reason: the name says only "the parser had no model for
this", so it collects whatever was left over. Its own one-line meaning is a
list of five unrelated layouts, which is the tell. It also has the smallest
real membership of the three: two of its six labels are the same binary
— /usr/bin/mariadb-repair and /usr/bin/mariadbcheck are both symlinks to
mariadb-check, and the two help texts differ only in the program name each
prints. Six labels are five tools, and the five are five shapes:
| tool | as written | disposition |
|---|---|---|
gcc |
--help={common|optimizers|params|target|warnings|…} |
already modeled and already deferred: help_text::confession::match_flag_value_row detects this exact row and caps status at incomplete. Following it needs a new exec::InertArgv for the one-token --help=common and its own §6 deliberation (WS5b, §6 rule 2b). Not a grammar defect at all |
mariadb-repair, mariadbcheck |
Variables (--variable-name=value) defaults table |
nothing to extract: all 39 rows restate an option already in the tree and add only a default value, which the IR has nowhere to put. One binary, two names |
qemu-arm64-static |
Argument │ Env-variable │ Description |
a three-column table: column 2 is swallowed into the description on 21 of 23 rows. Its remaining damage is single-dash-long, which has its own detector and fix |
sg_dd |
a synopsis paragraph resumed after a blank line, plus a where: KEY=VALUE operand table |
singleton, and already recorded under unparsed-flag above — re-verified here: --progress and --verify are the only losses |
ssh-keygen |
one synopsis line per invocation variant, each reprinting the tool's own name | the block is read — 40 flags come out of it. What is unmodeled is the mode words (-Y sign, -M generate, -Y find-principals), subcommands in all but name |
The mariadb residue is the only one two labels reach, and one binary under
two names is the same evidence counted twice — a tool, not a family. It is
also blocked in one direction, and was blocked in both until the corpus
gained a way to say it. Its single tree artifact is a phantom flag, whose
long name is the table's header ruler ---------------------------------.
That half is now closed: [contract]'s must_not_contain_flags (§13.2)
states the negative claim a fixture could not previously make — the same
gap must_contain_positionals closed for operands, on the invention side
rather than the omission side — and corpus/mariadb-check/2.7.4 is its
first user, [xfail] on exactly that ruler and no longer able to be
repaired silently. Note the existence oracle cannot substitute: its contract
is "does this spelling occur in the raw text", the ruler does occur
literally, and it is therefore correctly silent. The detector half is
still blocked. The obvious predicate — a long option name made only of - characters — fires
on bzless, whose ------> --help <------ decorator parses as -- plus
---- carrying the value >; bzless is labelled wrong-stream, so that
is a cross-family fire, and no exclusion may excuse a fire. Narrowing it to
"the raw text carries a line of nothing but dashes" does clear bzless,
and that narrowed predicate was scanned mechanically across all corpus
fixtures: zero of them carry such a line — re-run over 82 fixtures
after corpus/mariadb-check/2.7.4 landed, still zero, because the ruler
that motivated the predicate is two dash runs separated by a space and
so does not match the strict reading. Relaxing it to allow interior spaces
finds exactly one witness in the whole corpus, mariadb-check itself, which
is a witness count of one and not a calibration set. No detector was
built, and that is the finding — the third family to dissolve, and the
first to do so partly because a label count double-counted one binary. What
did change is that the defect is now pinned rather than merely described:
the fixture fails on it today and will announce its own repair the moment
the ruler stops parsing as a flag.
A fourth family, block-extent, was built — and it is the first one
found by an instrument rather than by a reviewer. Three defects were
taken up together on the hypothesis that they were one rule about how far
a usage or choice block extends: corpus/tar/1.35 (tracker #41, surfaced
by residue ranking, §13.1f, not by a human), sg_dd, and pptpsetup. They
are two shapes, not one, and not three:
| tool | where the block ends today | claimed by one rule? |
|---|---|---|
tar |
a nested FORMAT is one of the following: enum at indent 4 never dedents before the options table resumes at indent 6 |
yes — bare_block_end |
sg_dd |
a where: operand table at indent 4 whose own last six rows are flag rows at that same indent 4 |
yes — bare_block_end |
pptpsetup |
no block is ever opened: starts_with_usage_prefix finds no usage: anchor |
no — a different code path, and the singleton already dissolved above |
The rule is one line in help_text::sections::bare_block_end: a bare
block ends where a flag row resumes, looks_like_flag_start being the
witness token. It is not a new heuristic but the removal of an
inconsistency — the section engine's own first test already reads a
flag-shaped line as a headingless flags block, and the usage-block scan
already ends on the same signal (curl's 13 flag rows running into its
synopsis) — bare_block_end was the one place where a flag row was not
structure. The break is also non-destructive: the caller resumes the
main loop at that exact line, so a wrong break re-routes a tail from
choices to flags and never drops it.
Scanned mechanically across all 81 corpus fixtures before it was believed
(the check that killed unparsed-flag): exactly two trees change, and
both are the two targets — tar gains --old-archive, --pax-option
and --posix, and sg_dd goes from four undescribed synopsis flags to six
fully described ones, recovering --progress and --verify. sg_dd was
promoted out of [xfail] by the run that landed it, and tar's contract
now names its three by spelling: that fixture was green, blessed and
contract-gated for its entire life while missing three real flags, which
is the sharper lesson — a flag count floor could not see it, because the
flags were not absent from the tree, they were in the wrong field.
Two things this family deliberately does not claim. --portability,
the fourth flag on tracker #41, is a second long alias on
--old-archive's row and Flag has one long slot: that is
dropped-alias, not this. And sg_dd's synopsis is still truncated at
its blank line — the flags come back via the operand table, not via the
second paragraph — so the blank-line resumption stays exactly the
unfixed singleton the unparsed-flag reading above called it.
Unclassified is a recorded state, not a gap to fill. A judged defect
whose note nobody could confidently sort — a hedged by-reference note with
no fixture to check it against — carries no label, and both xtask detector list and every calibration report print that count. A visible hole bounds
how complete any family's calibration can be; a hole papered over with a
guess silently corrupts a cell of the matrix.
The confusion matrix has five cells, not four. Beyond fires-on-bad, misses, silence-on-good and false alarms, there is fires on a tool judged defective of a different family. That is neither a hit nor a false alarm: the human already said this parse is wrong, so a fire there may be a mislabel or a genuine second family. Counting it as a false alarm understates the detector; counting it as a true positive overstates it. Every cell names its tools, because a disagreement is only useful if a human can go look at it.
Not-evaluable is counted, never dropped. Calibration replays each
audited tool's corpus/<tool>/audit-seed2/ fixture — the same frozen-bytes,
zero-subprocess replay §13.2 uses, so calibration spawns nothing. A labelled
tool with no fixture is listed by name rather than omitted: a "perfect"
matrix computed over half the labelled set is a worse claim than an
imperfect one computed over all of it.
A detector may legitimately be uncalibratable, and says so.
Detector::family returns an Option, and None means "generalizes no
family this labelled set contains". Both existing fleet oracles return it:
across all 94 verdicts, not one reviewer reported a fabricated subcommand
or flag spelling, so the existence oracle — the instrument built for
[M-10], this project's worst shipped defect — can be neither confirmed nor
refuted here. That is a property of the sample, not of the oracle, and
reporting it is the honest result. Forcing such a detector onto the nearest
family would manufacture a matrix out of a correspondence nobody verified,
which is the original defect one level up.
Every report states its own limits, in full, every time. Not a footnote
and not abbreviated on repeat runs: calibration is against derived labels
over the audit's judged tools — a bounded sample of roughly 4% of PATH,
not the fleet, and not ground truth about the fleet. Passing means a
detector works on those tools; it says nothing about whether its fleet-wide
count is right. This is the same discipline the coverage scoreboard's
literal accuracy: unmeasured line enforces (§13.1b), for the same reason:
a number travels without its context unless the context is printed beside
it.
wrong versus incomplete must never become load-bearing. The boundary
between the two words is thin, and the maintainer has flagged that it may not
have been drawn consistently across the 94 seed-2 verdicts — plausible for
any single reviewer working alone across a full session. Nothing in this
project currently depends on which of the two a tool got, and nothing
should: no consumer here read the boundary carefully enough for it to bear
that weight. Checked directly rather than assumed: accuracy_over
(xtask::audit) counts correct against everything else, collapsing
wrong and incomplete into one "judged defect" bucket; verdict_requires_note
obligates a note under the identical rule for both; cmd_fixtures emits the
identical [xfail] shape from one shared "incomplete" | "wrong" match arm;
and a family label (this section) is derived from the reviewer's note text
plus the fixture evidence, never from which of the two words the reviewer
chose — unmodeled-help-shape labels both a wrong entry (ssh-keygen) and
an incomplete one (mariadb-repair) with no distinction drawn anywhere
downstream. If a later change ever needs to prioritise which judged defect
to fix first, the ranking is by family (does this shape recur across the
fleet) and by detector count (how many tools a calibrated detector
actually names) — never by which of the two verdict words a reviewer
happened to type. Prioritising by the word itself would retroactively make
an inconsistently applied distinction load-bearing, which is exactly the
kind of claim this project's verdicts were never built to support.
A fixed family inverts its own calibration, and the precondition must be
read accordingly. The bundled-short-flag detector is the first one whose
family was actually repaired, and the moment the grammar landed its
calibration went from 4 hits to 0% recall, naming tcpdump, tmux,
filefrag, xfs_io, ssh-keygen and eqn as misses. Nothing about the
detector changed; those six fixtures simply parse correctly now, so the
labelled set has nothing left to confirm against. The precondition above is
a claim about labels recorded against a particular parser, and it expires
for a family on the commit that fixes it. Two things carry the weight
afterwards, and both are cheaper than re-auditing: the detector's own
hand-built tests, which construct the defective shape directly and assert
the rule still fires on it, and sweep-diff, which is the instrument that
actually answers "did fixing this break anything else". A detector reading
zero because the bug is gone and a detector reading zero because it stopped
working are indistinguishable from the fleet number alone — so the
distinguishing evidence has to live in tests, and the fix's own commit
should say which.
A repaired family is reported as repaired, and the report carries its own
evidence. Calibration has three verdicts, not two. REPAIRED is reached
only when calibration has inverted (nothing labelled fires any more, and
there was something to fire on) and the detector's own hand-built cases
still hold. Those cases are Detector::self_checks — promoted out of
#[cfg(test)] and onto the trait precisely because neither consumer runs
under a test harness — and each names the exact number of findings the
detector must report on a hand-built input. The list is only evidence if it
covers both directions: at least one case the rule must fire on, because
a deleted detector satisfies every must-stay-silent case, and at least one it
must stay silent on, because a detector firing indiscriminately satisfies
every must-fire case. An empty list is refused rather than passing
vacuously. A detector with no self-checks can never be called repaired.
REPAIRED is a stated claim, never a suppression, and the distinction is the whole point: "the family was repaired" is otherwise the perfect excuse for a broken detector. So nothing moves between cells to reach it. Recall still reads 0%, every missed tool stays counted in the FALSE-NEGATIVE cell and stays named, the declared out-of-scope miss still prints in red, and the self-check block prints on every run — including the ones that do not reach REPAIRED — so the first time a reader sees the evidence is never the run where it is being used to excuse a zero. A false alarm blocks REPAIRED exactly as it blocks PASSES. And an inverted matrix whose self-checks did not hold gets its own loud verdict naming it as the dangerous case, rather than being rendered as an ordinary failure.
A ratchet gate asserts the detector alongside the count. Once a family
is repaired its fleet count is gated at zero (coverage --check, via
detector::ratchet_at_zero) — against a literal 0 rather than against the
checked-in scoreboard, which is editable and would otherwise let a
reintroducing commit raise its own baseline. The gate's second half is not
optional: a gate asserting count == 0 is satisfied by deleting the
detector, which is [M-10]'s "a metric improved by breaking the thing that
measures it" one level up. So the gate requires the same self-check evidence
the REPAIRED verdict does, and refuses a zero without it. Verified by
attacking it: with bundling::detect returning an empty report and the fleet
count at a perfect 0 tools / 0 destroyed flags, coverage --check exits 1
and names the six cases that stopped firing.
A declared scope exclusion must carry a structural predicate, not prose.
Scope::known_exclusions was (tool, &'static str), and adding an entry
silently converted a blocking false negative into a non-blocking named miss
with nothing checking that the sentence named a property of the shape.
That was the last goalpost-moving lever, so the reason is now a closed
Ground enum carrying a witness — the literal token from the tool's own
help text — plus the constant it falls below, referenced rather than
retyped. The arithmetic is computed from the witness and has to agree:
ssh-keygen's -hU swallows one member, below MIN_BUNDLED_MEMBERS = 2,
so it holds; an author trying to exclude tcpdump would have to supply
-AbdDefhHIJKlLnNOpqStuUvxX#, whose 25 members are below no threshold, and
the entry is refused as a false negative rather than an exclusion. A
threshold of zero and a witness that is not a cluster token are refused too.
Prose survives as a note printed beside the generated structural
sentence, never instead of it. A new kind of exclusion means a new Ground
variant with its own predicate — a reviewable change to the vocabulary,
which typing a new sentence into a &str was not. Two more variants exist
now, both added for single-dash-long's declared misses and both computed
from their witness the same way: OptionalBracketedTail (ip's real
-h[uman-readable] writes its tail in brackets, so the grammar records
ValueKind::Optional and a Required-only fingerprint cannot admit it) and
TailIsNotAnOptionName (sg_emc_trespass's real -hr: carries the
layout's own colon, which is not an option-name character). A witness whose
tail is clean, or which writes no bracket, is refused — the exclusion cannot
be claimed for a tool that is squarely inside the scope.
All three families sharing the K1 fingerprint now have a detector, and each is written to reject the other two. The discriminator is what the swallowed text is — a switch set, a word, or a run of the flag's own letter — never the tool, and each detector asserts the disjointness with its own must-stay-silent self-check against the other two families' real tokens. Their measured cross-family cell reads 0 in every direction, which is exactly the confusion this cell exists to surface: a fleet count alone cannot tell "my family occurs 800 times" from "I am counting somebody else's."
Calibration can find a mislabel, and finding one is the mechanism
working. repeated-char-flag's first calibration run reported one false
alarm: ntfsfallocate, judged correct, whose help text writes -v Verbose execution and -vv Very verbose execution as two rows and whose
tree loses the second — character for character the defect the same review
judged incomplete/wrong on five .bt tools. The alternative to amending
it was loosening the detector until it stopped seeing a defect it can see,
and there was no room to loosen anyway: the two documents are structurally
identical. It was amended through xtask audit amend (previous verdict, new
verdict and reason recorded in the entry), on the same mechanism and the
same justification as the tmux amendment §13.1c already carries. A false
alarm is never waived — it is either a detector bug or a label bug, and
which one has to be argued in the commit that resolves it.
cargo run -p xtask -- residue (xtask/src/residue.rs) is the complement
of the existence detector. Existence asks "is everything in the tree
attested by the text?" and catches invention; residue asks "what in
the text did the tree never account for?" and catches omission, which
is what every non-K1 family in the seed-2 backlog actually is.
It classifies each physical line of a captured --help document by shape
alone — a dash-led flag row, or an indented lowercase
name<gutter>description name row — and reports the rows no spelling
or name in the parsed tree accounts for. Everything else in the document
(prose, headings, usage lines, uppercase environment/exit-status tables,
the tool's own examples, wrapped description continuations) is never
counted, which is the entire substance of the design: a tool whose help is
one prose paragraph leaves a great deal of unconsumed text and zero
unconsumed rows, while a forty-row flag table that lost thirty rows
produces thirty pieces of line-numbered evidence. It replays
corpus/-shaped fixture directories from frozen bytes and spawns nothing;
there is no PATH sweep here and there must not be one.
It is not, and must never become, a gate or a quotable number. The
reason is a specific asymmetry: a wrong residue candidate costs review time
and cannot produce a wrong parse, because nothing downstream reads it.
The moment a residue count is treated as a measurement, that asymmetry is
gone and what remains is an unvalidated number — with a large,
shape-dependent, unmeasured false-positive rate — competing with the only
instrument in this project that touches ground truth, the 94 human verdicts
of §13.1c. That is §13.1b's fifth metric-design incident recognised in
advance instead of in hindsight. So: nothing in coverage --check consults
it, it appears in no ratchet and no corpus contract, there is deliberately
no --check flag to add one to, and
xtask::residue::tests::residue_is_reachable_from_no_gate fails the build
if coverage.rs, corpus.rs or status.rs ever calls into it. Its output
is a reading queue for a human, who turns a confirmed finding into a
deterministic, calibrated, ratchet-gated rule the ordinary way.
What it measured, on the evidence available when it was built. Against
the 84 seed-2 tools that carry a verdict and a capture (staged with xtask audit fixtures --seed 2 --corpus-dir tmp/residue-fixtures, then ranked with
xtask residue --dir tmp/residue-fixtures): 12 tools leave any residue at
all and every one of the 12 is already labelled wrong or incomplete;
none of the 32 tools judged correct leaves a single unaccounted row. In
each case the evidence lines up with what the reviewer independently wrote —
ar/gcc-ar's undetected commands: table, rubyobjnew-bpfcc's missing
positional arguments: block, ptargrep's --long|-s pipe-separated
aliases, sg_dd's operand table. Recall is the honest weakness: only 8 of
43 defective tools reach the ranking threshold, because the omission classes
this shape-based classifier can see are a subset of the ones that exist (a
description-less word grid — openssl's Standard commands: — produces no
rows at all, so a tool that drops one is invisible here). This is a
low-recall, high-precision signal, which is the right trade for a review
queue and the wrong one for a metric — a further reason it is not one.
It also found something nobody had looked at, which is what it is for:
corpus/tar/1.35 — a committed, green, snapshot-blessed fixture — is
missing four real GNU tar flags (--old-archive, --portability,
--pax-option, --posix, lines 202-206 of its own capture). They sit
indented inside the nested FORMAT is one of the following: choice block
that belongs to --format, and the block parser consumes the whole region
as choices. Recorded here as a finding, not fixed here: a fix belongs behind
a calibrated rule and a falsifying [contract], not behind a ranking.
A fixture (corpus/<tool>/<version>/) freezes both halves of one
extraction pass: the raw bytes a real probe produced, byte-exact
(.gitattributes marks everything under corpus/ -text specifically so
Git's own CRLF/whitespace normalization can never be the thing that quietly
"fixes" a capture), and the CommandNode tree mandible's actual tiered
pipeline produces from those bytes today — replayed with zero
subprocesses, through the same Transcript seam §13.1c's and §13.1d's
own replay uses. Snapshotting only the IR is not enough: an IR-only
snapshot can only assert "the tree once looked like this," and a
tool-version bump or a grammar rewrite leaves nothing to re-derive from.
Keeping the raw capture beside it turns every fixture into a live
regression check against whatever the parser does today, forever — not a
frozen fact about a parser that no longer exists. There is no more
per-tier bucketing here: §7's Tier A (a vendored catalog) is removed, and
a fixture is no longer filed by which tier resolved it, only by tool and
version — corpus/README.md has the full layout and the meta.toml
contract (a descriptive half, expected.snap, that --bless rewrites
wholesale, and a normative half, [contract], that only an explicit,
reviewed edit may weaken) and the lsof cautionary tale
(corpus/lsof/4.95.0, [xfail] again after being blessed once without
the raw-text-side-by-side review --bless does not itself perform).
[contract] can state a negative, not only a positive:
must_not_contain_flags. Every other field names something the real tool
really has and fails when the parser drops it, which covers the omission
half of what can go wrong and none of the invention half — a parser that
reads a table ruler or a decorator as an option produces a flag no fixture
could point at. This field is matched by exactly the matcher
must_contain_flags uses, negated (--foo a long name, -x a short one,
a bare word a long name verbatim), root flags only, and it asserts nothing
about the raw capture, nothing about the spelling the entry did not name,
and nothing below the root — a fixture author forbids only what they
looked at. A tree with no root satisfies it vacuously and is not reported:
a missing tree trivially breaks a positive claim and trivially keeps a
negative one, and reporting otherwise would be a false positive in the one
gate whose authority rests on having none. Dropping an entry is a
weakening, reported by --baseline-dir exactly as a dropped
must_contain_flags entry is. corpus/mariadb-check/2.7.4 is its first
user and the reason it exists (§13.1e's mariadb residue).
verdict_scope records which dimensions of the tree a human actually
looked at before blessing it — some subset of "flags",
"subcommands", "descriptions", "usage". Absent means no scope was
claimed, never every scope: a bless freezes every field in the tree
whether or not a human read it, so treating silence as "everything
verified" would let exactly the overclaim lsof cost this project
survive by omission — the conservative reading is deliberate, since it is
always safe to add a truthful claim later and never safe to have quietly
claimed one that was not made. Fixtures promoted from the seed-2 human
audit (§13.1c) carry verdict_scope = ["flags", "subcommands"], matching
that audit's own declared scope: the reviewer judged structure, never
prose.
Strict xfail, in the direction that matters here: an [xfail] fixture
whose snapshot and every [contract] field now pass fails the run.
cargo xtask corpus does not read that as success — a fixture marked
broken that quietly stops being broken means the bug appears fixed and the
fixture is stale, and the run demands it be promoted ([xfail] removed,
the now-passing expected.snap kept) rather than staying silently green
under a label that no longer applies. This is how a fix announces itself.
The bundled-short-flag grammar fix (§13.1e) is the worked case: three of
the six fixtures its own family originally judged wrong in the seed-2
audit — tcpdump, tmux, filefrag — flipped from [xfail] to passing
the run that landed the fix, exactly because leaving them labelled broken
would itself have failed; xfs_io, ssh-keygen, and eqn stayed
[xfail], their own must_contain_flags gaps unrelated to the collapse
this particular fix closed. Two of those three have since been promoted by
the next family's fix (brace-alternation-flag, §13.1e), which is the
mechanism working exactly as intended: xfs_io's gap was
[[-c|-C] cmd]... and eqn's was {-v | --version}, both of them the
alternation family rather than the bundle one, and both fixture comments
said so in words while they were still red. Both directions are checked on every run, not
this particular fix closed. The repeated-character-flag repair (§13.1e) is
the second case and a cleaner one: all five of that family's [xfail]
fixtures — killsnoop.bt, naptime.bt, opensnoop.bt, tcpaccept.bt,
threadsnoop.bt — flipped in the run that landed the fix and were promoted,
and their contracts were strengthened on promotion rather than merely
un-marked: each now also asserts the bare -v/-d/-k booleans the repair
reads as its own evidence, because a repair that consumed them would satisfy
the old must_contain_flags = ["vv", "dd"] and destroy the tool. Both
directions are checked on every run, not
only the "did it get fixed" one: a fixture claiming to be broken while
every check quietly passes is exactly as much a bug as an unmarked
regression.
Current scale: 82 fixtures — 68 passing, 14 [xfail], 0 unexpectedly
failing, as measured by cargo run -p xtask -- corpus (the counts stated
here had drifted from the runner's own summary line; these are that line).
Eleven are hand-captured against a real installed version (git, tar,
curl — two versions, du, gcc, ffmpeg, lsof, unzip, zoxide,
mariadb-check); the other 71 are audit-seed2 fixtures, xtask audit fixtures
turning a seed-2 human verdict directly into a fixture (correct → a real
expected.snap, wrong/incomplete → [xfail] with the reviewer's note
as reason, §13.1c). The corpus is now substantially the audit's own
output, staged and promoted, rather than a hand-curated tier list — see
corpus/README.md for the fixture layout, the full meta.toml contract,
and the contribution workflow (a fixture-only PR needs no Rust).
- Real-argv tests. Every tier needs at least one test that exercises the
actual argv construction, not just the parser behind it. A prior cobra
implementation omitted the literal
__completefrom its extract-path argv and the tier was silently dead in the real pipeline; its unit tests passed because they injected a mock probe that bypassed argv construction entirely. - Execution-policy tests (§6): a shim binary logs argv/env; any invocation outside the allowlist fails the suite.
- Sanitization tests: ANSI, C0, backspace-overstrike, tabs, embedded newlines, CJK/emoji width, and a 10 MB pathological string.
- Render tests against
ratatui::backend::TestBackend, asserting that border cells are intact for adversarial description text at several widths and scroll offsets. This is the regression test for the bug that was reverted twice. - Fuzzing the Tier B grammar (
cargo-fuzz) — it consumes untrusted text. - Merge property tests: merge is associative over authority; a
Nonenever displaces aSome; alias pairing is idempotent.
The workspace runs under cargo nextest run --workspace in CI, never
cargo test --workspace piped into a text-processing tool. The rule behind
this is not that nextest is faster: it is that human-format test output must
never be parsed, by anyone, for any reason. The concrete failure, self-reported
(AGENTS.md §3.3): grep -c FAILED against cargo test's output false-positived on
test data that happened to contain the literal word "FAIL" (fixture text,
a variant name, a snapshot value all share one output stream with no
structural separation from the test runner's own report), producing a
confident, wrong pass or fail count. cargo nextest run reports a real
nonzero exit code on any failure and can emit --message-format libtest-json when a structured result is actually needed. Read that, or the
exit code, never the prose. Nextest cannot run doctests (an upstream
limitation), so CI runs a separate cargo test --doc --workspace step to
cover them.
§13.1–§13.2 introduce five instruments at five different points, each for its own immediate reason, and nowhere states how they compose. They do, in this order:
- Corpus fixtures (§13.2) — per-document. Frozen bytes plus the tree
they should produce;
cargo xtask corpuscatches a regression on one tool someone already looked at, replayed through the real pipeline with zero subprocesses. - Sweep-diff (
xtask sweep-diff) — fleet-wide, not per-document: a semantic diff between two full-PATHscoreboards, gains and losses always reported as two separate totals, never netted, because summing them hides exactly the losses that motivated building it — two grammar fixes shipped regressions (228 flags across 72 tools; 6 onlsofplus 34 across four more) that both the aggregate%flags_textgate and the whole corpus stayed green through, caught only by a human diffing a before/after sweep by hand. It is the instrument that answers did fixing this break anything else, and non-blocking by construction (maintainer decision D4):cargo xtask sweep-diffalways exits0, and there is no--check/--gateflag to wire to a nonzero exit by accident. - Oracles — existence and misattribution (§13.1) — fleet-wide
self-consistency checks: does every extracted name occur in the tool's
own captured text (existence, built for [M-10]'s fabricated
tarsubcommands), and is a description attached to the flag it actually describes (misattribution, built forlsof's column-bled options table). Neither compares against the tool's real behavior; both re-examine text the pipeline already captured. - Audit (§13.1c) — sampled, and the only instrument in this list that
touches truth. A human reads a tool's own raw
--helptext beside the parsed tree and judges it. Everything above this line is self-consistency — internally coherent output can still be uniformly wrong — which is why the audit exists at all, on 94 tools so far (the seed-2 sample). - Family detectors + calibration (§13.1e) — generalizes one human
finding across the fleet. A detector encodes the shape a human found
wrong and checks every
PATHtool for it in seconds; its fleet-wide count is not quotable until calibrated against the audit's own labelled verdicts — it must fire on the tools the audit called defective for that shape and stay silent on the ones it called correct.
The loop these five compose into: a human audit finding gets a family
label (one of DEFECT_FAMILIES, derived from the reviewer's note plus
fixture evidence) → a detector generalizes that label's shape across the
fleet → the detector is calibrated against the labelled verdicts (fires
on known-bad, silent on known-good) → only once calibrated does its
fleet-wide count become quotable → the count motivates a grammar fix
in mandible-extract → the fix makes the family's xfail fixtures flip to
passing, which cargo xtask corpus's strict xfail (§13.2) reads as a demand
to promote them rather than a quiet pass → sweep-diff runs a
before/after full-PATH sweep to prove the fix broke nothing else → the
detector's fleet count is ratchet-gated at zero going forward, so any
future regression in that family is visible the moment the count leaves
zero.
The worked example is the bundled-short-flag family, run start to finish
this week. The seed-2 audit judged five tools wrong/incomplete for a
synopsis cluster like [-AbdDefhHIJKlLnNOpqStuUvxX#] collapsing into one
flag (-A) with every other letter glued on as its value — tcpdump losing
25 real flags this way, xfs_io 10, tmux 7, filefrag 7, ssh-keygen 1
— plus a sixth, eqn, labelled bundled-short-flag among several
overlapping families in its own audit note. The detector
(xtask/src/bundling.rs) generalized that shape and, on the same full
PATH sweep the audit's queue was frozen from (2,302 tools), reported 58
tools with a collapse, destroying 465 real flags — a number that became
quotable only once every one of the 58 was checked by hand against its own
captured text and no false positive turned up. help_text::grammar:: parse_bundled_shorts then read the same synopsis cluster as the set of
switches it actually is, and the i
10BC8
dentical sweep that had measured 58/465
came back at 0 tools, 0 destroyed flags; sweep-diff across the
before/after scoreboards showed 0 flag-count losses against 489 flags
gained across 67 tools (nine more than the 58 — the text-versus-tree gap
§13.1e's own doc comment explains: a cluster whose first member also
appeared in an ordinary options table never survived into the tree for the
detector to see, but the fix, reading the raw synopsis directly, recovers it
anyway). Three of the six originally-labelled fixtures — tcpdump, tmux,
filefrag — flipped from [xfail] to passing in the run that landed the
fix and were promoted (§13.2); xfs_io, ssh-keygen, and eqn remain
[xfail] for gaps this particular fix did not close.
And the fix inverted its own calibration — §13.1e states this as the
general rule; this is where it was first observed. Before the fix,
calibrating this detector against the labelled set reported 4 hits; the
moment the grammar landed, the identical calibration run reported 0%
recall, naming all six labelled tools — tcpdump, tmux, filefrag,
xfs_io, ssh-keygen, eqn — as misses, because every one of those
fixtures now parses correctly and the labelled set has nothing left to
confirm against. A detector reading zero because the bug is fixed and one
reading zero because it silently broke are indistinguishable from the fleet
number alone once that happens; what carries the weight afterward is the
detector's own hand-built tests (which construct the defective shape
directly, independent of any tool ever having exhibited it) and sweep-diff
against a fresh full sweep — not the calibration number, which has nothing
left to say once its family is fixed.
| Purpose | Crate | License | Notes |
|---|---|---|---|
| TUI framework | ratatui |
MIT | crossterm backend; mouse support |
| Display width | unicode-width |
MIT/Apache-2.0 | Required for correct truncation (§9) |
| Fuzzy matching | nucleo |
MIT/Apache-2.0 | Powers Helix |
| mandible's own CLI | clap + clap_complete |
MIT/Apache-2.0 | --completions <shell> emits a real completion script, which Tier C then parses — mandible parsing itself |
| Help-text grammar | winnow |
MIT | Preferred over pest for error recovery |
| Completion script AST | brush-parser |
MIT | Replaces conch-parser, which is unmaintained and emits a future-incompat rejection warning today [M-9]. Avoid yash-syntax (GPLv3). |
| Man page AST | (none — hand-written) | — | No bindgen/vendored mandoc. Revision 2 specified libmandoc via FFI; superseded, because it is not a system library on Linux [M-6] and #![forbid(unsafe_code)] rules out the FFI regardless. §7 Tier D is a pure-Rust subset parser over .TP/.IP + .B and .It Fl [M-14]. |
| Parallelism | rayon |
MIT/Apache-2.0 | Bounded pool for background subtree warming |
| Paths | directories |
MIT/Apache-2.0 | XDG cache/config resolution |
| Serialization | serde, serde_json, serde_yaml |
MIT/Apache-2.0 | IR, cache, carapace specs |
| Compression | flate2 |
MIT/Apache-2.0 | Cache entries |
| Errors | thiserror (libs), anyhow (binary) |
MIT/Apache-2.0 | |
| Logging | tracing + tracing-subscriber |
MIT | Behind MANDIBLE_LOG; never writes to the TUI's terminal |
| Clipboard | arboard |
MIT/Apache-2.0 | For y; degrade to OSC-52 when unavailable |
| Testing | insta, proptest, cargo-fuzz |
MIT/Apache-2.0 | Snapshots, properties, grammar fuzzing |
Build-time (not shipped): Python 3 + PyYAML, used by the catalog vendoring script. Revision 1's table omitted this even though the vendoring step already depended on it.
Data dependencies (revision 1 omitted these entirely while scrutinizing crate licenses — this is the more likely real exposure):
| Data | Source | Obligation |
|---|---|---|
| carapace specs | carapace-sh/carapace-bin |
Verify current license text at vendor time; record source commit and date; carry in NOTICE |
| withfig specs (optional) | withfig/autocomplete |
MIT; carry in NOTICE |
The project should be shippable as an open-source repo, via cargo install, and
through apt/dnf, without rework. That constrains layout from day one.
Repository layout.
LICENSE-MIT }
LICENSE-APACHE } dual-licensed MIT OR Apache-2.0 — the Rust ecosystem standard
NOTICE Third-party data attribution (§14) — required, not optional
README.md What it is, install, a screenshot, the honest coverage story
CONTRIBUTING.md Including the §1 invariant, stated prominently
CHANGELOG.md Keep-a-changelog format
spec.md This document
.github/workflows/ ci.yml (fmt, clippy -D warnings, test, coverage-harness diff),
release.yml (tagged cross-platform binaries)
xtask/ coverage harness, vendoring, packaging
packaging/ debian/, rpm/, mandible.1 (man page for mandible itself)
Cargo metadata. Every crate carries description, license, repository,
readme, keywords, categories, and rust-version (MSRV, tested in CI).
Internal crates that are not independently useful are published anyway — a
workspace cannot be cargo installed otherwise — so their descriptions must make
the relationship clear.
Distro packaging constraints, which shape earlier decisions:
- Vendored data must be reproducible from a script with a recorded source commit; distro maintainers will ask where the 11 MB came from.
- Default features must build with no network and no C toolchain. That is why Tier D is opt-in.
- Ship completions for mandible itself and
packaging/mandible.1, installed to the standard paths. cargo-debandcargo-generate-rpmmetadata live inmandible/Cargo.toml.- Respect
$XDG_CACHE_HOME/$XDG_CONFIG_HOME; never write outside them.
A tool that returns its root help for every subcommand is shown as-is
(2026-08-12). After [M-19], every systemctl subcommand's verbatim pane
shows the same root help, because systemctl <verb> --help genuinely returns
it. A special-case message ("this tool returns its root help for every
subcommand") was proposed and declined: if that is how the tool behaves,
showing it is honest, and a reader seeing identical text across 18 subcommands
can draw the conclusion without being told. Do not re-propose. The one
residual is that each degraded node keeps its own copy of that text instead of
sharing one, which is a memory cost rather than a correctness one, and does not
justify a special case either.
Enrichment by authority merge is off by default (2026-08-11). Shown a
mockup of git restore with man prose merged into its 16 already-parsed
flags, the maintainer judged it "nice to have, but kind of defeats the
cleanliness, maybe as an opt-in later." This resolved a contradiction inside
this document rather than demoting an agreed plan: §7 Tier D described prose
backfill as enrichment via authority merge, while [M-14] specified it fires
only as a zero-confidence fallback, which never touches a tool that already
parsed. [M-14]'s reading wins. A tool whose only good documentation is a man
page stays shallow in the tree, and that is a stated limit rather than a bug
to chase.
Sub-case (b) of the -h fallback is unmeasured and must stay that way until
it is measured on disposable infrastructure. Sending -h to a root whose
own --help is man-shaped is unknown territory, distinct from the shipped
sub-case (a), where a well-behaved root's subcommands detour to man pages.
Six such roots are known. The measurement belongs on an ephemeral CI runner
inside a PID or user namespace under full §6 containment, instrumented on both
sides for files written, children spawned, exit code and wall time, and
recorded as a new [M-n] with method. Never on a development machine. The
standing posture is that an unmeasured argv broadening is refused: the burden
is on the measurement, not on the objection. Two hardenings ship with the
feature whatever its final scope, namely that -h output must itself pass
help-shape validation before being consumed, and that the exec-policy shim
suite covers both halves, the fallback being attempted for a permitted shim
and refused for a pkill-shaped one even when that shim's --help is
man-shaped.
Resolved: xtask audit sample no longer reclassifies the whole PATH on
every draw. xtask audit freeze now snapshots the tool list and its
classification once, into a shuffle-stratified queue, and xtask audit sample just advances that queue's cursor — see §13.1d for the full design,
the storage decision, and the honest caveats a frozen population still
carries (population drift, and reclassification updating a stratum without
re-shuffling the queue's order).
The invariant table in AGENTS.md is due a prune. Its own maintenance
policy prefers making a mistake impossible over documenting it, and every
parser-lesson row that now exists as a corpus fixture should be deleted in the
same change that lands the fixture. The exec-policy rows stay, since shim
tests enforce them and the rows record why, though each is worth checking:
where the shim test's own comment carries the reasoning, the row can go too.
- Cold-start cost is the top UX risk. 10–25 s for cobra-heavy tools if extraction is eager [M-3]. Mitigated by lazy per-node extraction (§5.2) and caching (§11) — both of which must exist early, not in a polish phase.
- Running other people's binaries can damage a machine. Mitigated by §6, and
§6 is only real because
exec/is the sole module allowed to spawn processes and a test enforces it. - Description coverage is the actual product value, and only Tiers A and D supply it well. A tool absent from the catalog with no man page — which is every internal company CLI, precisely the case "universal" is for — renders as names with sparse prose. This is the honest limit of the design and the UI must show it rather than hide it.
- Vendored catalog staleness and weight. 11 MB, a point-in-time snapshot,
with no automatic refresh. Mitigated by preferring a live
carapacebinary, showing the vendoring date, and anxtaskrefresh path. - Tier B/C fragility across tool updates. A
--helplayout can change between minor versions and silently degrade extraction. The confidence score and provenance footer exist so this fails visibly; the coverage harness (§13.1) exists so it fails measurably. - Rendering untrusted text into a terminal. Mitigated by §4.1 and by the border-integrity render tests (§13.3).
- Attribution and licensing of vendored data (§14) — the most likely genuine legal issue, and the easiest to fix now.
- Windows is weakest for Tiers C and D. Tier A (pure data) and Tier B (help text, which every tool has everywhere) carry that platform.
- Multi-page man discovery is unproven. It is the highest-value un-built
piece for classic Unix prose, and it is also the one most likely to need
per-distro path fiddling — which brushes against the §1 invariant. Keep it
general (
MANPATH+man -k) or don't ship it.
A local tool-calling model (e.g. cactus-compute/needle, ~26–30M params, MIT
weights with an MIT ONNX export) was investigated for queries that share no
vocabulary with the target — "squash my last 3 commits" will never fuzzy-match
rebase --interactive. The conclusion is to defer it, and the reasoning is
recorded here so it isn't re-litigated.
The license finding is worth keeping regardless. The model weights and the
ONNX export are MIT, but the Cactus inference engine is a custom, non-OSI
license: free use is granted only to individuals for personal/educational/
non-commercial use, organizations under both $2M funding and $2M revenue,
educational institutions, and 501(c)(3) nonprofits — with the grant terminating
automatically if a qualifying org crosses either threshold. If mandible depended on
that engine, any downstream user past those thresholds would need a commercial
license from Cactus Compute merely for using mandible. Do not take that
dependency. If the model is ever revisited, load the MIT ONNX export via ort
or tract (both MIT/Apache-2.0), or reimplement the small architecture in
candle. Avoid the prebuilt needle-cq4.zip — it is a proprietary quantization
format tied to Cactus's kernels.
Why deferred:
- The "the CommandNode tree is almost exactly Needle's tool registry" claim does
not survive the data.
gitalone is 279 nodes and 2,999 flags [M-1]. A 26M model with an 8k BPE vocab cannot take that as a registry. You would need retrieval to pre-narrow candidates first — at which point retrieval is doing the work and the model is re-ranking, a materially different design. - It needs a fine-tune to work on CLI phrasing. Needle was trained on general
function-calling data (
get_weather(location)), not on the terse jargon ofgit/ffmpeg/tar. That is a data-generation project, not an integration. - The cheap version probably captures most of the value. The reason "squash my last 3 commits" fails today is that matching runs over names. Descriptions are already indexed for 2,979 git flags [M-1]; a BM25/tf-idf pass over description and example text should be tried first, and the residual failure set is what would justify a model.
- It inverts the product's identity — a fast, local, instant reference becomes a 61 MB ML dependency.
Vendor-published figures in this section (parameter count, distillation source, throughput) are unverified vendor claims, not measurements.
Measured 2026-08-05 on Ubuntu (Linux 6.17, x86-64). Re-measure before treating any of these as current.
-
[M-1] Vendored carapace snapshot. 740 tools; 48,224 flag descriptions total; 2,814 descriptions longer than 120 chars; 10 containing embedded newlines.
git279 nodes / 2,999 flags / 2,979 described.docker162/836/836.gh249/1,061/1,061.curl273 flags.tar171 flags.ffmpegpresent but 0 flags. -
[M-2] cobra probe shape.
gh __complete ""→ 28 subcommands, no flags.gh __complete "-"→--help,--version.gh __complete pr "-"→--help,--repo, and-Ras a separate row with an identical description. Output ends with a:Ndirective line on stdout; a human-readable directive note goes to stderr.Correction (2026-08-17): the "empty word returns subcommands only" half of this entry is wrong, and was only ever measured at nodes that have subcommands. cobra answers
__complete <path> ""by emitting the node's real subcommands and then appending whatever that command'sValidArgsFunctionreturns — application code that reads live state. At a leaf there are no subcommands, so the response is entirely argument data. Measured on docker 29.7.2, this box, with threehello-worldcontainers present:$ docker __complete container "" # a node with subcommands attach<TAB>Attach local standard input, output, and error streams to a container commit<TAB>Create a new image from a container's changes ... :4 $ docker __complete stop "" # a leaf: RUNNING CONTAINER NAMES, bare mandible-canary-1 mandible-canary-2 :4 $ docker __complete run "" # a leaf: image names, bare vsnote:latest hello-world:latest :4
This was a live defect, reported from real use: the reporter's own container names were rendered in the tree as docker subcommands, and each fabricated node was then warmed like any other (§5.2 step 4), multiplying the probe count by the size of a set that scales with the user's data rather than with the tool.
-
[M-2a] Which cobra candidates are really subcommands. Method: walk both installed cobra binaries breadth-first to depth 3 via
__complete <path> "", classifying every response list as all-described, all-undescribed, or mixed, and reading each list against the tool's real command tree. Result, over 631 distinct command paths (docker29.7.2: 253;gh2.45.0: 378):list shape count what they actually were every candidate described ( name<TAB>text)85 genuine subcommand lists, all 85 every candidate undescribed (bare name)45 argument data, all 45 mixed 5 argument data, all 5 The 50 non-subcommand lists were container names, image names and tags, network names, buildx builder names, docker context names, and
gh project field-create --data-type's enum values. No real subcommand appeared undescribed, and no argument value appeared in an all-described list, which is why Tier E accepts a candidate list only when every candidate in it carries a non-empty description (§7 Tier E). The mechanism behind the rule is cobra's own formatting: it writes subcommand rows withfmt.Sprintf("%s\t%s", subCmd.Name(), subCmd.Short), while aValidArgsFunctionreturning a plain[]stringproduces bare rows.The 5 mixed lists were all
docker context <verb> "", whose completer decorates only the currently selected context (rootless<TAB>current) and leaves the rest bare. They are why a single undescribed candidate has to condemn the whole list rather than only itself: cobra marks no boundary between its own subcommand block and the appended argument block, so a described entry sitting in a list that also carries bare entries cannot be attributed to either.Known residual, measured, not closed. A completer that describes every value it returns is indistinguishable from a subcommand list in cobra's wire format.
docker context use ""hits this on a machine with exactly one context, where the sole candidate isdefault<TAB>current; with two or more contexts the list is mixed and the rule closes it. Closing the one-context case would need a signal cobra does not provide.Two alternative designs were measured and rejected. (a) Probing cobra's own
helpcommand —__complete help <path> "", which enumerates the command tree and never runs aValidArgsFunction— is exactly right ongh(gh __complete help pr ""returnspr's 20 real subcommands) and returns nothing at all ondocker, which replaces cobra's help command. (b) Differencing against a sentinel positional —__complete <path> <sentinel> ""suppresses cobra's subcommand block, soA \ Bis the subcommand set — works for 6 of 8 sampled paths, costs a third spawn per node (+50%), and still leaksdocker context use, whose completer returns the same list either way. -
[M-3] Recursive walk cost.
docker: 255 nodes, 232 spawns, 10.5 s.gh: 196 nodes, 182 spawns, 11.6 s. Both depth-capped at 3, one probe per node. ~40–65 ms per spawn. -
[M-4] clap
CompleteEnvavailability.COMPLETE=zsh rg→ "ripgrep requires at least one pattern to execute a search".COMPLETE=zsh cargo→ ordinary help text. Neither supports the protocol. -
[M-5] Man page availability. Test container: 31 pages in
/usr/share/man/man1, none forgitorcurl. -
[M-6] libmandoc. No
libmandoc*in/usr/lib/x86_64-linux-gnu;mandocavailable only as an apt source package. -
[M-7] Pane width. At 80 columns, a 35% tree pane is 28 cells; minus borders and a depth-3 indent, ~20 cells remain for name plus summary.
-
[M-8]
--helpstream and exit code.openssl --help: 0 bytes stdout, 2,908 bytes stderr, exit 0.ip --help: 0 bytes stdout, 972 bytes stderr, exit 255.ffmpeg -h: 5,365 stdout and 1,827 stderr. -
[M-9]
conch-parser. Builds with "the following packages contain code that will be rejected by a future version of Rust: conch-parser v0.1.1". -
[M-10] Tier B phantom subcommands (2026-08-05, first implementation). Tools with no subcommands at all reported:
tar39 nodes,dd40,less65,zstdless65,sed17,zoxide13,find11,zramctl9. Phantom names were wrapped description fragments ("treat them as errors", "extracting (default)", "silently skip over them") and--format=enum values (gnu,oldgnu,pax,posix).tarandddwere reportedokat100% described; onlyless/zstdlesstrippedlow-confidence. -
[M-11] Probes that write. A coverage run invoking
--helpacross 1,665PATHexecutables caused font-cache builders to write into the working directory, andmysql_secure_installationto write a.my.cnfcontaining an empty root password.--helpis not reliably a read-only operation. -
[M-12] Framework distribution (2026-08-06, 1,634 executables in
/usr/bin,/bin,/usr/sbin; 1,563 with usable--help). Classified by deliberately crude fingerprints: genericUsage:31.2%, clap-v4 24.6%, GNU argp/getopt 15.5%, argparse 4.1%, picocli 2.0%, BSD-terse 0.6%, clap-v2 0.4%, then dotnet/urfave/go-flag/symfony/cobra/oclif/click ~1% total; unmatched 20.4%. Three fingerprints cover 71%; about a dozen cover ~80%, and better-engineered patterns would improve on that. Separately: flag descriptions from live--helpparsing alone reached 87.0% across the 904 tools absent from the carapace catalog, versus 99.5% for the 251 tools in it. -
[M-14] What a man-page tier would actually recover, measured before building one. Of the 724 tools the PATH sweep still rates weak (verbatim, no-tier, low-confidence, or zero/poorly-described flags), 164 have a man page whose flag-tagged entries outnumber what mandible finds today, worth about 2,515 option entries. 472 have a man page with no flag-tagged entries at all (prose only — roff would not help them), and 78 have no page. The gainers are not obscure:
ssh52 entries against 0 today,bash162 against 18,ps58,tcpdump82,mdadm125,dash/sh65.Three design constraints fell out of the measurement. Do not gate on an
OPTIONSsection —bash,psandtmuxdocument options underDESCRIPTION, and that gate alone cost 28 tools; gate on the tag line beginning with a flag, which is also what excludes examples (pstags its examples with.TP, and a looser rule counted them, inflating the estimate to 276 tools / 4,923 entries before it was corrected). Target man(7).TP/.IP+.B, with.It Flfor mdoc — only ~20 of these pages are mdoc, so an mdoc-first plan aims at the wrong majority; combined with [M-6] and#![forbid(unsafe_code)]ruling out C FFI, a pure-Rust subset parser is both smaller and unblocked. And fire only as a zero-confidence fallback, which keeps staleness away from the ~1,500 tools that already parse and avoids authority-merge questions entirely.Note also that [M-5]'s "31 man1 pages" is a bare-container artifact and understates real systems badly: the machine this was measured on has 9,515.
2,515 is an upper bound — entries present in the pages, not entries a parser would extract cleanly.
-
[M-13] Artifact fingerprinting beats prose fingerprinting.
stringsover the binary:dockercontainsspf13/cobra583×,gh283×;gitandripgrepcontain zero (correctly — hand-rolled C, and ripgrep dropped clap for a custom parser). Meanwhile a help-text signature keyed on cobra's usualAvailable Commands:misseddockerentirely, because docker printsCommon Commands:. -
[M-16] git's subcommands are recoverable today, via
-h— and the man tier as designed would not recover them (2026-08-10, git 2.43.0, aarch64). Found by running the TUI by hand, not by any gate.git <sub> --helpdoes not print help: it execsmanand rendersGIT-COMMIT(1). The man-page banner check catches that and correctly degrades to verbatim (§7 Tier B step 3), so all 22 of git's listed subcommands render as raw roff output while the root parses cleanly.[M-14]'s design does not reach them. It targets man(7)
.TP/.IP.B. git's 184git-*.1pages contain zero.TPmacros — they are asciidoc-generated and mark options as bold-run paragraphs (\fB\-\-amend\fR, 2,426 occurrences, an inflated upper bound since inline cross-references repeat). So the tier spec §7 Tier D names git as its highest-value case would recover approximately nothing from git. Reconciling Tier D with [M-14] must also account for the asciidoc- generated dialect, which is a generator, not a tool — §1-clean, and the same unit of knowledge Tier A′ already parses by.
The cheap path is a probe-ordering rule, not a new tier.
git commit -hprints an ordinary two-column option table the generic grammar already handles. Measured across the 22 listed subcommands: 501 option lines on 21 of them (onlybisectyields none). mandible extracts zero today, purely because the-hfallback fires only when--helpproduced no output on either stream — and a man page is plenty of output.The fix is to treat detected as a rendered man page as "no usable help" and fall back to
-h, reusing the banner detection that already exists. Keyed on an observable property of the output, never on the tool name. The one hazard is already contained:-his an action flag on machine-state tools, andHELP_ONLY_PROBErestricts those to exactly--helpregardless (§6 rule 0).The exposure set, now measured (2026-08-10, same machine — aarch64, 2,266 tools on
PATH; CI's x86-64 image carries ~2,832 and its list will differ, so the authoritative run is CI's). The coverage harness gained amancolumn that re-runs the existing banner check over text the pipeline already captured (CommandNode::unparsed), so the enumeration costs no new probe and no new argv shape — which matters, because measuring an argv broadening by performing one would be circular.Six tools have a man-shaped root
--help:byobu,byobu-screen,byobu-tmux,git-receive-pack,git-upload-archive,git-upload-pack. All six currently renderverbatimwith zero flags. None belongs to the process-signalling or machine-state classes §6 rule 0 restricts, so the measurement campaign for the risky sub-case is six named binaries rather than a survey.verbatimwould have been a 50× overestimate as a proxy. The same sweep reportsverbatim_count=314againstman_shaped_count=6: a root degrades to verbatim overwhelmingly because nothing parsed, only rarely because it printed a man page. Anything reasoning about man-page exposure from the verbatim column is wrong by a factor of fifty — the distinction has to come from the banner check itself.Adding the column changed no parse:
pct_described94.18%,no_tier2,suspicious1, identical to the run before it.git's own root correctly reports not man-shaped, which is the true negative that matters — it is git's subcommands that print man pages, not its root. -
[M-15] The declination tax: 378 tools report
okwith zero flags (2026-08-10, mandible 0.2.2 at 7384b6f, aarch64 — note this differs from the x86-64 baseline above, and that difference is itself an argument for pinning the sweep's environment). Of the 1,895 tools the full-PATHsweep ratesok, 378 (20%) carry no flags at all.This is the exact counterpart to [M-10] and it is invisible to every gate §13.1 currently defines. [M-10] was fabrication — invented nodes inflate
%described, which the structure-sanity column now catches. This is declination: a tool the grammar refused to read rather than risk fabricating from. It depresses no metric, because a node with zero flags has no described-ratio to report at all — the scoreboard prints—and the tool is excluded from the aggregate rather than counted against it. So every precision tightening insections.rs(the apt-get prose rule, the mysqlslap same-indent rule, the curl usage-continuation rule) could pay for itself in recall elsewhere onPATHand nothing would say so. §13.1's own lesson applies to its own gate set: a metric a failure mode can slip past is worse than no metric.Method: the checked-in scoreboard, filtered to rows with status
okand a flag count of 0. Deliberately not re-derived by shelling out to 378 binaries — probing outsideexec::run_inertbypasses every §6 rule, so the finer breakdown below has to be computed inside the harness.Not measured, and worth measuring in the harness: how many of the 378 publish flags only in a usage synopsis. Spot-checked by hand, that case is real and includes marquee tools —
git --helpdocuments-v/--version,-C <path>,-p/--paginate,--git-dir=<path>and eight more entirely inside itsusage:block, and mandible extracts none of them (help_text::sections::extract_positionalsmines that block for positionals only).zipinfois the same shape. The class is not uniform, though, and the honest counterexample matters:apt-get's zero flags is correct — apt 2.8.3's help has no options section at all — so the 378 is an upper bound on what any single grammar could recover, not a target. -
[M-17] The
/dev/ttyhazard behind themandible systemctlfreeze report, and what actually triggers it (2026-08-11, systemd 255, less 643, dash (Ubuntu/bin/sh), aarch64, Ubuntu 24.04). A user reportedmandible systemctlfreezing their entire TUI, with a pager observed. The working theory —env_clear()leavesPAGERmerely absent, which lets a pager-searching tool go findlessitself, combined withprocess_group(0)not severing the controlling terminal — turned out to be right about the mechanism and wrong about which half of it fires forsystemctlspecifically.The
PAGER-absence half does not hold, measured. systemd's own pager gate checksisattyon its own stdout and stderr before ever consultingPAGER, andrun_inertalways makes both pipes. A sweep of all 74systemctlverbs plus the root, each probed with both--helpand-hunder the exact sanitized environmentrun_inertuses (run insideunshare -rpf --mount-proc— read-only queries only, no privileged verb was actually dispatched, confirmed by identical output across every verb, see the second half below), found zero pager invocations: every one of the 148 probes produced byte-identical global help text to plainsystemctl --help, with empty stderr and no descendant process of any kind. Confirmed at theless-binary level too, directly viastrace -f -e trace=openat,open:lessnever attemptsopen("/dev/tty")once its own stdout is non-tty — true both with no controlling terminal available at all, and (built for this measurement, usingopenpty()+login_tty()to give a throwaway process a real pty as its controlling terminal, standing in for a real interactive session) with one genuinely available.less's decision is keyed entirely on its own fds, not on whether a reachable controlling terminal exists.The session half is real, demonstrated with a positive control. Confirming
process_group(0)'s residual risk needed a program that wants a controlling terminal unconditionally, since no argv this crate actually constructs against any known tool was found to do so in this environment. Built directly: a shim run under the same rig (real pty as controlling terminal vialogin_tty, probe spawned exactly asrun_inertspawns it) that does nothing butopen("/dev/tty"). Underprocess_group(0)alone, it succeeds — the descendant reaches the real controlling terminal, exactly the mechanism the bug report pointed at, regardless of which tool or argv triggers it. Spawning the probe as the leader of a new session (pre_exec+setsid(), this crate's one auditedunsafe) makes the same call fail withENXIO. This istests/exec_policy.rs'sdev_tty_hazard:: probe_cannot_reopen_the_controlling_terminaltest, verified to fail against the pre-fix code and pass against the fix — the AGENTS.md §2 discipline of a fixture that has actually been made to fail once, not prose alone.Building the positive control itself needed two non-obvious fixes, recorded here since they'd otherwise cost the next person the same half-day: (a)
openpty()must happen before the worker becomes a ctty-less session leader, or a plainopen()risks auto-acquiring the terminal ambiguously; and more load-bearing, (b) the pty's master side must stay open — closing it (even in a different process that merely held a copy of the fd) hangs up the slave, andTIOCSCTTY/login_ttyon a hung-up slave fails withEIO, which is easy to misread as "the fix already worked" when it is actually a broken rig.Net effect: both fixes shipped regardless of which half caused the original freeze — the pager variables (rule 6) as defense-in-depth against a tool whose own pager gate is weaker than systemd's
isattycheck, and the session change (also rule 6) because it is the only one of the two demonstrated, by measurement, to actually close a realopen("/dev/tty")path. The exact trigger on the reporting user's own machine remains unreproduced — this sandbox has no controlling terminal at all (AGENTS.md§3.2), so the user-visible freeze itself could not be attempted directly, only the underlying mechanism. -
[M-18]
systemctl <verb> --helpis safe — measured, not assumed (2026-08-11, same environment as [M-17]). Prompted by rule 0's own precedent:shutdown -hlooked harmless until measured and turned out to attempt the real halt, stopped only by polkit.systemctl's verb list includesreboot,poweroff,kexec,haltand other machine-state actions, and the background tree warmer will probeHelpLongForPath(systemctl <verb> --help) for every one of them —HELP_ONLY_PROBE(rule 0) matches on the binary's file name, so it restricts the standalonereboot/poweroff/halt/shutdownmulti-call symlinks but notsystemctlinvoked with one of those words as a verb.All 74 verbs from
systemctl --help's own listing, each probed assystemctl <verb> --helpandsystemctl <verb> -hunder the sanitized environment (insideunshare -rpf --mount-proc), produced output byte-identical to plainsystemctl --help— includingreboot,poweroff,kexec, andhalt— with empty stderr and exit 0 for every case. This is the opposite finding fromshutdown -h: onsystemctl(unlike its standalone multi-call-binary symlinks),-his a genuine alias for--help, and GNU getopt's permutation intercepts it globally before verb dispatch, for every verb tested, not just the ones already covered by rule 0. No change toHELP_ONLY_PROBEor any other safety list is needed; this closes the concern rather than extending the list. -
[M-19] The actual mechanism behind the
mandible systemctlfreeze: a self-similar background-warmer fan-out, not a pager and not/dev/tty(2026-08-12, systemd 255, mandible atfd5212f, aarch64, 4-core sandbox with a workingsystemctl/PID 1 — not the bare container [M-5]/[M-17] measured in). [M-17] and [M-18] closed the tty-reachability and per-verb safety questions but left the freeze itself unreproduced. It reproduces.Reproduced under
scripts/pty_screenshot.py, which forks a real pty.mandible systemctlunder the harness: pressingj(move selection down) up to 75 times in a row produced zero change on screen — same frame, byte-for-byte, every capture — whilemandible gitunder the identical harness updates on every single keystroke. Eventually (around the 75th key, ~45s of wall time on this machine) exactly one keystroke got through, then it froze again. Ruled out as a screenshot-tooling artifact by first confirminggitvisibly changes pane content (not just highlight color, which the plain-text capture can't see) on onej, and by checking withps//procmid-run.Failure shape: the event loop starved, not a blocked process. While frozen, the
mandibleprocess was in stateS(interruptible sleep, notD), andpsshowed 132% CPU across its threads — actively running, contending for the CPU, not blocked on a syscall. This is the fourth shape the investigation brief distinguished ("the event loop starved"), not a hung child, not a/dev/ttyreopen, not corrupted terminal state — the terminal state and the process were both fine, the main thread just never got scheduled back around toevent::pollandterm.draw(mandible/src/app_runner.rs'srun_loop) for tens of seconds.Root mechanism, confirmed at the shell and then via the extraction API directly.
systemctl's GNU getopt permutes--helpto the front of argv regardless of what precedes it, andsystemctlnever validates a verb before help dispatch — sosystemctl <anything...> --helpprints the tool's own root help, byte-identical, no matter how many words come first or what they are:$ diff <(systemctl --help) <(systemctl preset-all get-default daemon-reload halt reboot --help) (no difference; exit 0)Calling
Runner::fill_nodedirectly (the same call the background warmer makes) againstsystemctl,systemctl preset-all, andsystemctl preset-all get-defaultin turn showed all three reporting the same 18 subcommands — becauseHelpTextTier::extract_node(mandible-extract/src/help_text/mod.rs) has no way to know a probe's output describes the root rather than the node it was asked about, so it reads the root's "Commands:" section a second (third, fourth…) time and reports it as that subcommand's own children.Why that becomes a freeze, not just wrong data. The background
Warmer(mandible/src/background.rs) cascades every discovered child's fill unconditionally — "every fill queues the children it just discovered" — with no cycle or self-similarity detection (unlike the cobra tier's own visited-set protocol, [M-2]). 18 children each (wrongly) reporting 18 children of their own is 18², then 18³ = 5,832, bounded only byMAX_WARMED_NODES(4,096) — a global submission cap, not a depth cap. Reaching it means thousands of concurrentsystemctl <phantom path...> --helpspawns queued on a 16-thread pool (available_parallelism() * 4clamped to[4, 32]; 4 cores here) plus their stdout/stderr reader threads, all contending with the single UI thread for the same 4 cores — enough scheduler pressure, measured, to starve a 100ms poll loop for 45+ seconds, which is exactly what a user experiences as "the TUI froze."The fix: recognize the hazard from an observable property of the output, the same discipline [M-16] used for man-page detection — never the tool's name.
HelpTextTiernow remembers each tool's root--helptext (keyed by resolved binary path) the first time it probes the root, and compares every later subcommand probe's raw text against it. A byte-identical match degrades that node to verbatim (spec §7 Tier B step 3 — "never fabricate, degrade to verbatim", the same existing machinery a structurally-implausible parse already uses, factored out asverbatim_node) instead of reporting the root's subcommands as its own:children_filled: true,subcommands: [], raw text still available to thetview. An emptysubcommandslist is what stopsWarmer::warm_children's loop from queuing anything further for that node, so the cascade halts at depth 1 instead of growing exponentially. This generalizes to any multi-call binary or getopt permutation with the same behavior, not justsystemctl— the check never inspects the tool's name.Verified no longer reproducing, same pty harness, same commands. Post-fix,
mandible systemctlunderpty_screenshot.py: everyjpress updates the pane immediately (preset-all→get-default→show-environment→ …, all 18 of the root's children, one per keystroke), matchinggit's responsiveness exactly. A 40-keystroke stress run completed in the harness's own nominal wall-clock time (~26s for 40 keys at the harness's fixed pacing) with no stall at any point.HelpTextTier::extract_node, called the same way against the same three-levelsystemctlpath directly, now reports 18 subcommands at the root and 0 at the first child (previously 18/18/18). Regression tests (mandible-extract/src/help_text/mod.rs::tests:: a_subcommand_probe_identical_to_the_root_does_not_fan_outand::a_subcommand_probe_merely_similar_to_the_root_is_parsed_normally) pin both the positive case and the negative one (a subcommand that shares a preamble with the root but is not byte-identical still parses its own real flags) — the first, verified against this section's own discipline, fails against the pre-fix tier with the exact symptom above (["preset-all", "get-default"]reported as the child's own children) and passes against the fix.The full workspace test suite (584 tests, two more than the 582 this investigation started from) and all 6 corpus fixtures stayed green throughout — the guard is keyed on exact byte equality to the root, so it never fires for a genuinely distinct subcommand's genuinely distinct help text, corpus fixtures included.
| Area | Revision 1 | Revision 2 | Why |
|---|---|---|---|
| Tier priority | One priority: u8; first tier wins |
Two-axis Authority (structural/prose); attempt order is cost, conflict order is authority |
The best-structure tier is usually not the best-prose tier [M-1, M-2] |
| Tier ordering | Tier 0 (native) first | Tier A (catalog) first | Catalog is 740 tools / 48k descriptions for zero spawns; clap CompleteEnv is nearly absent [M-1, M-4] |
| Extraction trait | extract() -> whole tree |
extract_node(path) + laziness |
Eager extraction is 10–25 s for docker/gh [M-3] |
| Cost model | Absent | §5.1, with measurements | Largest UX risk in the project |
| Execution safety | Implicit; probe ran bare binaries | §6, binding rules + enforcement test; exec/ is the only module allowed to spawn |
Running an arbitrary binary bare is not a cheap operation |
| Provenance | One enum per node | Per-field, with Source list |
Per-node provenance is inaccurate after a merge, and an inaccurate trust badge is worse than none |
| Text handling | Unspecified | Text::sanitize as an IR invariant |
Root cause of the border corruption reverted twice |
| Flag identity | same_flag on long name |
Alias pairing before merge | Sources emit --repo and -R separately [M-2] |
| IR fields | — | hidden, deprecated, choices, group, inherited, children_filled |
Needed for correct display and lazy fill |
| Search | Flags folded into parent haystack | Flags are first-class index entries; hierarchy-preserving filter | "Find the flag" is the core job; flat filtering breaks navigation |
| cobra protocol | One probe | Two probes + depth cap + visited set + alias detection | One probe returns zero flags [M-2]; unbounded recursion is reachable |
--help capture |
stdout, exit 0 | stdout and stderr, any exit code | openssl/ip fail the revision-1 rule [M-8] |
| Tier B recursion | Root only | Per-node, lazily | Root-only yields no subcommand flags |
| Man tier | System libmandoc |
Vendored mandoc + cc; multi-page discovery |
Not a system library on Linux [M-6]; git's tree lives across git-*.1 |
| Shell parser | conch-parser |
brush-parser |
Unmaintained; future-incompat warning [M-9] |
| Catalog storage | 11 MB include_str! |
Indexed, one tool deserialized per lookup | Whole-catalog deserialize per lookup |
| Cache key | Binary content hash | Path/size/mtime/inode + versions; negative caching | Hashing a 50 MB binary costs more than the parse |
| Layout | 35%/65% | Min(24) + fill, responsive breakpoints |
35% is ~20 usable cells at 80 columns [M-7] |
| Testing | Golden files | Golden files + coverage harness + real-argv, exec-policy, sanitization, border-integrity, fuzz | Makes universality measurable; a mocked-past argv bug shipped a dead tier |
| Data licenses | Absent | §14 table + NOTICE requirement |
Most likely genuine legal exposure |
| Packaging | Absent | §15 | Shipping to crates.io/deb/rpm constrains layout from day one |
| NL search | Phase 6 feature | §17, deferred with reasoning preserved | Registry-size claim fails on real data; needs a fine-tune project |
| UX | — | y copy, ? overlay, --doctor, designed degraded states |
Copying the flag is the end of the core journey |
Post-revision-2 note (2026-08-11): pct_described's denominator changed.
Not a revision bump on its own, but worth recording here because it changes
what every historical scoreboard number means — a reader comparing an old
coverage-scoreboard.txt/coverage-scoreboard.ci.txt figure against a new
one needs to know the ratio itself moved, not just the tools underneath it.
pct_described was described / total; it is now described / describable
(§13.1b's metric design rules), excluding flags whose only source is a usage
synopsis (mandible_core::Source::HelpTextSynopsis, spec [M-15]) from the
denominator, since a synopsis carries spellings, never prose, by
construction. A scoreboard produced before this change has no
describable_flags field in its # aggregate: footer at all (it defaults
to 0.0 on parse, per parse_aggregate_footer's doc comment) and its
pct_described was computed over raw total_flags; a scoreboard produced
after it has both fields, and pct_described is over describable_flags.
The raw flag count (total_flags, and the per-row flags column) is
unaffected either way and remains directly comparable across the change.
Post-revision-2 note (2026-08-11, later same day): the column was renamed,
its ratio unchanged. A second, independent change from the denominator one
above — worth its own entry because it changes what a historical
scoreboard's column header means, not what any number in it is. The
scoreboard's %described column and its pct_described aggregate/footer
field are renamed %flags_text/pct_flags_with_text: same computation
(described / describable, per the note above), same value on any given
scoreboard, only the name changed. The rename exists because %described
reads as an accuracy claim — "this flag's text is correct" — when all it has
ever measured is presence — "this flag has text attached" — and lsof
(corpus/lsof/4.95.0, [xfail]) is the proof the gap is real: it scored 79%
"described" while roughly a quarter of its flags carried a different flag's
description, misread from a three-column options table the generic parser
reads as one. See §13.1's note on the misattribution detector
(xtask/src/misattribution.rs), the instrument this incident motivated, and
§13.1b's added fifth metric-design rule on names as part of a metric's
design. A scoreboard written before this rename has pct_described= in its
# aggregate: footer instead of pct_flags_with_text=;
parse_aggregate_footer reads both, mapped to the same field, so --check
against an old baseline still works. Every scoreboard, old or new, also now
carries a literal # accuracy: unmeasured line — not parsed by --check,
just a standing, honest remi
342C
nder that nothing here measures correctness yet.
Post-revision-2 note (2026-08-12): the "anti-fabrication oracle" is two
checks, not one. WS4 originally described a single instrument; building it
found that misattribution (xtask/src/misattribution.rs, added first) and
existence (xtask/src/existence.rs, added by this note) check different
things with different victims — see §13.1's own account of both. The
scoreboard gains an exist column (right after misattr, same tightly-packed
right-aligned style) and the # aggregate: footer gains
existence_fabrication_tools=, appended after
misattribution_column_aligned_tools= and before total=. A scoreboard
written before this change has neither key; parse_aggregate_footer defaults
existence_fabrication_tools to 0 on such a scoreboard, so --check
against an old baseline still works. xtask/src/transition.rs's fixed-offset
row parser (row_offsets) was updated in the same change to recognize the new
column's width (has_existence_column, mirroring the existing
has_misattr_column) — without it, sweep-diff would have silently misread
every status field on any scoreboard carrying the new column, since that
field is sliced by trailing character offset, not by name. Caught by
strengthening parses_a_freshly_rendered_scoreboard_back_out to assert
status's actual value (not just that a row with the right key exists)
rather than by any gate — worth recording here because the failure mode is
exactly the kind a presence-only test stays green through.