Skip to main content

majordomus_cli/capability/
closure.rs

1//! Projection closure: a declared exposure is answered by the surface that would carry it.
2//!
3//! `project.interfaces-are-projections@1` says a capability is defined once and every
4//! external interface is derived from that definition. Three of the four interfaces prove
5//! it by construction — the MCP tool list, the HTTP route table and the OpenAPI document
6//! are each built by walking the registry, so an entry cannot exist without a descriptor
7//! and a descriptor cannot fail to produce one. The command line is the exception. It is
8//! declared a second time, in clap, in `cli.rs`; [`CliExposure`](super::CliExposure) is a
9//! *claim* about that declaration, and until this module nothing compared the two.
10//!
11//! The gap was not theoretical. `tests/projections.rs` checked the CLI half with
12//! `registry.by_cli(&cli.path).unwrap().id == c.id`, which asks the registry whether it
13//! agrees with itself and is satisfied by any path at all, including one clap has never
14//! heard of. A capability could declare `cli: ["worktree", "topology"]` with no such
15//! subcommand and nothing — not the build, not the tests, not `generate --check` — would
16//! say so; the failure surfaced at run time, to whoever typed the command, as "no
17//! capability is exposed as `majordomus …`".
18//!
19//! So this module compares the two declarations, in both directions:
20//!
21//! * **declared, not present** — a capability claims a CLI path the clap tree does not
22//!   have, or has only as a group that cannot be run. That is a broken promise and a
23//!   failure ([`Finding`]).
24//! * **present, not declared** — a runnable clap command that no capability claims. That
25//!   is not automatically wrong: `serve`, `mcp` and `generate` are infrastructure and have
26//!   no capability behind them by design. It is, however, the measure of how much of the
27//!   command line is still hand-written rather than derived, so it is reported as an
28//!   inventory ([`unbacked`]). What is refused is the *silent* case: of that inventory,
29//!   the commands [`crate::cli::local::LOCAL`] does not explain either ([`Matrix`]'s
30//!   `unclassified`), which `scripts/ci/projection-check` exits 10 on.
31//!
32//! Everything here is a pure function of the registry and the clap tree. Nothing reads the
33//! repository, the environment or the network, so the whole check costs a walk of two
34//! in-memory structures and runs inside a unit test.
35
36use std::collections::{BTreeMap, BTreeSet};
37
38use super::model::Capability;
39use super::registry::CapabilityRegistry;
40use crate::cli::{CommandDoc, DECLARATION};
41
42/// Which interface a finding is about. Only the command line can be closed by comparison
43/// today, because it is the only interface with a declaration of its own to compare
44/// against; the variant exists so that a surface which grows one later says which it is
45/// rather than being told apart by the text of its message.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
47pub enum Projection {
48    /// The native command line, declared in clap in `cli.rs`.
49    Cli,
50}
51
52impl Projection {
53    /// The name a report prints.
54    ///
55    /// ```
56    /// use majordomus_cli::capability::closure::Projection;
57    /// assert_eq!(Projection::Cli.name(), "cli");
58    /// ```
59    pub fn name(self) -> &'static str {
60        match self {
61            Projection::Cli => "cli",
62        }
63    }
64
65    /// The file the projection is declared in; where a reader goes to fix it.
66    pub fn declaration(self) -> &'static str {
67        match self {
68            Projection::Cli => DECLARATION,
69        }
70    }
71}
72
73/// One broken promise: a capability declared an exposure and the surface does not carry it.
74///
75/// The shape follows `project.finding-carries-reproduce@1` — a finding names the subject,
76/// what is wrong, where the claim was made, where the surface is declared, and the one
77/// command that shows it again.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Finding {
80    /// The stable code a reader greps for.
81    pub code: &'static str,
82    /// The capability whose claim is unmet.
83    pub capability: String,
84    /// The interface the claim was about.
85    pub projection: Projection,
86    /// The exposure as declared, `majordomus why show`.
87    pub claim: String,
88    /// What is wrong, in one line.
89    pub detail: String,
90    /// Where the capability is declared; repository-relative, never a machine path.
91    pub source: String,
92    /// The rule the claim belongs to.
93    pub rule: &'static str,
94}
95
96impl std::fmt::Display for Finding {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        write!(
99            f,
100            "{}:\n  capability: {}\n  claims: {}\n  source: {}\n  projection: {}\n  detail: {}\n  rule: {}\n  reproduce: majordomus capabilities projections --unmet",
101            self.code,
102            self.capability,
103            self.claim,
104            self.source,
105            self.projection.declaration(),
106            self.detail,
107            self.rule,
108        )
109    }
110}
111
112/// The rule every finding here belongs to; named once so the text cannot drift from it.
113const RULE: &str =
114    "a capability is defined once and every external interface is derived from that definition";
115
116/// A runnable command of the command line that no capability claims.
117///
118/// Not a violation on its own: the process commands (`serve`, `mcp`), the generators
119/// (`generate`) and the lifecycle verbs that call a service directly are all legitimately
120/// hand-written today. It is the debt the rule is measured against, and the gate that reads
121/// it refuses growth rather than existence.
122#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
123pub struct Unbacked {
124    /// The command as a person types it, `majordomus web list`.
125    pub command: String,
126    /// The words after `majordomus`, which is what a [`CliExposure`](super::CliExposure)
127    /// would have to carry for this command to be derived.
128    pub path: Vec<String>,
129}
130
131/// Where one capability is projected, and where it is not.
132///
133/// This is the coverage matrix of the rule: a row per capability, a column per interface.
134/// It is derived, never written down — a capability that reaches nothing shows as a row of
135/// `false`, which is exactly the "exists but is invisible" case worth seeing.
136#[derive(
137    Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
138)]
139pub struct Row {
140    /// The canonical id.
141    pub id: String,
142    /// The module that composes it.
143    pub module: String,
144    /// Query, command or resource.
145    pub kind: String,
146    /// Where it stands.
147    pub stability: String,
148    /// What a call changes: nothing, this process's memory, or the repository. The same
149    /// classification every projection reads, so the matrix answers "where is it reachable"
150    /// and "what does reaching it risk" in one row.
151    pub effect: super::model::Effect,
152    /// The command line, when it declares one and clap has it.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub cli: Option<String>,
155    /// The HTTP route, when it declares one.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub http: Option<String>,
158    /// The MCP tool name, when it declares one.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub mcp_tool: Option<String>,
161    /// The MCP resource URI, when it declares one.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub mcp_resource: Option<String>,
164    /// Whether every exposure this row declares is answered by its surface.
165    pub closed: bool,
166    /// Where the capability is declared; repository-relative.
167    pub source: String,
168}
169
170/// The whole matrix, with the findings and the debt beside it: one value that answers
171/// "where does each capability appear, and is any claim unmet".
172#[derive(
173    Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
174)]
175#[schemars(rename = "ClosureMatrix")]
176pub struct Matrix {
177    /// One row per capability, in id order.
178    pub rows: Vec<Row>,
179    /// Every unmet claim, in capability order. Empty is the rule satisfied.
180    #[serde(skip, default)]
181    pub findings: Vec<Finding>,
182    /// Runnable commands of the command line that no capability claims, in command order.
183    pub unbacked: Vec<String>,
184    /// Of those, the ones [`crate::cli::local::LOCAL`] does not explain either: a command
185    /// that is neither the projection of a capability nor declared command-line-only with
186    /// a reason. Empty is the closure the rule asks for; anything here is an operation
187    /// that left the API by accident rather than by decision.
188    pub unclassified: Vec<String>,
189}
190
191/// Every unmet claim, in capability order, then in projection order.
192///
193/// Pure: the registry and the clap tree, nothing read from disk. An empty result is the
194/// closure the rule asks for.
195///
196/// ```
197/// # use majordomus_cli::capability::{closure, CapabilityRegistry};
198/// // the command line this crate declares answers every claim made about it
199/// let registry = CapabilityRegistry::builder()
200///     .with_modules(majordomus_cli::capability::builtin::modules())
201///     .build()
202///     .expect("the builtin registry builds");
203/// assert!(closure::findings(&registry, &majordomus_cli::cli::tree()).is_empty());
204/// ```
205pub fn findings(registry: &CapabilityRegistry, tree: &CommandDoc) -> Vec<Finding> {
206    let commands = index(tree);
207    let mut out: Vec<Finding> = registry
208        .iter()
209        .filter_map(|c| check(c, &commands))
210        .collect();
211    out.sort_by(|a, b| {
212        (a.capability.as_str(), a.projection, a.code).cmp(&(
213            b.capability.as_str(),
214            b.projection,
215            b.code,
216        ))
217    });
218    out
219}
220
221/// Every runnable command of the command line that no capability claims, in command order.
222///
223/// ```
224/// # use majordomus_cli::capability::{closure, CapabilityRegistry};
225/// let registry = CapabilityRegistry::builder()
226///     .with_modules(majordomus_cli::capability::builtin::modules())
227///     .build()
228///     .unwrap();
229/// let debt = closure::unbacked(&registry, &majordomus_cli::cli::tree());
230/// // `serve` starts a process and is not a capability; it is in the inventory by design
231/// assert!(debt.iter().any(|u| u.command == "majordomus serve"));
232/// ```
233pub fn unbacked(registry: &CapabilityRegistry, tree: &CommandDoc) -> Vec<Unbacked> {
234    let declared: BTreeSet<&[String]> = registry
235        .iter()
236        .filter_map(|c| c.exposure.cli.as_ref().map(|e| e.path.as_slice()))
237        .collect();
238    let mut out: Vec<Unbacked> = tree
239        .flatten()
240        .into_iter()
241        // the root groups everything and is nobody's exposure
242        .filter(|c| c.executable && c.path.len() > 1)
243        .filter(|c| !declared.contains(&c.path[1..]))
244        .map(|c| Unbacked {
245            command: c.command(),
246            path: c.path[1..].to_vec(),
247        })
248        .collect();
249    out.sort();
250    out
251}
252
253/// A matrix row is the capability it reports on, and is ordered by the same id the registry
254/// and the site order capabilities by, so the three can be read side by side.
255impl crate::order::Ordered for Row {
256    fn order_key(&self) -> crate::order::OrderKey<'_> {
257        crate::order::OrderKey::plain(&self.id, &self.id)
258    }
259}
260
261/// The matrix, the findings and the debt in one pass.
262pub fn matrix(registry: &CapabilityRegistry, tree: &CommandDoc) -> Matrix {
263    let commands = index(tree);
264    let mut rows: Vec<Row> = registry.iter().map(|c| row(c, &commands)).collect();
265    crate::order::canonical(&mut rows);
266    let debt = unbacked(registry, tree);
267    Matrix {
268        rows,
269        findings: findings(registry, tree),
270        unbacked: debt.iter().map(|u| u.command.clone()).collect(),
271        unclassified: debt
272            .iter()
273            .filter(|u| crate::cli::local::find(&u.path.join(" ")).is_none())
274            .map(|u| u.command.clone())
275            .collect(),
276    }
277}
278
279fn row(c: &Capability, commands: &BTreeMap<&[String], &CommandDoc>) -> Row {
280    let cli = c.exposure.cli.as_ref();
281    // the claim is shown only when the surface answers it; a row that reports a command
282    // clap does not have would be the same lie the findings are there to catch
283    let cli_present = cli
284        .map(|e| {
285            commands
286                .get(e.path.as_slice())
287                .is_some_and(|cmd| cmd.executable)
288        })
289        .unwrap_or(false);
290    Row {
291        id: c.id.to_string(),
292        module: c.module.to_string(),
293        kind: format!("{:?}", c.kind).to_lowercase(),
294        stability: format!("{:?}", c.stability).to_lowercase(),
295        effect: c.execution.effect,
296        cli: cli
297            .filter(|_| cli_present)
298            .map(|e| format!("majordomus {}", e.path.join(" "))),
299        http: c
300            .exposure
301            .http
302            .as_ref()
303            .map(|h| format!("{} {}", h.method.as_str(), h.path)),
304        mcp_tool: c.exposure.mcp.as_ref().and_then(|m| m.tool.clone()),
305        mcp_resource: c
306            .exposure
307            .mcp
308            .as_ref()
309            .and_then(|m| m.resource.as_ref().map(|r| r.uri.clone())),
310        closed: cli.is_none() || cli_present,
311        source: c.provenance.source_path(),
312    }
313}
314
315/// The whole comparison, for one descriptor: `None` when the claim is answered.
316///
317/// [`findings`] maps this over the registry, and the tests drive it directly over one
318/// descriptor, so the rule is proved where it is written rather than by a second copy of it
319/// in the test module.
320fn check(c: &Capability, commands: &BTreeMap<&[String], &CommandDoc>) -> Option<Finding> {
321    let cli = c.exposure.cli.as_ref()?;
322    let claim = format!("majordomus {}", cli.path.join(" "));
323    let finding = |code, detail| Finding {
324        code,
325        capability: c.id.to_string(),
326        projection: Projection::Cli,
327        claim: claim.clone(),
328        detail,
329        source: c.provenance.source_path(),
330        rule: RULE,
331    };
332    match commands.get(cli.path.as_slice()) {
333        None => Some(finding(
334            "CLOSURE_CLI_ABSENT",
335            format!(
336                "the clap declaration has no command `{}`",
337                cli.path.join(" ")
338            ),
339        )),
340        Some(cmd) if !cmd.executable => Some(finding(
341            "CLOSURE_CLI_NOT_RUNNABLE",
342            "the command exists but only groups other commands and cannot be run on its own"
343                .to_string(),
344        )),
345        Some(_) => None,
346    }
347}
348
349/// The clap tree by path-after-`majordomus`, so a claim is looked up rather than searched.
350fn index(tree: &CommandDoc) -> BTreeMap<&[String], &CommandDoc> {
351    tree.flatten()
352        .into_iter()
353        .filter(|c| c.path.len() > 1)
354        .map(|c| (&c.path[1..], c))
355        .collect()
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use crate::capability::model::{
362        Availability, BenchmarkPolicy, CachePolicy, CapabilityId, CapabilityKind, CliExposure,
363        Exposure, ModuleId, Provenance, Stability, Visibility,
364    };
365    use crate::capability::schema::CanonicalSchema;
366
367    /// A descriptor claiming one CLI path, with nothing else that matters here.
368    fn claiming(id: &str, path: &[&str]) -> Capability {
369        let exposure = Exposure {
370            cli: Some(CliExposure {
371                path: path.iter().map(|w| w.to_string()).collect(),
372            }),
373            ..Default::default()
374        };
375        Capability {
376            id: CapabilityId::unchecked(id),
377            module: ModuleId::unchecked(id.split('.').next().unwrap()),
378            kind: CapabilityKind::Query,
379            // Capability grew this field while this test helper was written; it is
380            // classified from the kind everywhere else, and so it is here.
381            execution: crate::capability::ExecutionPolicy::classify(CapabilityKind::Query),
382            skill: None,
383            title: "T".into(),
384            description: "D".into(),
385            input: CanonicalSchema::of::<()>(),
386            output: CanonicalSchema::of::<()>(),
387            provenance: Provenance::Builtin {
388                module: "majordomus_cli::capability::builtin::demo".into(),
389            },
390            availability: Availability::classify(CapabilityKind::Query, &exposure),
391            visibility: Visibility::classify(&exposure),
392            exposure,
393            stability: Stability::Experimental,
394            tags: vec![],
395            benchmark: BenchmarkPolicy::Required,
396            cache: CachePolicy::Disabled,
397        }
398    }
399
400    /// One descriptor against the shipped command line, through the real comparison.
401    fn check_one(c: &Capability, tree: &CommandDoc) -> Option<Finding> {
402        check(c, &index(tree))
403    }
404
405    #[test]
406    fn a_claim_the_command_line_does_not_answer_is_a_finding() {
407        let tree = crate::cli::tree();
408        let bogus = claiming("demo.ghost", &["ghost", "walks"]);
409        let found = check_one(&bogus, &tree).expect("an absent command is a finding");
410        assert_eq!(found.code, "CLOSURE_CLI_ABSENT");
411        assert_eq!(found.capability, "demo.ghost");
412        assert_eq!(found.claim, "majordomus ghost walks");
413        assert!(found.detail.contains("ghost walks"));
414        // the finding names the file to edit, repository-relative
415        assert_eq!(
416            found.source,
417            "apps/majordomus-cli/src/capability/builtin/demo.rs"
418        );
419        assert!(!found.source.starts_with('/'), "never a machine path");
420
421        // the rendering a person reads carries every part of the repair, and no machine path
422        let shown = found.to_string();
423        for part in [
424            "CLOSURE_CLI_ABSENT",
425            "demo.ghost",
426            "majordomus ghost walks",
427            "apps/majordomus-cli/src/capability/builtin/demo.rs",
428            Projection::Cli.declaration(),
429            RULE,
430            "majordomus capabilities projections --unmet",
431        ] {
432            assert!(
433                shown.contains(part),
434                "the finding does not show {part}:\n{shown}"
435            );
436        }
437    }
438
439    #[test]
440    fn a_claim_on_a_group_that_cannot_be_run_is_a_finding() {
441        let tree = crate::cli::tree();
442        // the group is found in the tree rather than named here: a command that requires a
443        // subcommand today may gain a default tomorrow, and this test should follow it
444        let group = tree
445            .flatten()
446            .into_iter()
447            .find(|c| !c.executable && c.path.len() > 1)
448            .expect("the command line has at least one command that only groups others");
449        let words: Vec<&str> = group.path[1..].iter().map(String::as_str).collect();
450        let found = check_one(&claiming("demo.group", &words), &tree)
451            .unwrap_or_else(|| panic!("{} should be unrunnable", group.command()));
452        assert_eq!(found.code, "CLOSURE_CLI_NOT_RUNNABLE");
453        assert!(found.detail.contains("cannot be run"));
454    }
455
456    #[test]
457    fn a_claim_the_command_line_answers_is_no_finding() {
458        let tree = crate::cli::tree();
459        let runnable: Vec<Vec<&str>> = tree
460            .flatten()
461            .into_iter()
462            .filter(|c| c.executable && c.path.len() > 1)
463            .map(|c| c.path[1..].iter().map(String::as_str).collect())
464            .collect();
465        assert!(
466            !runnable.is_empty(),
467            "the command line has runnable commands"
468        );
469        for words in &runnable {
470            assert!(
471                check_one(&claiming("demo.ok", words), &tree).is_none(),
472                "majordomus {} is runnable and must not be a finding",
473                words.join(" ")
474            );
475        }
476    }
477
478    #[test]
479    fn the_declaration_this_crate_ships_is_closed() {
480        let registry = CapabilityRegistry::builder()
481            .with_modules(crate::capability::builtin::modules())
482            .build()
483            .expect("the builtin registry builds");
484        let tree = crate::cli::tree();
485        let unmet = findings(&registry, &tree);
486        assert!(
487            unmet.is_empty(),
488            "every declared CLI exposure must be a runnable command:\n{}",
489            unmet
490                .iter()
491                .map(|f| f.to_string())
492                .collect::<Vec<_>>()
493                .join("\n")
494        );
495    }
496
497    #[test]
498    fn the_matrix_reports_a_row_per_capability_and_hides_nothing_it_cannot_answer() {
499        let registry = CapabilityRegistry::builder()
500            .with_modules(crate::capability::builtin::modules())
501            .build()
502            .unwrap();
503        let tree = crate::cli::tree();
504        let m = matrix(&registry, &tree);
505        assert_eq!(m.rows.len(), registry.len());
506        assert!(m.findings.is_empty());
507        assert!(
508            m.rows.iter().all(|r| r.closed),
509            "the shipped tree is closed"
510        );
511        // ordering is by id and therefore stable across runs
512        let ids: Vec<&str> = m.rows.iter().map(|r| r.id.as_str()).collect();
513        let mut sorted = ids.clone();
514        sorted.sort_unstable();
515        assert_eq!(ids, sorted);
516        // every row that claims a command names one a person can actually type
517        let commands = index(&tree);
518        for r in m.rows.iter().filter(|r| r.cli.is_some()) {
519            let words: Vec<String> = r.cli.as_ref().unwrap()["majordomus ".len()..]
520                .split(' ')
521                .map(str::to_string)
522                .collect();
523            assert!(
524                commands.get(words.as_slice()).is_some_and(|c| c.executable),
525                "{} reports a command that cannot be run",
526                r.id
527            );
528        }
529    }
530
531    #[test]
532    fn a_row_never_reports_a_command_line_the_claim_does_not_have() {
533        // the row is what every surface renders, so a broken claim must not appear there as
534        // a working command: the finding says it is broken, the row says nothing
535        let tree = crate::cli::tree();
536        let commands = index(&tree);
537        let bogus = claiming("demo.ghost", &["ghost", "walks"]);
538        let r = row(&bogus, &commands);
539        assert_eq!(r.id, "demo.ghost");
540        assert_eq!(r.module, "demo");
541        assert_eq!(r.kind, "query");
542        assert_eq!(
543            r.cli, None,
544            "a claim clap cannot answer is not shown as a command"
545        );
546        assert!(!r.closed, "and the row says the claim is unmet");
547        assert_eq!(r.http, None);
548        assert_eq!(r.mcp_tool, None);
549        assert_eq!(r.mcp_resource, None);
550
551        // one that resolves is reported, and closed
552        let ok = claiming("demo.ok", &["scope"]);
553        let r = row(&ok, &commands);
554        assert_eq!(r.cli.as_deref(), Some("majordomus scope"));
555        assert!(r.closed);
556    }
557
558    #[test]
559    fn the_matrix_serialises_as_the_shape_every_transport_answers() {
560        // HTTP, MCP and the command line all render this value; the fields a client reads
561        // are asserted here rather than in three transport tests
562        let tree = crate::cli::tree();
563        let commands = index(&tree);
564        let m = Matrix {
565            rows: vec![row(&claiming("demo.ok", &["scope"]), &commands)],
566            findings: vec![],
567            unbacked: vec!["majordomus serve".to_string()],
568            unclassified: vec![],
569        };
570        let v = serde_json::to_value(&m).expect("the matrix serialises");
571        assert_eq!(v["rows"][0]["id"], "demo.ok");
572        assert_eq!(v["rows"][0]["cli"], "majordomus scope");
573        assert_eq!(v["rows"][0]["closed"], true);
574        assert_eq!(v["unbacked"][0], "majordomus serve");
575        assert!(
576            v["unclassified"].as_array().expect("an array").is_empty(),
577            "the classified half is carried on the wire too, and is empty here"
578        );
579        // absent exposures are omitted rather than rendered as null, so a client can ask
580        // "is there an http route" without distinguishing null from missing
581        assert!(v["rows"][0].get("http").is_none());
582        // findings are not part of the wire shape: `--unmet` filters rows, and the failure
583        // text belongs to `capabilities validate`
584        assert!(v.get("findings").is_none());
585    }
586
587    #[test]
588    fn the_projection_names_itself_and_the_file_it_is_declared_in() {
589        assert_eq!(Projection::Cli.name(), "cli");
590        assert_eq!(Projection::Cli.declaration(), crate::cli::DECLARATION);
591        assert!(
592            !Projection::Cli.declaration().starts_with('/'),
593            "repository-relative, never a machine path"
594        );
595    }
596
597    #[test]
598    fn the_debt_is_the_commands_no_capability_claims() {
599        let registry = CapabilityRegistry::builder()
600            .with_modules(crate::capability::builtin::modules())
601            .build()
602            .unwrap();
603        let tree = crate::cli::tree();
604        let debt = unbacked(&registry, &tree);
605        // the process commands have no capability behind them and are expected here
606        for expected in ["majordomus serve", "majordomus mcp", "majordomus generate"] {
607            assert!(
608                debt.iter().any(|u| u.command == expected),
609                "{expected} should be in the inventory"
610            );
611        }
612        // and a command that *is* a capability is not
613        assert!(
614            !debt.iter().any(|u| u.command == "majordomus why list"),
615            "a derived command is not debt"
616        );
617        // the root is never debt: it groups and is nobody's exposure
618        assert!(!debt.iter().any(|u| u.command == "majordomus"));
619        let mut sorted = debt.clone();
620        sorted.sort();
621        assert_eq!(
622            debt, sorted,
623            "the inventory is ordered, so a report is stable"
624        );
625    }
626
627    #[test]
628    fn every_command_no_capability_claims_says_why_it_is_local() {
629        // The inventory is allowed to be large — a process command has nothing to derive
630        // from — but not silent. A command that is neither a capability's projection nor
631        // classified in cli::LOCAL is an operation that left the API by accident, which is
632        // what `scripts/ci/projection-check` refuses from this same value.
633        let registry = CapabilityRegistry::builder()
634            .with_modules(crate::capability::builtin::modules())
635            .build()
636            .unwrap();
637        let m = matrix(&registry, &crate::cli::tree());
638        assert!(
639            !m.unbacked.is_empty(),
640            "the process commands are always in the inventory"
641        );
642        assert!(
643            m.unclassified.is_empty(),
644            "unclassified commands: {:?}",
645            m.unclassified
646        );
647    }
648}