1use crate::capability::{CapabilityRegistry, HttpMethod};
39use crate::cli::local::{self, LocalReason, LOCAL};
40use crate::cli::CommandDoc;
41
42use super::model::{OperationParity, Violation, ViolationCode};
43
44pub 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
64pub 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 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 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 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 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 assert!(!commands.iter().any(|c| c == "capabilities"));
252 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 let registry = CapabilityRegistry::builder()
262 .with_builtin(crate::capability::builtin::all())
263 .build()
264 .unwrap();
265 let (_, findings) = inspect(®istry, &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(®istry, &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 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 let registry = builtin_registry();
348 let doc = openapi_of(®istry);
349 let (counts, findings) = inspect(®istry, &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 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 let registry = builtin_registry();
369 let doc = openapi_of(®istry);
370 let (_, findings) = inspect(®istry, &tree_of(&["scope", "why"]), &doc);
371 assert!(!findings
374 .iter()
375 .any(|f| f.code == ViolationCode::OperationClassificationConflict));
376
377 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 let registry = builtin_registry();
390 let doc = openapi_of(®istry);
391 let (_, findings) = inspect(®istry, &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(®istry, &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 fn openapi_of(registry: &CapabilityRegistry) -> serde_json::Value {
423 crate::http::openapi::document(registry, "test", None).expect("the document builds")
424 }
425}