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