Skip to main content

aidoc_core/
lint.rs

1//! Lint stage: report doc-coverage issues on top of an indexed workspace
2//! (and, for size-related lints, on top of the generated artifacts).
3//!
4//! Lints are advisory by default. In strict mode every warning is
5//! promoted to an error and the pipeline is expected to exit with code
6//! 2. The `strict` flag lives on [`crate::Config`] so both CLI and MCP
7//! callers see the same behaviour.
8//!
9//! Adding a new lint means: (1) pick a stable `code` (kebab-case,
10//! short), (2) emit `Diagnostic` from [`lint`] with an actionable
11//! message, (3) if the check is expensive, feature-gate it behind a
12//! flag on `Config` and document the default.
13
14use rustdoc_types::{ItemEnum, Module, Visibility};
15
16use crate::config::Config;
17use crate::generate::Artifact;
18use crate::index::{IndexedCrate, IndexedWorkspace};
19
20/// Severity of a single [`Diagnostic`].
21///
22/// The lint stage only produces `Warn` values directly; strict mode
23/// promotes them to `Error` in [`lint`] so callers don't have to
24/// re-implement that logic.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Level {
27    /// Informational; does not cause a non-zero exit code on its own.
28    Warn,
29    /// Blocking; typically maps to CLI exit code 2 and MCP verdict = fail.
30    Error,
31}
32
33/// A single lint finding.
34#[derive(Debug, Clone)]
35pub struct Diagnostic {
36    /// Severity of this finding.
37    pub level: Level,
38    /// Stable machine-readable identifier (e.g. `"missing-crate-doc"`).
39    /// Callers filter and route by this code.
40    pub code: &'static str,
41    /// Human-readable location, e.g. `crate:aidoc-core`,
42    /// `module:aidoc_core::config`, or `artifact:llms-full.txt`.
43    pub location: String,
44    /// One-sentence description of the finding, ideally actionable.
45    pub message: String,
46}
47
48/// Soft byte-size ceiling for `llms-full.txt`. Anything larger than
49/// this triggers a warning: LLM context budgets tend to be tight and
50/// bloated dumps are usually a sign that we're emitting more than the
51/// public API surface warrants.
52///
53/// This is a soft ceiling, not a hard cap; the artifact is emitted
54/// either way.
55pub const LLMS_FULL_SOFT_MAX_BYTES: usize = 512 * 1024;
56
57/// Minimum number of non-empty lines a crate-root `//!` block must have
58/// before it counts as "an actual narrative" instead of a stub. Missing
59/// or shorter narratives produce warnings.
60pub const CRATE_ROOT_MIN_NARRATIVE_LINES: usize = 5;
61
62/// Run every lint against `workspace` and `artifacts`, applying the
63/// strict-mode promotion from `config`.
64///
65/// Returns diagnostics in emission order: crate-level lints first (in
66/// discovery order), then artifact-level lints. Callers that want a
67/// deterministic display order can sort by `(location, code)`.
68pub fn lint(
69    workspace: &IndexedWorkspace,
70    artifacts: &[Artifact],
71    config: &Config,
72) -> Vec<Diagnostic> {
73    let mut diagnostics = Vec::new();
74
75    for krate in &workspace.crates {
76        lint_crate(krate, &mut diagnostics);
77    }
78
79    lint_artifacts(artifacts, &mut diagnostics);
80
81    if config.strict {
82        for diag in &mut diagnostics {
83            if diag.level == Level::Warn {
84                diag.level = Level::Error;
85            }
86        }
87    }
88
89    diagnostics
90}
91
92fn lint_crate(krate: &IndexedCrate, diagnostics: &mut Vec<Diagnostic>) {
93    match krate.root_module_doc.as_deref() {
94        None => {
95            diagnostics.push(Diagnostic {
96                level: Level::Warn,
97                code: "missing-crate-doc",
98                location: format!("crate:{}", krate.name),
99                message: format!(
100                    "crate `{}` has no crate-root `//!` doc block; readers land in an empty index.md",
101                    krate.name
102                ),
103            });
104        }
105        Some(doc) => {
106            let non_empty_lines = doc.lines().filter(|line| !line.trim().is_empty()).count();
107            if non_empty_lines < CRATE_ROOT_MIN_NARRATIVE_LINES {
108                diagnostics.push(Diagnostic {
109                    level: Level::Warn,
110                    code: "short-crate-doc",
111                    location: format!("crate:{}", krate.name),
112                    message: format!(
113                        "crate `{name}` root doc is only {found} non-empty lines; \
114                         consider expanding to at least {min} lines of narrative",
115                        name = krate.name,
116                        found = non_empty_lines,
117                        min = CRATE_ROOT_MIN_NARRATIVE_LINES,
118                    ),
119                });
120            }
121        }
122    }
123
124    lint_module_docs(krate, diagnostics);
125}
126
127fn lint_module_docs(krate: &IndexedCrate, diagnostics: &mut Vec<Diagnostic>) {
128    let index = &krate.crate_data.index;
129
130    let Some(root_item) = index.get(&krate.crate_data.root) else {
131        return;
132    };
133    let ItemEnum::Module(root_module) = &root_item.inner else {
134        return;
135    };
136
137    let crate_prefix = krate.name.replace('-', "_");
138    walk_lint_modules(index, root_module, &crate_prefix, &krate.name, diagnostics);
139}
140
141fn walk_lint_modules(
142    index: &std::collections::HashMap<rustdoc_types::Id, rustdoc_types::Item>,
143    module: &Module,
144    prefix: &str,
145    crate_name: &str,
146    diagnostics: &mut Vec<Diagnostic>,
147) {
148    for child_id in &module.items {
149        let Some(child) = index.get(child_id) else {
150            continue;
151        };
152        if !matches!(child.visibility, Visibility::Public) {
153            continue;
154        }
155        let ItemEnum::Module(child_module) = &child.inner else {
156            continue;
157        };
158        let Some(name) = child.name.as_deref() else {
159            continue;
160        };
161
162        let path = format!("{prefix}::{name}");
163
164        if child.docs.as_deref().is_none_or(str::is_empty) {
165            diagnostics.push(Diagnostic {
166                level: Level::Warn,
167                code: "missing-module-doc",
168                location: format!("module:{path}"),
169                message: format!(
170                    "public module `{path}` in crate `{crate_name}` has no `//!` doc block"
171                ),
172            });
173        }
174
175        walk_lint_modules(index, child_module, &path, crate_name, diagnostics);
176    }
177}
178
179fn lint_artifacts(artifacts: &[Artifact], diagnostics: &mut Vec<Diagnostic>) {
180    if let Some(full) = artifacts.iter().find(|a| a.path == "llms-full.txt")
181        && full.body.len() > LLMS_FULL_SOFT_MAX_BYTES
182    {
183        diagnostics.push(Diagnostic {
184            level: Level::Warn,
185            code: "llms-full-too-large",
186            location: "artifact:llms-full.txt".to_owned(),
187            message: format!(
188                "llms-full.txt is {actual} bytes (> soft cap {cap}); \
189                 consider trimming crate-root narratives or excluding low-signal crates",
190                actual = full.body.len(),
191                cap = LLMS_FULL_SOFT_MAX_BYTES,
192            ),
193        });
194    }
195}