1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
// SPDX-License-Identifier: GPL-3.0-only
//! `doctrine doctor` — corpus health scan.
//!
//! Runs all ten checks (id integrity, relation integrity, spec FK, memory health,
//! lifecycle, raw label, TOML parse, prose citation, agent conformance, spawn seam
//! symmetry) over the corpus, renders them
//! grouped by category with severity, and exits non-zero on any error-severity
//! finding. The `--json` flag emits a flat JSON array of finding objects.
use std::io::Write;
use std::path::PathBuf;
use crate::finding::{Category, Finding};
pub(crate) fn run_doctor(
path: Option<PathBuf>,
json: bool,
verbose: bool,
with_terminal_slices: bool,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let mut findings: Vec<Finding> = Vec::new();
// #1 — Id Integrity (Error)
findings.extend(crate::integrity::id_integrity_findings_native(&root)?);
// #2 — Relation Integrity (Error)
let rel_lines = crate::relation_graph::validate_relations(&root)?;
findings.extend(Finding::from_lines(Category::RelationIntegrity, rel_lines));
// #3 — Spec Foreign Key (Error)
let fk_lines = crate::spec::spec_fk_findings(&root);
findings.extend(Finding::from_lines(Category::SpecFk, fk_lines));
// #4 — Memory Health (Error)
let today = crate::clock::today();
let mem_findings = match crate::memory::collect_memories(&root) {
Ok(memories) => crate::memory::memory_health_findings_native(&root, &memories, &today),
Err(_) => Vec::new(),
};
findings.extend(mem_findings);
// #5 — Lifecycle (Warning)
findings.extend(crate::backlog::lifecycle_findings(&root));
// #6 — Raw Label (Warning)
findings.extend(crate::doctor_checks::raw_label_findings(&root));
// #7 — TOML Parse (Warning)
findings.extend(crate::doctor_checks::toml_parse_findings(&root));
// #8 — Prose Citation (Warning)
// IMP-252: JSON mode always gets the full dataset; path exclusions only
// apply to non-JSON, non-verbose terminal output.
let prose_verbose = json || verbose;
// Terminal-slice exclusion (IMP-252 follow-up): skip closed/abandoned
// slices by default; --with-terminal-slices or --json overrides.
let include_terminal = json || with_terminal_slices;
findings.extend(crate::doctor_checks::prose_cite_findings(
&root,
prose_verbose,
include_terminal,
));
// #9 — Agent Conformance (Error) — SL-198 RSK-225: worker tool-surface is a
// jail wall; scan authored agent-defs under install/agents + .doctrine/agents.
findings.extend(crate::doctor_checks::agent_conformance_findings(&root));
// #10 — Spawn Seam Symmetry (Error) — SL-206 design §5.6 I1: the unjail
// nomination/gate deny-set must be ONE list covering EVERY harness spawn seam
// (`Agent`, `Workflow`); a drift here is an open escalation hole, not a lint nit.
findings.extend(crate::doctor_checks::spawn_seam_symmetry_findings(&root));
if json {
// Reuse the shared list envelope `{kind, rows}` (design §5.4 / F7) so the
// doctor's --json matches the rest of the CLI's report surfaces (RV-185 F-5).
let json_out = crate::listing::json_envelope("doctor", &findings)?;
writeln!(std::io::stdout(), "{json_out}")?;
} else {
let rendered = crate::finding::render_findings(&findings, verbose);
writeln!(std::io::stdout(), "{rendered}")?;
}
let has_errors = findings
.iter()
.any(|f| f.category.severity() == crate::finding::Severity::Error);
if has_errors {
anyhow::bail!("{} finding(s)", findings.len());
}
Ok(())
}
#[cfg(test)]
#[expect(clippy::unwrap_used, reason = "test code")]
mod tests {
//! VT-1 (SL-198 PHASE-04): the #9 conformance lint fails an unmarked worker
//! def, a marked def with an extra writable MCP token, and a bare
//! `mcp__doctrine` grant; it passes the pinned dispatch-worker (marked, only
//! `mcp__doctrine__worker_commit`). Scan roots are the authored trees
//! `install/agents` + `.doctrine/agents`, never the installed `.claude` copy.
use crate::doctor_checks::agent_conformance_findings;
use crate::finding::{Category, Severity};
fn write_def(root: &std::path::Path, rel: &str, body: &str) {
let path = root.join("install/agents").join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, body).unwrap();
}
#[test]
fn conformance_lint_pins_worker_tool_surface() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
// PASS: the pinned dispatch-worker — marked, only the sanctioned token.
write_def(
root,
"claude/dispatch-worker.md",
"---\nname: dispatch-worker\ndoctrine-role: worker\ntools: Read, Edit, Write, Bash, Grep, Glob, mcp__doctrine__worker_commit\n---\nbody\n",
);
// FAIL a: unmarked def (deny-by-default).
write_def(
root,
"claude/unmarked.md",
"---\nname: unmarked\ntools: Read, Edit\n---\nbody\n",
);
// FAIL b: marked def with an extra writable MCP token.
write_def(
root,
"claude/extra.md",
"---\nname: extra\ndoctrine-role: worker\ntools: Read, mcp__doctrine__worker_commit, mcp__slack__post\n---\nbody\n",
);
// FAIL c: bare mcp__doctrine server grant.
write_def(
root,
"claude/bare.md",
"---\nname: bare\ndoctrine-role: worker\ntools: Read, mcp__doctrine\n---\nbody\n",
);
let findings = agent_conformance_findings(root);
assert_eq!(
findings.len(),
3,
"three defs violate, one passes: {findings:?}"
);
assert!(
findings
.iter()
.all(|f| f.category == Category::AgentConformance)
);
assert!(
findings
.iter()
.all(|f| f.category.severity() == Severity::Error)
);
let joined: String = findings.iter().filter_map(|f| f.entity.clone()).collect();
assert!(joined.contains("unmarked.md"));
assert!(joined.contains("extra.md"));
assert!(joined.contains("bare.md"));
assert!(
!joined.contains("dispatch-worker.md"),
"pinned worker must pass"
);
}
}