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