Skip to main content

rto_spec/
tool_check.rs

1//! The **read-only `check` document** the model-facing tool surfaces return.
2//!
3//! `roteiro check` is a gate: it rebuilds the graph from a tree and exits
4//! non-zero on drift, and the pre-commit hook reads that exit code. Over a tool
5//! surface there is no exit code — there is only a document — so the one thing
6//! this module exists to guarantee is that **`0 violations` and `did not run` are
7//! never the same document**. See [`ToolCheck`].
8//!
9//! It shares its violation rule with the gate rather than restating it:
10//! [`crate::authored_layer`] reads the same authored file set `build_graph` reads,
11//! and [`crate::validate`] is literally the function [`crate::run`] calls. What
12//! this module adds is the two preconditions a read-only caller must satisfy
13//! before either is meaningful, and an honest answer when it cannot.
14
15use std::path::Path;
16
17use rto_graph::{GraphSource, Repo, Store};
18use serde::Serialize;
19
20use crate::check::{CheckReport, validate};
21use crate::layer::authored_layer;
22
23/// Schema tag for the tool-surface `check` document.
24pub const TOOL_CHECK_SCHEMA: &str = "roteiro.check/v1";
25
26/// The gate verdict, as a value rather than an exit code.
27///
28/// `NotRun` is a third state on purpose. A caller that only tests
29/// `violations.is_empty()` would read a check that never ran as a clean
30/// repository; making the absence of a verdict its own value means that caller
31/// has to notice.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
33#[serde(rename_all = "kebab-case")]
34pub enum Gate {
35    /// The check ran and found no drift (`roteiro check` would exit 0).
36    Pass,
37    /// The check ran and found drift (`roteiro check` would exit non-zero).
38    Fail,
39    /// The check did **not** run. `report` is absent; see `not_run_reason`.
40    NotRun,
41}
42
43/// Which graph the verdict describes, so a reader can tell what was compared.
44#[derive(Debug, Clone, Serialize)]
45pub struct CheckedAgainst {
46    /// The tree the authored layer was read from — always `"committed"` here.
47    pub source: &'static str,
48    /// The `HEAD` tree id the derived graph was synced from and the authored
49    /// layer was parsed from. They are equal by construction: an inequality is
50    /// the `NotRun` case below, not a caveat on a verdict.
51    pub tree: String,
52}
53
54/// The tool-surface `check` result.
55///
56/// # Why `report` is an `Option` and not an empty `CheckReport`
57///
58/// The whole hazard this shape addresses is that a check which could not run
59/// looks exactly like a clean one once it is serialised. `CheckReport` has
60/// `violations: Vec<Violation>`, and an empty vector is the *good* answer — so a
61/// not-run result must not produce a `CheckReport` at all. It doesn't: `report`
62/// is `None` and is skipped entirely in JSON, so a consumer reaching for
63/// `violations` finds nothing rather than nothing-wrong. `gate` says the same
64/// thing in one word for a consumer that reads only that.
65#[derive(Debug, Clone, Serialize)]
66pub struct ToolCheck {
67    /// Stable schema tag ([`TOOL_CHECK_SCHEMA`]).
68    pub schema: &'static str,
69    /// The verdict — `pass`, `fail`, or `not-run`. Always present.
70    pub gate: Gate,
71    /// The full drift report. **Absent unless the check actually ran.**
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub report: Option<CheckReport>,
74    /// What was compared. Absent unless the check actually ran.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub checked_against: Option<CheckedAgainst>,
77    /// Why the check could not run. Present exactly when `gate` is `not-run`.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub not_run_reason: Option<String>,
80}
81
82impl ToolCheck {
83    /// A `not-run` document carrying `reason`. No `report`, by construction.
84    fn not_run(reason: String) -> Self {
85        Self {
86            schema: TOOL_CHECK_SCHEMA,
87            gate: Gate::NotRun,
88            report: None,
89            checked_against: None,
90            not_run_reason: Some(reason),
91        }
92    }
93}
94
95/// Run `roteiro check`'s drift validation **read-only** against `store`, with the
96/// authored layer read from the committed `HEAD` tree of the repository at
97/// `root`.
98///
99/// Nothing is written: [`authored_layer`] reads git and parses text, and
100/// [`validate`] only queries the store. The `authored` edges the gate would weave
101/// in are discarded here — that write is [`crate::run`]'s, and it is the only
102/// thing this path leaves out.
103///
104/// # The two preconditions, and why a failure is `not-run` rather than a verdict
105///
106/// 1. **There must be a repository on disk.** A pre-opened or in-memory store has
107///    no tree to read an authored layer from. (This is the same rule
108///    `debt_ignore_for` follows for a project's `roteiro.toml`: substituting some
109///    other repository's files would answer confidently about the wrong thing.)
110///
111/// 2. **The graph must match `HEAD`.** `check` compares the authored layer
112///    against the derived layer, so a graph synced from an older tree yields
113///    broken links for symbols that exist and misses ones that do not — drift
114///    reported against a repository state that is nobody's. `Store::sync_state`
115///    records the `HEAD` tree id of the last committed sync; a worktree or index
116///    sync records a `…:dirty:…`/`index:…` marker instead, so neither can be
117///    mistaken for a clean committed tree.
118///
119/// Both refuse rather than degrade, for the reason the CLI already gives for
120/// declining to serve gates from a stale graph: for a gate, a
121/// stale-but-unrefreshed graph is a confident wrong verdict, and a hard refusal
122/// is the honest answer.
123///
124/// # Why the persisted graph gives the same verdict as a fresh gate run
125///
126/// A served store holds more than the derived layer: `build_graph` applies the
127/// authored layer and then re-applies the import layers, so `adr:` nodes,
128/// `authored` edges and imported nodes are all already in it, whereas the CLI
129/// gate validates *before* `reapply_imports`. That difference cannot move the
130/// verdict, because the imports are namespaced away from everything a link can
131/// name: an `[[…]]` link resolves only to `sym:<lang>:<path>#<name>` or
132/// `file:<path>` (`resolve_target`) and a `@rto:` annotation only to `adr:<id>`,
133/// while imports produce `graphify:<id>` and `lat:<path>`. The already-applied
134/// authored nodes are the ones `run` would have applied from the same tree — the
135/// `HEAD`-match precondition above is what makes "the same tree" true — and
136/// [`validate`]'s overlay reaches the same answer for them either way.
137///
138/// # Errors
139/// Returns [`StoreError`](rto_graph::StoreError) if querying the store fails.
140/// Everything that can go wrong *outside* the store — no repository, no `HEAD`,
141/// an unreadable tree, a stale graph — is a `not-run` document rather than an
142/// error, because those are answers the caller must be able to read.
143pub fn tool_check(store: &Store, root: Option<&Path>) -> Result<ToolCheck, rto_graph::StoreError> {
144    let Some(root) = root else {
145        return Ok(ToolCheck::not_run(
146            "this project has no repository on disk to read the authored layer from \
147             (the graph was opened directly), so `check` cannot run"
148                .to_owned(),
149        ));
150    };
151    let repo = match Repo::discover(root) {
152        Ok(repo) => repo,
153        Err(e) => {
154            return Ok(ToolCheck::not_run(format!(
155                "cannot open the repository at {}: {e}",
156                root.display()
157            )));
158        }
159    };
160    let head = match repo.head_tree_id() {
161        Ok(tree) => tree,
162        Err(e) => {
163            return Ok(ToolCheck::not_run(format!(
164                "cannot read the HEAD tree of {}: {e}",
165                root.display()
166            )));
167        }
168    };
169    match store.sync_state()? {
170        Some(synced) if synced == head => {}
171        Some(synced) => {
172            return Ok(ToolCheck::not_run(format!(
173                "the graph was synced from `{synced}` but HEAD is `{head}`, so a drift \
174                 verdict would describe neither tree — run `roteiro sync` (or restart \
175                 the server) and ask again"
176            )));
177        }
178        None => {
179            return Ok(ToolCheck::not_run(
180                "the graph records no synced tree, so there is nothing to check the \
181                 authored layer against — run `roteiro sync`"
182                    .to_owned(),
183            ));
184        }
185    }
186
187    let layer = match authored_layer(&repo, GraphSource::Committed) {
188        Ok(layer) => layer,
189        Err(e) => {
190            return Ok(ToolCheck::not_run(format!(
191                "cannot read the authored layer from {}: {e}",
192                root.display()
193            )));
194        }
195    };
196    let mut validation = validate(store, &layer.docs, &layer.blueprints, &layer.annotations)?;
197    // A malformed ADR is drift, exactly as it is for the CLI gate.
198    validation.report.violations.extend(layer.malformed);
199
200    let gate = if validation.report.has_violations() {
201        Gate::Fail
202    } else {
203        Gate::Pass
204    };
205    Ok(ToolCheck {
206        schema: TOOL_CHECK_SCHEMA,
207        gate,
208        report: Some(validation.report),
209        checked_against: Some(CheckedAgainst {
210            source: GraphSource::Committed.as_str(),
211            tree: head,
212        }),
213        not_run_reason: None,
214    })
215}
216
217#[cfg(test)]
218mod tests {
219    use super::{Gate, TOOL_CHECK_SCHEMA, ToolCheck, tool_check};
220    use rto_graph::{FactSet, Node, NodeKind, Repo, Store};
221    use std::path::{Path, PathBuf};
222
223    /// A git repo at `dir` with `files` committed. Returns its `HEAD` tree id.
224    fn repo_with(dir: &Path, files: &[(&str, &str)]) -> String {
225        std::fs::remove_dir_all(dir).ok();
226        std::fs::create_dir_all(dir).unwrap();
227        let git = |args: &[&str]| {
228            let status = std::process::Command::new("git")
229                .args([
230                    "-c",
231                    "init.defaultBranch=main",
232                    "-c",
233                    "user.email=t@example.com",
234                    "-c",
235                    "user.name=T",
236                    "-c",
237                    "commit.gpgsign=false",
238                ])
239                .args(args)
240                .current_dir(dir)
241                .status()
242                .expect("run git");
243            assert!(status.success(), "git {args:?} failed in {}", dir.display());
244        };
245        git(&["init", "-q"]);
246        for (path, body) in files {
247            let full = dir.join(path);
248            std::fs::create_dir_all(full.parent().unwrap()).unwrap();
249            std::fs::write(&full, body).unwrap();
250        }
251        git(&["add", "-A"]);
252        git(&["commit", "-q", "-m", "seed"]);
253        Repo::discover(dir).unwrap().head_tree_id().unwrap()
254    }
255
256    fn tmp(name: &str) -> PathBuf {
257        std::env::temp_dir().join(format!("rto-toolcheck-{name}-{}", std::process::id()))
258    }
259
260    /// A store holding `facts`, recorded as synced from `tree` — the shape a
261    /// committed `roteiro sync` leaves behind.
262    fn synced(facts: &FactSet, tree: &str) -> Store {
263        let mut store = Store::open_in_memory().expect("store");
264        store.rebuild(facts, Some(tree)).expect("rebuild");
265        store
266    }
267
268    const ADR_OK: &str = "---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# ADR-0001\n\n                          ## Design\n\nUses [[src/store.rs#Store]].\n";
269    const ADR_BROKEN: &str = "---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# ADR-0001\n\n                              ## Design\n\nUses [[src/store.rs#Ghost]].\n";
270
271    fn derived() -> FactSet {
272        FactSet::new()
273            .with_node(Node::new("file:src/store.rs", NodeKind::File, "store.rs"))
274            .with_node(Node::new(
275                "sym:rust:src/store.rs#Store",
276                NodeKind::Struct,
277                "Store",
278            ))
279    }
280
281    #[test]
282    fn a_clean_repository_passes_and_says_what_it_checked() {
283        let dir = tmp("pass");
284        let tree = repo_with(
285            &dir,
286            &[
287                ("src/store.rs", "pub struct Store;\n"),
288                ("docs/adr/0001.md", ADR_OK),
289            ],
290        );
291        let store = synced(&derived(), &tree);
292
293        let out = tool_check(&store, Some(&dir)).expect("tool_check");
294        assert_eq!(out.gate, Gate::Pass, "{out:?}");
295        let report = out.report.expect("a check that ran has a report");
296        assert_eq!(report.adrs, 1);
297        assert_eq!(report.links_ok, 1, "{:?}", report.violations);
298        assert!(report.violations.is_empty(), "{:?}", report.violations);
299        let against = out.checked_against.expect("checked_against");
300        assert_eq!(against.source, "committed");
301        assert_eq!(against.tree, tree);
302        assert!(out.not_run_reason.is_none());
303
304        std::fs::remove_dir_all(&dir).ok();
305    }
306
307    #[test]
308    fn drift_fails_the_gate_and_is_reported_in_full() {
309        let dir = tmp("fail");
310        let tree = repo_with(
311            &dir,
312            &[
313                ("src/store.rs", "pub struct Store;\n"),
314                ("docs/adr/0001.md", ADR_BROKEN),
315            ],
316        );
317        let store = synced(&derived(), &tree);
318
319        let out = tool_check(&store, Some(&dir)).expect("tool_check");
320        assert_eq!(out.gate, Gate::Fail, "{out:?}");
321        let report = out.report.expect("report");
322        assert_eq!(report.violations.len(), 1, "{:?}", report.violations);
323        assert_eq!(
324            report.violations[0].kind,
325            crate::ViolationKind::BrokenLink,
326            "{:?}",
327            report.violations
328        );
329
330        std::fs::remove_dir_all(&dir).ok();
331    }
332
333    /// The read-only path must leave the store exactly as it found it — no
334    /// authored edges woven, no ADR structure applied. That write belongs to the
335    /// gate ([`crate::run`]) and to nothing on a tool surface.
336    #[test]
337    fn checking_writes_nothing_to_the_store() {
338        let dir = tmp("readonly");
339        let tree = repo_with(
340            &dir,
341            &[
342                ("src/store.rs", "pub struct Store;\n"),
343                ("docs/adr/0001.md", ADR_OK),
344            ],
345        );
346        let store = synced(&derived(), &tree);
347        let before = (
348            store.node_count().unwrap(),
349            store.edge_count().unwrap(),
350            store.all_edges().unwrap(),
351        );
352
353        let out = tool_check(&store, Some(&dir)).expect("tool_check");
354        assert_eq!(out.gate, Gate::Pass);
355        assert_eq!(store.node_count().unwrap(), before.0, "nodes changed");
356        assert_eq!(store.edge_count().unwrap(), before.1, "edges changed");
357        assert_eq!(store.all_edges().unwrap(), before.2, "edges changed");
358        assert!(
359            store.get_node("adr:0001").unwrap().is_none(),
360            "the ADR node must not have been applied by a read-only check",
361        );
362
363        std::fs::remove_dir_all(&dir).ok();
364    }
365
366    /// A graph synced from another tree describes a repository state that is
367    /// nobody's. Refuse, naming both trees — never a verdict.
368    #[test]
369    fn a_stale_graph_refuses_rather_than_reporting_drift_against_the_wrong_tree() {
370        let dir = tmp("stale");
371        let tree = repo_with(
372            &dir,
373            &[
374                ("src/store.rs", "pub struct Store;\n"),
375                ("docs/adr/0001.md", ADR_OK),
376            ],
377        );
378        let store = synced(&derived(), "0000000000000000000000000000000000000000");
379
380        let out = tool_check(&store, Some(&dir)).expect("tool_check");
381        assert_eq!(out.gate, Gate::NotRun, "{out:?}");
382        assert!(out.report.is_none(), "a not-run check has no report");
383        let reason = out.not_run_reason.expect("reason");
384        assert!(reason.contains(&tree), "names HEAD's tree: {reason}");
385        assert!(
386            reason.contains("0000000"),
387            "names the synced tree: {reason}"
388        );
389
390        std::fs::remove_dir_all(&dir).ok();
391    }
392
393    #[test]
394    fn a_project_with_no_repository_reports_not_run_and_no_report() {
395        let store = Store::open_in_memory().expect("store");
396        let out = tool_check(&store, None).expect("tool_check");
397        assert_eq!(out.gate, Gate::NotRun);
398        assert!(out.report.is_none(), "a not-run check has no report");
399        assert!(
400            out.not_run_reason
401                .as_deref()
402                .is_some_and(|r| r.contains("no repository on disk")),
403            "{:?}",
404            out.not_run_reason
405        );
406    }
407
408    /// A store that records no synced tree cannot be checked against one. It is
409    /// reachable from a real session: `--sync-on-access` opens a project's graph
410    /// lazily, and a graph that has never synced is empty, not clean.
411    #[test]
412    fn an_unsynced_graph_refuses_rather_than_reporting_a_clean_repository() {
413        let dir = tmp("unsynced");
414        repo_with(&dir, &[("src/store.rs", "pub struct Store;\n")]);
415        let store = Store::open_in_memory().expect("store");
416
417        let out = tool_check(&store, Some(&dir)).expect("tool_check");
418        assert_eq!(out.gate, Gate::NotRun, "{out:?}");
419        assert!(
420            out.not_run_reason
421                .as_deref()
422                .is_some_and(|r| r.contains("no synced tree")),
423            "{:?}",
424            out.not_run_reason
425        );
426        assert!(out.report.is_none());
427
428        std::fs::remove_dir_all(&dir).ok();
429    }
430
431    /// The defect this shape exists to prevent: a caller reading `violations`
432    /// must not see an empty list when nothing was checked. The `not-run`
433    /// document must not carry the key at all.
434    #[test]
435    fn a_not_run_document_cannot_be_read_as_zero_violations() {
436        let out = ToolCheck::not_run("nope".to_owned());
437        let json: serde_json::Value = serde_json::to_value(&out).expect("json");
438        assert_eq!(json["schema"], TOOL_CHECK_SCHEMA);
439        assert_eq!(json["gate"], "not-run");
440        assert!(
441            json.get("report").is_none(),
442            "`report` must be absent, not an empty report: {json}"
443        );
444        assert!(
445            json.pointer("/report/violations").is_none(),
446            "`violations` must be unreachable in a not-run document: {json}"
447        );
448        assert!(json.get("checked_against").is_none(), "{json}");
449    }
450}