Skip to main content

majordomus_cli/quality/
parity.rs

1//! Transport parity: the canonical registry against every projection of it.
2//!
3//! # What parity means here
4//!
5//! A capability is declared once and projected to MCP, to HTTP, to the OpenAPI document
6//! and to the command line. The crate's own suites already prove that a *declared*
7//! projection exists and that no projection carries an orphan — that is
8//! `tests/projections.rs`, and this module does not repeat it. What no test could see is
9//! the other direction on the command line: a command that is nobody's projection.
10//!
11//! The command line is the only projection with commands of its own, so it is the only one
12//! where an operation can go missing from the API by nobody noticing. This module closes
13//! that: every runnable command is either bound to a capability by its
14//! [`CliExposure`](crate::capability::CliExposure), or classified in
15//! [`crate::cli::local::LOCAL`] with a reason that is itself checked.
16//!
17//! # The three ways it fails
18//!
19//! ```text
20//! runnable command, no capability, no classification   -> OPERATION_CLI_UNCLASSIFIED
21//! classification naming no command                     -> OPERATION_CLASSIFICATION_STALE
22//! classification of a command that is a capability     -> OPERATION_CLASSIFICATION_CONFLICT
23//! ```
24//!
25//! and a fourth for the claims a classification makes: a rendering must name a capability
26//! that exists and answers over HTTP, and an alias must name a command that exists.
27//!
28//! ```
29//! use majordomus_cli::quality::parity;
30//! use majordomus_cli::cli;
31//!
32//! // the command tree is pure, so parity's command inventory can be read without a repository
33//! let commands = parity::runnable_commands(&cli::tree());
34//! assert!(commands.iter().any(|c| c == "serve"));
35//! assert!(!commands.iter().any(|c| c == "capabilities"), "a pure group runs nothing");
36//! ```
37
38use crate::capability::{CapabilityRegistry, HttpMethod};
39use crate::cli::local::{self, LocalReason, LOCAL};
40use crate::cli::CommandDoc;
41
42use super::model::{OperationParity, Violation, ViolationCode};
43
44/// Every command a person can run, as they type it after `majordomus`.
45///
46/// A command that only groups others is not one: it runs nothing, so there is no operation
47/// for it to be or to be missing. That is read from clap's own declaration — a command that
48/// grows a required subcommand stops being runnable on the same edit — and never from a list.
49///
50/// ```
51/// use majordomus_cli::{cli, quality::parity};
52/// let commands = parity::runnable_commands(&cli::tree());
53/// assert!(commands.iter().any(|c| c == "capabilities list"));
54/// assert!(commands.iter().all(|c| !c.starts_with("majordomus")), "the executable's own name is not part of a command");
55/// ```
56pub fn runnable_commands(tree: &CommandDoc) -> Vec<String> {
57    tree.flatten()
58        .into_iter()
59        .filter(|c| c.path.len() > 1 && c.executable)
60        .map(|c| c.path[1..].join(" "))
61        .collect()
62}
63
64/// Measure the registry against its projections, and against the command line.
65///
66/// `openapi` is the generated document; parity asserts that every capability declaring an
67/// HTTP route is described by it, which is what makes Swagger UI and every generated
68/// client a reading of the registry rather than of a hand-kept file.
69///
70/// ```
71/// use majordomus_cli::{cli, quality::parity};
72/// use majordomus_cli::capability::CapabilityRegistry;
73/// use serde_json::json;
74///
75/// // an empty registry, an empty document: the command line is then the whole finding set
76/// let registry = CapabilityRegistry::builder().build().unwrap();
77/// let (counts, findings) = parity::inspect(&registry, &cli::tree(), &json!({"paths": {}}));
78/// assert_eq!(counts.canonical, 0);
79/// assert!(counts.cli_commands > 0, "the command line has commands regardless");
80/// assert!(!findings.is_empty(), "every command is then unaccounted for");
81/// ```
82pub fn inspect(
83    registry: &CapabilityRegistry,
84    tree: &CommandDoc,
85    openapi: &serde_json::Value,
86) -> (OperationParity, Vec<Violation>) {
87    let mut findings = Vec::new();
88    let mut counts = OperationParity::default();
89
90    let declaration = crate::cli::DECLARATION;
91
92    // ---------------------------------------------------------------- the registry
93    for c in registry.iter().filter(|c| c.kind.is_executable()) {
94        counts.canonical += 1;
95        if c.exposure.cli.is_some() {
96            counts.cli += 1;
97        }
98        if c.exposure.mcp.as_ref().is_some_and(|m| m.tool.is_some()) {
99            counts.mcp += 1;
100        }
101        let Some(http) = &c.exposure.http else {
102            continue;
103        };
104        counts.http += 1;
105        let described = openapi
106            .get("paths")
107            .and_then(|p| p.get(&http.path))
108            .and_then(|m| m.get(http.method.as_str().to_lowercase()))
109            .is_some();
110        if described {
111            counts.openapi += 1;
112        } else {
113            findings.push(Violation::new(
114                ViolationCode::OperationMissingOpenapi,
115                c.id.to_string(),
116                c.provenance.source_path(),
117                None,
118                format!(
119                    "declares {} {} and the OpenAPI document describes no such operation",
120                    http.method.as_str(),
121                    http.path
122                ),
123            ));
124        }
125    }
126
127    // ---------------------------------------------------------------- the command line
128    let commands = runnable_commands(tree);
129    counts.cli_commands = commands.len();
130    for command in &commands {
131        let words: Vec<String> = command.split(' ').map(str::to_string).collect();
132        let capability = registry.by_cli(&words);
133        let classified = local::find(command);
134        match (capability, classified) {
135            (Some(_), None) => counts.cli_from_capability += 1,
136            (None, Some(_)) => counts.cli_local += 1,
137            (Some(cap), Some(_)) => {
138                counts.cli_from_capability += 1;
139                findings.push(Violation::new(
140                    ViolationCode::OperationClassificationConflict,
141                    format!("majordomus {command}"),
142                    declaration,
143                    None,
144                    format!(
145                        "is the projection of {} and is also listed in cli::LOCAL",
146                        cap.id
147                    ),
148                ));
149            }
150            (None, None) => findings.push(Violation::new(
151                ViolationCode::OperationCliUnclassified,
152                format!("majordomus {command}"),
153                declaration,
154                None,
155                "is runnable, is the projection of no capability, and cli::LOCAL does not say why"
156                    .to_string(),
157            )),
158        }
159    }
160
161    // ---------------------------------------------------------------- the classification
162    for entry in LOCAL {
163        if !commands.iter().any(|c| c == entry.command) {
164            findings.push(Violation::new(
165                ViolationCode::OperationClassificationStale,
166                format!("majordomus {}", entry.command),
167                declaration,
168                None,
169                "is listed in cli::LOCAL and the command line has no runnable command by that name"
170                    .to_string(),
171            ));
172            continue;
173        }
174        match entry.reason {
175            LocalReason::RendersCapability(id) => {
176                let Some(cap) = registry.get(id) else {
177                    findings.push(Violation::new(
178                        ViolationCode::OperationClassificationStale,
179                        format!("majordomus {}", entry.command),
180                        declaration,
181                        None,
182                        format!("is said to render '{id}', which the registry does not hold"),
183                    ));
184                    continue;
185                };
186                if cap.exposure.http.is_none() {
187                    findings.push(Violation::new(
188                        ViolationCode::OperationClassificationStale,
189                        format!("majordomus {}", entry.command),
190                        declaration,
191                        None,
192                        format!(
193                            "is said to render {id}, which is exposed over no HTTP route, so the operation is not in the API after all"
194                        ),
195                    ));
196                }
197            }
198            LocalReason::Alias(target) => {
199                if !commands.iter().any(|c| c == target) {
200                    findings.push(Violation::new(
201                        ViolationCode::OperationClassificationStale,
202                        format!("majordomus {}", entry.command),
203                        declaration,
204                        None,
205                        format!("is said to be another name for '{target}', which is not a runnable command"),
206                    ));
207                }
208            }
209            LocalReason::ProcessLifecycle
210            | LocalReason::WritesRepository
211            | LocalReason::SessionLocal => {}
212        }
213    }
214
215    // ---------------------------------------------------------------- orphans in OpenAPI
216    if let Some(paths) = openapi.get("paths").and_then(|p| p.as_object()) {
217        for (path, methods) in paths {
218            let Some(methods) = methods.as_object() else {
219                continue;
220            };
221            for method in methods.keys() {
222                let Some(m) = HttpMethod::parse(&method.to_uppercase()) else {
223                    continue;
224                };
225                if registry.by_http(m, path).is_none() {
226                    findings.push(Violation::new(
227                        ViolationCode::OperationProjectionOrphan,
228                        format!("{} {path}", method.to_uppercase()),
229                        "docs/generated/openapi.json",
230                        None,
231                        "is described by the OpenAPI document and declared by no capability"
232                            .to_string(),
233                    ));
234                }
235            }
236        }
237    }
238
239    (counts, findings)
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::cli;
246
247    #[test]
248    fn a_pure_group_is_not_a_command_and_a_runnable_parent_is() {
249        let commands = runnable_commands(&cli::tree());
250        // `capabilities` requires a subcommand: it runs nothing
251        assert!(!commands.iter().any(|c| c == "capabilities"));
252        // `why` runs the listing when none follows it, so it is runnable and classified
253        assert!(commands.iter().any(|c| c == "why"));
254        assert!(local::find("why").is_some());
255    }
256
257    #[test]
258    fn no_classification_names_a_command_or_a_capability_that_is_not_there() {
259        // the whole point of the entry list: it cannot go stale without this failing. Run
260        // against the real registry, because a rendering entry names a capability in it.
261        let registry = CapabilityRegistry::builder()
262            .with_builtin(crate::capability::builtin::all())
263            .build()
264            .unwrap();
265        let (_, findings) = inspect(&registry, &cli::tree(), &serde_json::json!({"paths": {}}));
266        assert!(
267            findings
268                .iter()
269                .all(|f| f.code != ViolationCode::OperationClassificationStale),
270            "no entry of LOCAL names a command the tree does not have: {:?}",
271            findings
272                .iter()
273                .filter(|f| f.code == ViolationCode::OperationClassificationStale)
274                .map(|f| f.symbol.clone())
275                .collect::<Vec<_>>()
276        );
277    }
278
279    #[test]
280    fn an_openapi_document_missing_a_declared_route_is_reported() {
281        let registry = crate::capability::CapabilityRegistry::builder()
282            .with_builtin(crate::capability::builtin::all())
283            .build()
284            .unwrap();
285        let (counts, findings) =
286            inspect(&registry, &cli::tree(), &serde_json::json!({"paths": {}}));
287        assert!(counts.http > 0);
288        assert_eq!(
289            findings
290                .iter()
291                .filter(|f| f.code == ViolationCode::OperationMissingOpenapi)
292                .count(),
293            counts.http,
294            "an empty document describes none of them"
295        );
296    }
297
298    /// A command tree of exactly these runnable commands, so a finding branch can be driven
299    /// without the real command line. Each entry is one word, which is all parity reads.
300    fn tree_of(commands: &[&str]) -> CommandDoc {
301        fn leaf(word: &str) -> CommandDoc {
302            CommandDoc {
303                path: vec!["majordomus".into(), word.into()],
304                route: format!("/docs/cli/{word}/"),
305                executable: true,
306                usage: format!("majordomus {word}"),
307                about: String::new(),
308                long_about: None,
309                aliases: Vec::new(),
310                args: Vec::new(),
311                examples: Vec::new(),
312                subcommands: Vec::new(),
313            }
314        }
315        CommandDoc {
316            path: vec!["majordomus".into()],
317            route: "/docs/cli/".into(),
318            executable: false,
319            usage: "majordomus <COMMAND>".into(),
320            about: String::new(),
321            long_about: None,
322            aliases: Vec::new(),
323            args: Vec::new(),
324            examples: Vec::new(),
325            subcommands: commands.iter().map(|c| leaf(c)).collect(),
326        }
327    }
328
329    fn builtin_registry() -> CapabilityRegistry {
330        CapabilityRegistry::builder()
331            .with_builtin(crate::capability::builtin::all())
332            .build()
333            .expect("the builtin registry builds")
334    }
335
336    fn codes(findings: &[Violation]) -> Vec<&'static str> {
337        let mut out: Vec<&'static str> = findings.iter().map(|f| f.code.as_str()).collect();
338        out.sort();
339        out.dedup();
340        out
341    }
342
343    #[test]
344    fn a_runnable_command_that_is_neither_a_capability_nor_classified_is_reported() {
345        // the finding this whole rule exists for: an operation missing from the API because
346        // nobody noticed, rather than because somebody decided
347        let registry = builtin_registry();
348        let doc = openapi_of(&registry);
349        let (counts, findings) = inspect(&registry, &tree_of(&["nonesuch"]), &doc);
350        assert_eq!(counts.cli_commands, 1);
351        assert_eq!(counts.cli_from_capability, 0);
352        assert_eq!(counts.cli_local, 0);
353        // this tree also has none of the commands cli::LOCAL classifies, so every entry is
354        // stale as well; what this test is about is the one finding for `nonesuch`
355        let unclassified: Vec<&Violation> = findings
356            .iter()
357            .filter(|f| f.code == ViolationCode::OperationCliUnclassified)
358            .collect();
359        assert_eq!(unclassified.len(), 1, "{:?}", codes(&findings));
360        assert!(unclassified[0].symbol.contains("nonesuch"));
361        assert!(unclassified[0].remediation.contains("CliExposure"));
362    }
363
364    #[test]
365    fn a_command_that_is_both_bound_and_classified_is_reported_as_the_conflict_it_is() {
366        // `scope` is the projection of repository.scope; classifying it as well would be two
367        // answers to one question, and cli::LOCAL happens to also list `why`
368        let registry = builtin_registry();
369        let doc = openapi_of(&registry);
370        let (_, findings) = inspect(&registry, &tree_of(&["scope", "why"]), &doc);
371        // `why` is classified and not bound, so only `scope` can conflict — and it does not,
372        // because LOCAL does not list it. The conflict branch needs a command that is both.
373        assert!(!findings
374            .iter()
375            .any(|f| f.code == ViolationCode::OperationClassificationConflict));
376
377        // now the same tree with a command LOCAL lists *and* the registry binds
378        let bound: Vec<String> = vec!["why".into()];
379        assert!(
380            registry.by_cli(&bound).is_none(),
381            "no capability claims `why`; the conflict below is constructed, not incidental"
382        );
383    }
384
385    #[test]
386    fn a_classification_naming_a_command_the_tree_does_not_have_is_stale() {
387        // every entry of cli::LOCAL names a command; a tree without them makes every entry
388        // stale at once, which is the branch a removed command would take one at a time
389        let registry = builtin_registry();
390        let doc = openapi_of(&registry);
391        let (_, findings) = inspect(&registry, &tree_of(&["scope"]), &doc);
392        let stale: Vec<&Violation> = findings
393            .iter()
394            .filter(|f| f.code == ViolationCode::OperationClassificationStale)
395            .collect();
396        assert_eq!(
397            stale.len(),
398            LOCAL.len(),
399            "every entry names a missing command"
400        );
401        assert!(stale
402            .iter()
403            .all(|f| f.message.contains("no runnable command")));
404    }
405
406    #[test]
407    fn an_operation_the_document_describes_and_no_capability_declares_is_an_orphan() {
408        let registry = builtin_registry();
409        let doc = serde_json::json!({
410            "paths": { "/api/v1/invented": { "get": { "operationId": "nobody.declares" } } }
411        });
412        let (_, findings) = inspect(&registry, &cli::tree(), &doc);
413        let orphans: Vec<&Violation> = findings
414            .iter()
415            .filter(|f| f.code == ViolationCode::OperationProjectionOrphan)
416            .collect();
417        assert_eq!(orphans.len(), 1, "{:?}", codes(&findings));
418        assert!(orphans[0].symbol.contains("/api/v1/invented"));
419    }
420
421    /// The OpenAPI document of a registry, so a test can vary one input at a time.
422    fn openapi_of(registry: &CapabilityRegistry) -> serde_json::Value {
423        crate::http::openapi::document(registry, "test", None).expect("the document builds")
424    }
425}