Skip to main content

majordomus_cli/capability/builtin/
quality.rs

1//! The `quality` module: what this executable's own public surface is held to, answered
2//! through every transport the surface is measured against.
3//!
4//! This is the subsystem dogfooding itself. `project.rust-public-api-quality` and
5//! `project.operation-transport-parity` are rules about the crate; the measurement of
6//! them is a capability of the crate, declared once here, and therefore reaches the
7//! command line, HTTP, the OpenAPI document, MCP and the Cockpit as projections rather
8//! than as five renderers. A quality subsystem that needed hand-written adapters would be
9//! failing the architecture it exists to enforce.
10//!
11//! The measurement reads the crate's sources from the repository the index was built from.
12//! A repository that carries no Rust crate — the layer installs into plenty of them — is
13//! answered with `measured: false` and the reason, never with a clean report over nothing.
14//!
15//! `quality.rustdoc` holds the tree rustdoc renders from the same crate to the same
16//! inventory: every exported item has its page at the route rustdoc gives it, the tree is
17//! HEAD's, and nothing in it is broken or leaks the machine it was built on. Its subject is
18//! the `rustdoc` web surface's artifact as the topology resolves it — never a path a caller
19//! names — and a tree that is not there is the `no_tree` verdict, exit 12, never a pass.
20//!
21//! ```
22//! use majordomus_cli::capability::builtin::quality::module;
23//! let m = module();
24//! let c = m.capabilities.iter().find(|c| c.capability.id.as_str() == "quality.report").unwrap();
25//! assert_eq!(c.capability.exposure.http.as_ref().unwrap().path, "/api/v1/quality");
26//! assert_eq!(
27//!     c.capability.exposure.cli.as_ref().unwrap().path,
28//!     vec!["quality".to_string(), "report".to_string()]
29//! );
30//! ```
31
32use std::path::{Path, PathBuf};
33
34use schemars::JsonSchema;
35use serde::{Deserialize, Serialize};
36
37use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
38use crate::capability::handler::{CapabilityError, Context};
39use crate::capability::model::{
40    CachePolicy, CliExposure, Exposure, McpExposure, McpResource, Stability,
41};
42use crate::capability::module::ModuleDescriptor;
43use crate::quality::rustdoc::{RustdocFindingKind, RustdocReport, Subject, Tree};
44use crate::quality::{QualityReport, ViolationCode};
45use crate::web::discover::{discover, Runtime, GENERATED_ROOT};
46use crate::{capability, module};
47
48use super::get;
49
50/// The URI under which the report is read as an MCP resource.
51pub const QUALITY_URI: &str = "majordomus://quality";
52
53/// The URI under which the judgement of the rustdoc tree is read as an MCP resource.
54pub const RUSTDOC_URI: &str = "majordomus://quality/rustdoc";
55
56/// Where the crate this executable is built from lives, relative to the repository root.
57/// The one fact the measurement needs that the index does not carry.
58pub const CRATE_DIR: &str = crate::capability::model::CRATE_DIR;
59
60/// The input of `quality.report`: which findings to answer with.
61#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
62pub struct QualityInput {
63    /// Only findings carrying this code (`RUST_PUBLIC_MISSING_EXAMPLE`). All of them when
64    /// absent. A code nothing recognises is refused rather than answered with an empty
65    /// list, so a typo cannot read as a clean report.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub code: Option<String>,
68    /// Only findings whose file path starts with this, repository-relative
69    /// (`apps/majordomus-cli/src/web`). All of them when absent.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub path: Option<String>,
72    /// Answer with the counts and leave the findings out. For a caller that wants the
73    /// state of the crate and not the list of what to do about it.
74    #[serde(default)]
75    pub summary_only: bool,
76    /// Include the findings the ratchet already accepts, which are left out by default.
77    ///
78    /// The default is what a gate wants: only what is new. This is what a person paying the
79    /// debt down wants, and it is what `--write-baseline` must ask for — a baseline written
80    /// from a report that had the baseline applied to it would empty the file, accepting
81    /// nothing and failing on everything the next time it ran.
82    #[serde(default)]
83    pub include_baselined: bool,
84}
85
86impl BenchmarkCases for QualityInput {
87    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
88        // One case, and deliberately one: the cost of this capability is parsing the crate,
89        // which every input pays identically, so a second case would time the same work
90        // again and lengthen every benchmark run for no evidence.
91        vec![NamedCase::new("default", QualityInput::default())]
92    }
93}
94
95/// The answer: the measurement, or the reason there was not one.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
97pub struct QualityAnswer {
98    /// Whether a crate was found and measured. `false` is a complete answer, not a failure:
99    /// the layer installs into repositories that carry no Rust crate, and a doctrine that
100    /// cannot apply is not a violation.
101    pub measured: bool,
102    /// Why nothing was measured, when nothing was.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub reason: Option<String>,
105    /// The measurement. Present and empty-of-findings when `measured` is false, so that a
106    /// consumer reads one shape either way and `measured` is the only question it must ask.
107    pub report: QualityReport,
108    /// Whether the report as filtered leaves nothing blocking. Answered here so that a
109    /// caller over any transport reads the verdict rather than deriving it, and so that the
110    /// command line's exit code and this field can never disagree.
111    pub passes: bool,
112    /// The findings the ratchet accepts because they stood when the rule landed. A finding
113    /// outside this count is what fails the gate.
114    pub baselined: usize,
115}
116
117/// The input of `quality.rustdoc`: which findings to answer with.
118///
119/// There is deliberately no field naming the tree. The subject is the `rustdoc` surface's
120/// artifact as the repository's web topology resolves it, so a caller over HTTP or MCP can
121/// ask about the one tree the repository publishes and about no other directory of the
122/// machine; the command line's `--tree` is a local affordance of a person at their own
123/// terminal, and never reaches this input.
124#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
125pub(crate) struct RustdocInput {
126    /// Only findings of this kind (`missing-page`, `link`, `stale`). All of them when
127    /// absent. The verdict and the counts are never narrowed: a filter that changed the
128    /// verdict would be a way of passing a tree with a missing page.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub kind: Option<RustdocFindingKind>,
131    /// Answer with the verdict, the counts and the module routes, and leave the findings
132    /// out.
133    #[serde(default)]
134    pub summary_only: bool,
135}
136
137impl BenchmarkCases for RustdocInput {
138    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
139        // the cost is the crate's parse and one walk of the tree, which every input pays;
140        // the second case exists so that each optional parameter has an example
141        vec![
142            NamedCase::new("default", RustdocInput::default()),
143            NamedCase::new(
144                "missing-pages-summary",
145                RustdocInput {
146                    kind: Some(RustdocFindingKind::MissingPage),
147                    summary_only: true,
148                },
149            ),
150        ]
151    }
152}
153
154/// The tree `quality.rustdoc` judges: the artifact of the `rustdoc` surface, as the web
155/// topology of the repository at `root` resolves it.
156///
157/// A topology that declares no such surface, or one that cannot be read, is a tree that is
158/// not there — with the reason, which names the producer — and never a tree that is
159/// clean.
160pub(crate) fn rustdoc_tree(root: &Path) -> Tree {
161    use crate::quality::rustdoc::{PRODUCER, SURFACE};
162    let shown = format!("{GENERATED_ROOT}/{SURFACE}");
163    let topology = match discover(root, Runtime::full()) {
164        Ok(t) => t,
165        Err(e) => {
166            let reason =
167                format!("the web topology cannot be read, so '{SURFACE}' cannot be found: {e}");
168            return Tree::Absent { shown, reason };
169        }
170    };
171    let Some(surface) = topology.get(SURFACE) else {
172        return Tree::Absent {
173            shown,
174            reason: format!(
175                "the web topology declares no '{SURFACE}' surface: discovery declares it from the crate, which this repository does not have, so {PRODUCER} has nothing to build"
176            ),
177        };
178    };
179    let Some(artifact) = &surface.artifact else {
180        return Tree::Absent {
181            shown,
182            reason: format!("the '{SURFACE}' surface declares no artifact to judge"),
183        };
184    };
185    Tree::At {
186        dir: root.join(artifact),
187        shown: artifact.to_string_lossy().replace('\\', "/"),
188        mount: Some(surface.mount.as_str().to_string()),
189    }
190}
191
192/// Judge a tree of the repository at `root` against its crate: the one call the capability
193/// and the command line's `--tree` share, so the two cannot judge differently.
194pub(crate) fn judge_rustdoc(root: &Path, tree: Tree) -> Result<RustdocReport, crate::Error> {
195    let dir = crate_dir(root);
196    let head = crate::worktree::git::head_of(root).ok().flatten();
197    crate::quality::rustdoc::judge(&Subject {
198        root,
199        crate_dir: dir.as_deref(),
200        tree,
201        head: head.as_deref(),
202    })
203}
204
205fn rustdoc(ctx: &Context, input: RustdocInput) -> Result<RustdocReport, CapabilityError> {
206    let root = Path::new(&ctx.index.repository.root);
207    let report = judge_rustdoc(root, rustdoc_tree(root))
208        .map_err(|e| CapabilityError::Internal(e.to_string()))?;
209    Ok(report.filtered(input.kind, input.summary_only))
210}
211
212/// The crate directory of a repository, when it has one.
213///
214/// ```
215/// use majordomus_cli::capability::builtin::quality::crate_dir;
216/// let dir = tempfile::tempdir().unwrap();
217/// assert_eq!(crate_dir(dir.path()), None, "a repository with no crate has none");
218/// std::fs::create_dir_all(dir.path().join("apps/majordomus-cli/src")).unwrap();
219/// std::fs::write(dir.path().join("apps/majordomus-cli/Cargo.toml"), "").unwrap();
220/// std::fs::write(dir.path().join("apps/majordomus-cli/src/lib.rs"), "//! x\n").unwrap();
221/// assert!(crate_dir(dir.path()).is_some());
222/// ```
223pub fn crate_dir(root: &Path) -> Option<PathBuf> {
224    let dir = root.join(CRATE_DIR);
225    (dir.join("Cargo.toml").is_file() && dir.join("src/lib.rs").is_file()).then_some(dir)
226}
227
228/// The findings the ratchet accepts, read from the repository's baseline file.
229///
230/// One finding per line, `CODE<tab>path<tab>symbol`, in the order the report sorts them.
231/// A repository with no baseline file accepts none, which is the finished state the rule
232/// describes.
233///
234/// ```
235/// use majordomus_cli::capability::builtin::quality::baseline_keys;
236/// let dir = tempfile::tempdir().unwrap();
237/// assert!(baseline_keys(dir.path()).is_empty(), "no file, no debt accepted");
238/// ```
239pub fn baseline_keys(root: &Path) -> std::collections::BTreeSet<String> {
240    let path = root.join(BASELINE);
241    let Ok(text) = std::fs::read_to_string(path) else {
242        return Default::default();
243    };
244    text.lines()
245        .map(str::trim)
246        .filter(|l| !l.is_empty() && !l.starts_with('#'))
247        .map(str::to_string)
248        .collect()
249}
250
251/// Where the ratchet's accepted findings are recorded, repository-relative.
252pub const BASELINE: &str = ".ai/repo/rust-quality-baseline.txt";
253
254/// The key of one finding in the baseline: its code, its file and its symbol.
255///
256/// The line is deliberately not part of it. A finding that moves down a file when something
257/// above it is edited is the same finding, and a baseline keyed by line would turn every
258/// unrelated edit into new debt.
259///
260/// ```
261/// use majordomus_cli::capability::builtin::quality::baseline_key;
262/// use majordomus_cli::quality::{Violation, ViolationCode};
263/// let v = Violation::new(
264///     ViolationCode::RustPublicMissingExample, "a::b", "src/a.rs", Some(9), "none");
265/// assert_eq!(baseline_key(&v), "RUST_PUBLIC_MISSING_EXAMPLE\tsrc/a.rs\ta::b");
266/// ```
267pub fn baseline_key(violation: &crate::quality::Violation) -> String {
268    format!(
269        "{}\t{}\t{}",
270        violation.code.as_str(),
271        violation.path,
272        violation.symbol
273    )
274}
275
276fn report(ctx: &Context, input: QualityInput) -> Result<QualityAnswer, CapabilityError> {
277    if let Some(code) = &input.code {
278        if !ViolationCode::all().iter().any(|c| c.as_str() == code) {
279            return Err(CapabilityError::InvalidInput(format!(
280                "'{code}' is not a violation code; the codes are: {}",
281                ViolationCode::all()
282                    .iter()
283                    .map(|c| c.as_str())
284                    .collect::<Vec<_>>()
285                    .join(", ")
286            )));
287        }
288    }
289    let root = Path::new(&ctx.index.repository.root);
290    let Some(dir) = crate_dir(root) else {
291        return Ok(QualityAnswer {
292            measured: false,
293            reason: Some(format!(
294                "no Rust crate at {CRATE_DIR}; there is nothing here for this rule to apply to"
295            )),
296            report: QualityReport {
297                schema: crate::quality::SCHEMA.to_string(),
298                target: CRATE_DIR.to_string(),
299                ..QualityReport::default()
300            },
301            passes: true,
302            baselined: 0,
303        });
304    };
305
306    let openapi = crate::http::openapi::document(&ctx.registry, crate::VERSION, None)
307        .map_err(|e| CapabilityError::Internal(e.to_string()))?;
308    let mut report = crate::quality::report::inspect(
309        &dir,
310        CRATE_DIR,
311        &ctx.registry,
312        &crate::cli::tree(),
313        &openapi,
314    )
315    .map_err(|e| CapabilityError::Internal(e.to_string()))?;
316
317    let accepted = baseline_keys(root);
318    let baselined = report
319        .violations
320        .iter()
321        .filter(|v| accepted.contains(&baseline_key(v)))
322        .count();
323    // the verdict is always about what the ratchet does *not* accept, whether or not the
324    // caller asked to see the rest: a report that showed the debt and then failed on it
325    // would make `--include-baselined` a way of failing a build that passes
326    let passes = report
327        .violations
328        .iter()
329        .all(|v| accepted.contains(&baseline_key(v)));
330    if !input.include_baselined {
331        report
332            .violations
333            .retain(|v| !accepted.contains(&baseline_key(v)));
334    }
335
336    if let Some(code) = &input.code {
337        report.violations.retain(|v| v.code.as_str() == code);
338    }
339    if let Some(prefix) = &input.path {
340        report.violations.retain(|v| v.path.starts_with(prefix));
341    }
342    if input.summary_only {
343        report.violations.clear();
344    }
345    Ok(QualityAnswer {
346        measured: true,
347        reason: None,
348        report,
349        passes,
350        baselined,
351    })
352}
353
354/// The module.
355pub fn module() -> ModuleDescriptor {
356    module! {
357        id: "quality",
358        title: "Public API quality",
359        description: "What this executable's own public surface is held to, measured from its syntax tree: documentation that says more than the signature, an executable example on everything that carries behaviour, a module boundary something exercises, and every command of the command line accounted for against the capability registry. The rules are project.rust-public-api-quality and project.operation-transport-parity; this is the measurement of them.",
360        stability: Stability::BehaviorallyVerified,
361        capabilities: [
362            capability! {
363                id: "quality.report",
364                title: "Public API quality report",
365                description: "The crate's exported surface measured against the repository's rules: how many items are documented and exampled, how many modules are documented, exampled and behaviourally tested, how the canonical operations stand against the command line, HTTP, OpenAPI and MCP, and one finding per violation carrying a stable code, the rule that requires it, its file and line, why it matters and what to do about it.",
366                input: QualityInput,
367                output: QualityAnswer,
368                stability: Stability::BehaviorallyVerified,
369                exposure: Exposure {
370                    mcp: Some(McpExposure {
371                        tool: Some("majordomus_quality".into()),
372                        resource: Some(McpResource { uri: QUALITY_URI.into(), name: "quality".into() }),
373                    }),
374                    http: get("/api/v1/quality"),
375                    cli: Some(CliExposure { path: vec!["quality".into(), "report".into()] }),
376                },
377                tags: ["quality", "introspection", "rust"],
378                // the crate is parsed on every call and the sources do not change under a
379                // running process; a short-lived entry keeps a Cockpit page that polls from
380                // reparsing a hundred files each time
381                cache: CachePolicy::Process { max_entries: 8, ttl_seconds: Some(10) },
382                handler: report,
383            },
384            capability! {
385                id: "quality.rustdoc",
386                title: "Rustdoc tree integrity",
387                description: "The crate's rustdoc tree — the rustdoc surface's artifact, as the web topology resolves it — judged against the crate's own inventory of exported items: every item that owns a page has it at the route rustdoc gives it, no item page is left without an item, the tree declares it was built from HEAD, the library's index is present and names the crate, its assets are present, every relative link resolves, and no file names the machine it was built on or carries a credential. Answers the verdict (clean, findings, or no_tree when there is nothing to judge), the counts it joined, every exported module with its page, and one typed finding per defect with the file and what to do.",
388                input: RustdocInput,
389                output: RustdocReport,
390                stability: Stability::BehaviorallyVerified,
391                exposure: Exposure {
392                    mcp: Some(McpExposure {
393                        tool: Some("majordomus_quality_rustdoc".into()),
394                        resource: Some(McpResource { uri: RUSTDOC_URI.into(), name: "quality-rustdoc".into() }),
395                    }),
396                    http: get("/api/v1/quality/rustdoc"),
397                    cli: Some(CliExposure { path: vec!["quality".into(), "rustdoc".into()] }),
398                },
399                tags: ["quality", "introspection", "rust", "documentation"],
400                // one walk of a tree of thousands of files per call; a Cockpit page that
401                // polls reads the same answer for a few seconds instead of walking again
402                cache: CachePolicy::Process { max_entries: 8, ttl_seconds: Some(10) },
403                handler: rustdoc,
404            },
405        ],
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    /// The declaration is the only place the id and the three projection names exist; a
414    /// refactor that dropped one would still compile and every test of the measurement
415    /// itself would still pass.
416    #[test]
417    fn the_declaration_yields_the_projections_it_claims() {
418        let m = module();
419        assert_eq!(m.id.as_str(), "quality");
420        let ids: Vec<&str> = m
421            .capabilities
422            .iter()
423            .map(|e| e.capability.id.as_str())
424            .collect();
425        assert_eq!(ids, ["quality.report", "quality.rustdoc"]);
426        let e = &m.capabilities[0].capability.exposure;
427        assert_eq!(
428            e.mcp.as_ref().and_then(|m| m.tool.as_deref()),
429            Some("majordomus_quality")
430        );
431        assert_eq!(
432            e.mcp
433                .as_ref()
434                .and_then(|m| m.resource.as_ref())
435                .map(|r| r.uri.as_str()),
436            Some(QUALITY_URI)
437        );
438        assert_eq!(
439            e.http.as_ref().map(|h| h.path.as_str()),
440            Some("/api/v1/quality")
441        );
442        assert_eq!(
443            e.cli.as_ref().map(|c| c.path.clone()),
444            Some(vec!["quality".to_string(), "report".to_string()])
445        );
446    }
447
448    #[test]
449    fn a_baseline_key_survives_the_finding_moving_down_its_file() {
450        use crate::quality::{Violation, ViolationCode};
451        let at = |line| {
452            Violation::new(
453                ViolationCode::RustPublicMissingExample,
454                "majordomus_cli::a::b",
455                "apps/majordomus-cli/src/a.rs",
456                Some(line),
457                "none",
458            )
459        };
460        assert_eq!(baseline_key(&at(9)), baseline_key(&at(400)));
461    }
462
463    #[test]
464    fn a_repository_with_no_crate_is_answered_and_never_failed() {
465        let dir = tempfile::tempdir().unwrap();
466        assert_eq!(crate_dir(dir.path()), None);
467        // and the answer's shape is the same one a measured repository gets
468        let answer = QualityAnswer {
469            measured: false,
470            reason: Some("no crate".into()),
471            report: QualityReport::default(),
472            passes: true,
473            baselined: 0,
474        };
475        let json = serde_json::to_value(&answer).unwrap();
476        assert_eq!(json["measured"], false);
477        assert_eq!(json["passes"], true);
478        assert!(json.get("report").is_some(), "one shape either way");
479    }
480
481    /// A repository with the crate, so that whichever source declares the `rustdoc`
482    /// surface — the crate itself, or the producer's declaration — declares it here.
483    fn repository_with_the_crate() -> tempfile::TempDir {
484        let dir = tempfile::tempdir().unwrap();
485        let krate = dir.path().join(CRATE_DIR);
486        std::fs::create_dir_all(krate.join("src")).unwrap();
487        std::fs::write(
488            krate.join("Cargo.toml"),
489            "[package]\nname = \"majordomus-cli\"\n[lib]\nname = \"majordomus_cli\"\n",
490        )
491        .unwrap();
492        std::fs::write(krate.join("src/lib.rs"), "//! Root.\n").unwrap();
493        dir
494    }
495
496    #[test]
497    fn the_rustdoc_tree_is_the_topologys_surface_and_never_a_callers_path() {
498        use crate::quality::rustdoc::{PRODUCER, SURFACE};
499        // no crate, so nothing declares the surface: absent, naming the producer
500        let bare = tempfile::tempdir().unwrap();
501        match rustdoc_tree(bare.path()) {
502            Tree::Absent { shown, reason } => {
503                assert_eq!(shown, format!("{GENERATED_ROOT}/{SURFACE}"));
504                assert!(reason.contains(PRODUCER), "{reason}");
505            }
506            other => panic!("{other:?}"),
507        }
508        // the producer ran: the artifact and the mount are the topology's
509        let repo = repository_with_the_crate();
510        let out = repo.path().join(GENERATED_ROOT).join(SURFACE);
511        std::fs::create_dir_all(&out).unwrap();
512        std::fs::write(
513            out.join("surface.json"),
514            r#"{"schema":"web-surface/v1","id":"rustdoc","mount":"/rustdoc","built_from":"abc"}"#,
515        )
516        .unwrap();
517        match rustdoc_tree(repo.path()) {
518            Tree::At { dir, shown, mount } => {
519                assert_eq!(dir, out);
520                assert_eq!(shown, "target/web/rustdoc");
521                assert_eq!(mount.as_deref(), Some("/rustdoc"));
522            }
523            other => panic!("{other:?}"),
524        }
525    }
526
527    #[test]
528    fn a_repository_without_the_tree_or_the_crate_is_no_tree_and_exits_twelve() {
529        use crate::quality::rustdoc::RustdocVerdict;
530        let bare = tempfile::tempdir().unwrap();
531        let report = judge_rustdoc(bare.path(), rustdoc_tree(bare.path())).unwrap();
532        assert_eq!(report.verdict, RustdocVerdict::NoTree);
533        assert_eq!(report.exit_code(), 12);
534        assert!(report.reason.unwrap().contains("no Rust crate"));
535
536        // the crate, and nothing built: still nothing to judge, and the module routes are
537        // answered because they are the crate's
538        let repo = repository_with_the_crate();
539        let report = judge_rustdoc(repo.path(), rustdoc_tree(repo.path())).unwrap();
540        assert_eq!(report.exit_code(), 12, "{report:?}");
541        assert_eq!(report.krate, "majordomus_cli");
542        assert_eq!(report.modules[0].route, "majordomus_cli/index.html");
543    }
544}