spec_driven_docs/services/
verifier.rs1use camino::Utf8Path;
11
12use crate::adapters::fs::{DestinationRefusal, check_destination, sha256_file};
13use crate::domain::gate_id::GateId;
14use crate::domain::manifest::{MANIFEST_PATH, Manifest, ManifestParseError};
15use crate::domain::marker;
16use crate::domain::version::CanonVersion;
17use crate::error::AppError;
18
19#[derive(Debug, Default)]
21pub struct VerifyReport {
22 pub lines: Vec<String>,
24 pub failures: usize,
26 pub managed_drift: usize,
28 pub adopted_drift: usize,
30}
31
32impl VerifyReport {
33 fn fail(&mut self, line: impl Into<String>) {
34 self.lines.push(line.into());
35 self.failures += 1;
36 }
37
38 fn note(&mut self, line: impl Into<String>) {
39 self.lines.push(line.into());
40 }
41}
42
43pub(crate) fn read_manifest(target: &Utf8Path) -> Result<Manifest, AppError> {
44 let path = target.join(MANIFEST_PATH);
45 if reached_through_symlink(target, Utf8Path::new(MANIFEST_PATH)) {
46 return Err(AppError::ManifestInvalid(
47 "manifest reached through a symlink".to_string(),
48 ));
49 }
50 if !path.is_file() {
51 return Err(AppError::ManifestMissing(path));
52 }
53 let text = std::fs::read_to_string(&path)?;
54 Manifest::parse(&text).map_err(|error| match error {
55 ManifestParseError::Invalid(detail) => AppError::ManifestInvalid(detail),
56 other => AppError::ManifestInvalid(other.to_string()),
57 })
58}
59
60fn check_block_entries(block: &str, report: &mut VerifyReport) {
61 let mut sdd_entries = 0usize;
62 for line in block.lines() {
63 let Some(entry) = line.trim_start().strip_prefix("entry: ") else {
64 continue;
65 };
66 let words: Vec<&str> = entry.split_whitespace().collect();
67 let Some(gate_position) = words.iter().position(|word| *word == "gate") else {
68 if words.last() == Some(&"verify") {
69 sdd_entries += 1;
70 }
71 continue;
72 };
73 sdd_entries += 1;
74 match words.get(gate_position + 1) {
75 Some(id) if id.parse::<GateId>().is_ok() => {}
76 Some(id) => report.fail(format!(
77 "FAIL managed block entry names an unknown gate: {id}"
78 )),
79 None => report.fail(format!("FAIL managed block entry names no gate: {entry}")),
80 }
81 }
82 if sdd_entries == 0 {
83 report.fail("FAIL managed block wires no sdd entry");
84 }
85}
86
87fn reached_through_symlink(target: &Utf8Path, destination: &Utf8Path) -> bool {
88 matches!(
89 check_destination(target, destination),
90 Err(DestinationRefusal::SymlinkEscape)
91 )
92}
93
94fn check_declaration(target: &Utf8Path, manifest: &Manifest, report: &mut VerifyReport) {
113 let declaration = match crate::domain::instance_config::InstanceConfig::read(target) {
114 Ok(declaration) => declaration,
115 Err(error) => {
116 report.fail(format!("FAIL {error}"));
117 return;
118 }
119 };
120
121 let agents = target.join(crate::commands::hooks::AGENTS);
125 let agents_recorded = manifest
126 .integration_blocks
127 .iter()
128 .any(|block| block.path.as_str() == crate::commands::hooks::AGENTS);
129 if agents_recorded
130 && let Ok(host) = std::fs::read_to_string(&agents)
131 && let Some(region) = crate::domain::marker::block_region_with(
132 &host,
133 crate::domain::marker::AGENTS_BEGIN,
134 crate::domain::marker::AGENTS_END,
135 )
136 {
137 let expected = crate::services::agents_render::render_block(
138 manifest.docs_root.as_str(),
139 &declaration.writing_style,
140 );
141 if region != expected {
142 report.fail(format!(
143 "FAIL the documentation block in {} does not match the declaration; run 'sdd hooks --apply'",
144 crate::commands::hooks::AGENTS
145 ));
146 }
147 }
148
149 let config = target.join(crate::commands::hooks::CONFIG);
150 let Ok(host) = std::fs::read_to_string(&config) else {
151 return;
152 };
153 let Ok((base, _)) = crate::domain::marker::split_block(&host) else {
157 return;
158 };
159 let Ok(indent) = crate::domain::marker::splice_indent(&base) else {
163 return;
164 };
165 let rendered = crate::services::hooks_render::render_block(
166 &crate::services::hooks_render::RenderOptions {
167 docs_root: manifest.docs_root.to_string(),
168 indent,
169 declaration,
170 ..crate::services::hooks_render::RenderOptions::default()
171 },
172 );
173 let expected = crate::services::hooks_render::selectors(&rendered);
177 let Some(region) = crate::domain::marker::block_region(&host) else {
178 return;
179 };
180 let found = crate::services::hooks_render::selectors(®ion);
181 for (id, wanted) in &expected {
182 let Some(actual) = found.get(id) else {
183 continue;
184 };
185 if actual != wanted {
186 report.fail(format!(
187 "FAIL the wiring for {id} in {} does not match the declaration; run 'sdd hooks --apply'",
188 crate::commands::hooks::CONFIG
189 ));
190 }
191 }
192}
193
194fn check_projection(manifest: &Manifest, report: &mut VerifyReport) {
195 if manifest.canon_version != CanonVersion::current() {
196 return;
197 }
198 let declaration = manifest.profile.profile();
199 let managed: std::collections::BTreeSet<&str> = manifest
200 .managed_files
201 .iter()
202 .map(|entry| entry.destination.as_str())
203 .collect();
204 let adopted: std::collections::BTreeSet<&str> = manifest
205 .adopted_files
206 .iter()
207 .map(|entry| entry.destination.as_str())
208 .collect();
209
210 let self_layout = declaration
211 .managed
212 .iter()
213 .all(|projection| managed.contains(projection.source));
214
215 let mut expected: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
216 let mut missing: Vec<String> = Vec::new();
217 if self_layout {
218 for projection in declaration.managed {
219 expected.insert(projection.source.to_string());
220 }
221 for file in crate::embedded::SPECS.files() {
222 if let Some(name) = file.path().as_os_str().to_str() {
223 expected.insert(format!("_docs/specs/{name}"));
224 }
225 }
226 for template in crate::domain::profile::CANON_TEMPLATES {
227 expected.insert((*template).to_string());
228 }
229 for destination in &expected {
230 if !managed.contains(destination.as_str()) && !adopted.contains(destination.as_str()) {
231 missing.push(destination.clone());
232 }
233 }
234 } else {
235 for projection in declaration.managed {
236 if !managed.contains(projection.destination) {
237 missing.push(projection.destination.to_string());
238 }
239 }
240 for projection in declaration.adopted {
241 let destination = crate::domain::profile::resolve_destination(
242 projection.destination,
243 manifest.docs_root,
244 );
245 if !adopted.contains(destination.as_str()) {
246 missing.push(destination.to_string());
247 }
248 }
249 }
250 for destination in missing {
251 report.fail(format!(
252 "FAIL manifest omits a declared projection: {destination}"
253 ));
254 }
255 let required: &[&str] = if self_layout {
259 &[".pre-commit-config.yaml"]
260 } else {
261 &[".pre-commit-config.yaml", "AGENTS.md"]
262 };
263 for path in required {
264 if !manifest
265 .integration_blocks
266 .iter()
267 .any(|block| block.path.as_str() == *path)
268 {
269 report.fail(format!(
270 "FAIL manifest records no integration block for {path}"
271 ));
272 }
273 }
274}
275
276pub fn verify(target: &Utf8Path) -> Result<VerifyReport, AppError> {
284 let manifest = read_manifest(target)?;
285 let mut report = VerifyReport::default();
286
287 check_declaration(target, &manifest, &mut report);
288
289 for entry in &manifest.managed_files {
290 let file = target.join(&entry.destination);
291 if reached_through_symlink(target, &entry.destination) {
292 report.managed_drift += 1;
293 report.fail(format!(
294 "FAIL managed file reached through a symlink: {}",
295 entry.destination
296 ));
297 continue;
298 }
299 if !file.is_file() {
300 report.managed_drift += 1;
301 report.fail(format!("FAIL missing managed file: {}", entry.destination));
302 continue;
303 }
304 if sha256_file(&file)? != entry.sha256 {
305 report.managed_drift += 1;
306 report.fail(format!("FAIL managed drift: {}", entry.destination));
307 }
308 }
309
310 for entry in &manifest.adopted_files {
311 let file = target.join(&entry.destination);
312 if reached_through_symlink(target, &entry.destination) {
313 report.fail(format!(
314 "FAIL adopted file reached through a symlink: {}",
315 entry.destination
316 ));
317 continue;
318 }
319 if !file.is_file() {
320 report.fail(format!("FAIL missing adopted file: {}", entry.destination));
321 continue;
322 }
323 if sha256_file(&file)? != entry.sha256 {
324 report.adopted_drift += 1;
325 report.note(format!(
326 "DRIFT adopted file requires reconciliation: {}",
327 entry.destination
328 ));
329 }
330 }
331
332 check_projection(&manifest, &mut report);
333 check_integration(target, &manifest, &mut report)?;
334 check_specs(target, &manifest, &mut report)?;
335 check_debt(target, &mut report);
336 for reconciliation in crate::services::policy::needed(target, manifest.docs_root)? {
337 report.note(reconciliation.note(manifest.docs_root));
338 }
339
340 let current = CanonVersion::current();
341 if manifest.canon_version > current {
342 report.fail(format!(
343 "FAIL sdd {current} is older than the installed canon {}; upgrade sdd",
344 manifest.canon_version
345 ));
346 } else if manifest.canon_version < current {
347 report.note(format!(
348 "note: sdd {current} is newer than the installed canon {}; run 'sdd upgrade'",
349 manifest.canon_version
350 ));
351 }
352
353 if report.failures == 0 {
354 report.note(format!(
355 "OK spec-driven-docs {} at {target}",
356 manifest.canon_version
357 ));
358 }
359 Ok(report)
360}
361
362fn check_debt(target: &Utf8Path, report: &mut VerifyReport) {
369 use crate::domain::debt::{Debt, LEGACY_DEBT_PATH, Presence};
370 let presence = Presence::at(target);
371 if let Err(error) = Debt::read(target) {
372 report.fail(format!("FAIL {error}"));
373 return;
374 }
375 if presence.legacy {
376 report.note(format!(
377 "note: {LEGACY_DEBT_PATH} is the legacy debt list, which skips a listed chapter instead of holding it to a ceiling; run 'sdd debt migrate --apply'"
378 ));
379 }
380}
381
382fn markers_for(path: &str) -> (&'static str, &'static str) {
384 if path == ".pre-commit-config.yaml" {
385 (marker::BEGIN, marker::END)
386 } else {
387 (marker::AGENTS_BEGIN, marker::AGENTS_END)
388 }
389}
390
391fn check_integration(
392 target: &Utf8Path,
393 manifest: &Manifest,
394 report: &mut VerifyReport,
395) -> Result<(), AppError> {
396 for block in &manifest.integration_blocks {
397 let path = block.path.as_str();
398 let (begin, end) = markers_for(path);
399 let full = target.join(&block.path);
400 if reached_through_symlink(target, &block.path) {
401 report.fail(format!("FAIL {path} reached through a symlink"));
402 continue;
403 }
404 if !full.is_file() {
405 report.fail(format!("FAIL missing integration host: {path}"));
406 continue;
407 }
408 let host = std::fs::read_to_string(&full)?;
409 let begins = host.lines().filter(|line| *line == begin).count();
410 let ends = host.lines().filter(|line| *line == end).count();
411 if begins != 1 {
412 report.fail(format!("FAIL missing managed block: {path}"));
413 continue;
414 }
415 if ends != 1 {
416 report.fail(format!("FAIL malformed managed block: {path}"));
417 continue;
418 }
419 match marker::block_hash_with(&host, begin, end) {
420 Some(present) if present == block.marker_hash => {
421 if path == ".pre-commit-config.yaml"
422 && let Some(region) = marker::block_region_with(&host, begin, end)
423 {
424 check_block_entries(®ion, report);
425 }
426 }
427 _ => report.fail(format!("FAIL managed block tampered: {path}")),
428 }
429 }
430 Ok(())
431}
432
433fn check_specs(
434 target: &Utf8Path,
435 manifest: &Manifest,
436 report: &mut VerifyReport,
437) -> Result<(), AppError> {
438 let specs = target.join(manifest.docs_root.as_str()).join("specs");
439 if specs.is_dir() {
440 let mut counts = std::collections::BTreeMap::new();
441 let mut names: Vec<_> = specs
442 .read_dir_utf8()?
443 .filter_map(Result::ok)
444 .map(|entry| entry.file_name().to_string())
445 .filter(|name| {
446 #[allow(
447 clippy::case_sensitive_file_extension_comparisons,
448 reason = "the corpus convention is lowercase"
449 )]
450 name.ends_with(".md")
451 })
452 .collect();
453 names.sort();
454 for name in names {
455 let text = std::fs::read_to_string(specs.join(name))?;
456 for id in crate::embedded::rule_ids_in(&text) {
457 *counts.entry(id).or_insert(0usize) += 1;
458 }
459 }
460 let duplicated: Vec<String> = counts
461 .into_iter()
462 .filter(|(_, n)| *n > 1)
463 .map(|(id, _)| id)
464 .collect();
465 if !duplicated.is_empty() {
466 report.fail("FAIL duplicate rule ID in local specs");
467 for id in duplicated {
468 report.note(format!("### `{id}`"));
469 }
470 }
471 } else {
472 report.fail(format!(
473 "FAIL missing local specs: {}/specs",
474 manifest.docs_root
475 ));
476 }
477 Ok(())
478}