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 config = target.join(crate::commands::hooks::CONFIG);
122 let Ok(host) = std::fs::read_to_string(&config) else {
123 return;
124 };
125 let Ok((base, _)) = crate::domain::marker::split_block(&host) else {
129 return;
130 };
131 let Ok(indent) = crate::domain::marker::splice_indent(&base) else {
135 return;
136 };
137 let rendered = crate::services::hooks_render::render_block(
138 &crate::services::hooks_render::RenderOptions {
139 docs_root: manifest.docs_root.to_string(),
140 indent,
141 declaration,
142 ..crate::services::hooks_render::RenderOptions::default()
143 },
144 );
145 let expected = crate::services::hooks_render::selectors(&rendered);
149 let Some(region) = crate::domain::marker::block_region(&host) else {
150 return;
151 };
152 let found = crate::services::hooks_render::selectors(®ion);
153 for (id, wanted) in &expected {
154 let Some(actual) = found.get(id) else {
155 continue;
156 };
157 if actual != wanted {
158 report.fail(format!(
159 "FAIL the wiring for {id} in {} does not match the declaration; run 'sdd hooks --apply'",
160 crate::commands::hooks::CONFIG
161 ));
162 }
163 }
164}
165
166fn check_projection(manifest: &Manifest, report: &mut VerifyReport) {
167 if manifest.canon_version != CanonVersion::current() {
168 return;
169 }
170 let declaration = manifest.profile.profile();
171 let managed: std::collections::BTreeSet<&str> = manifest
172 .managed_files
173 .iter()
174 .map(|entry| entry.destination.as_str())
175 .collect();
176 let adopted: std::collections::BTreeSet<&str> = manifest
177 .adopted_files
178 .iter()
179 .map(|entry| entry.destination.as_str())
180 .collect();
181
182 let self_layout = declaration
183 .managed
184 .iter()
185 .all(|projection| managed.contains(projection.source));
186
187 let mut expected: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
188 let mut missing: Vec<String> = Vec::new();
189 if self_layout {
190 for projection in declaration.managed {
191 expected.insert(projection.source.to_string());
192 }
193 for file in crate::embedded::SPECS.files() {
194 if let Some(name) = file.path().as_os_str().to_str() {
195 expected.insert(format!("_docs/specs/{name}"));
196 }
197 }
198 for template in crate::domain::profile::CANON_TEMPLATES {
199 expected.insert((*template).to_string());
200 }
201 for destination in &expected {
202 if !managed.contains(destination.as_str()) && !adopted.contains(destination.as_str()) {
203 missing.push(destination.clone());
204 }
205 }
206 } else {
207 for projection in declaration.managed {
208 if !managed.contains(projection.destination) {
209 missing.push(projection.destination.to_string());
210 }
211 }
212 for projection in declaration.adopted {
213 let destination = crate::domain::profile::resolve_destination(
214 projection.destination,
215 manifest.docs_root,
216 );
217 if !adopted.contains(destination.as_str()) {
218 missing.push(destination.to_string());
219 }
220 }
221 }
222 for destination in missing {
223 report.fail(format!(
224 "FAIL manifest omits a declared projection: {destination}"
225 ));
226 }
227 let required: &[&str] = if self_layout {
231 &[".pre-commit-config.yaml"]
232 } else {
233 &[".pre-commit-config.yaml", "AGENTS.md"]
234 };
235 for path in required {
236 if !manifest
237 .integration_blocks
238 .iter()
239 .any(|block| block.path.as_str() == *path)
240 {
241 report.fail(format!(
242 "FAIL manifest records no integration block for {path}"
243 ));
244 }
245 }
246}
247
248pub fn verify(target: &Utf8Path) -> Result<VerifyReport, AppError> {
256 let manifest = read_manifest(target)?;
257 let mut report = VerifyReport::default();
258
259 check_declaration(target, &manifest, &mut report);
260
261 for entry in &manifest.managed_files {
262 let file = target.join(&entry.destination);
263 if reached_through_symlink(target, &entry.destination) {
264 report.managed_drift += 1;
265 report.fail(format!(
266 "FAIL managed file reached through a symlink: {}",
267 entry.destination
268 ));
269 continue;
270 }
271 if !file.is_file() {
272 report.managed_drift += 1;
273 report.fail(format!("FAIL missing managed file: {}", entry.destination));
274 continue;
275 }
276 if sha256_file(&file)? != entry.sha256 {
277 report.managed_drift += 1;
278 report.fail(format!("FAIL managed drift: {}", entry.destination));
279 }
280 }
281
282 for entry in &manifest.adopted_files {
283 let file = target.join(&entry.destination);
284 if reached_through_symlink(target, &entry.destination) {
285 report.fail(format!(
286 "FAIL adopted file reached through a symlink: {}",
287 entry.destination
288 ));
289 continue;
290 }
291 if !file.is_file() {
292 report.fail(format!("FAIL missing adopted file: {}", entry.destination));
293 continue;
294 }
295 if sha256_file(&file)? != entry.sha256 {
296 report.adopted_drift += 1;
297 report.note(format!(
298 "DRIFT adopted file requires reconciliation: {}",
299 entry.destination
300 ));
301 }
302 }
303
304 check_projection(&manifest, &mut report);
305 check_integration(target, &manifest, &mut report)?;
306 check_specs(target, &manifest, &mut report)?;
307
308 let current = CanonVersion::current();
309 if manifest.canon_version > current {
310 report.fail(format!(
311 "FAIL sdd {current} is older than the installed canon {}; upgrade sdd",
312 manifest.canon_version
313 ));
314 } else if manifest.canon_version < current {
315 report.note(format!(
316 "note: sdd {current} is newer than the installed canon {}; run 'sdd upgrade'",
317 manifest.canon_version
318 ));
319 }
320
321 if report.failures == 0 {
322 report.note(format!(
323 "OK spec-driven-docs {} at {target}",
324 manifest.canon_version
325 ));
326 }
327 Ok(report)
328}
329
330fn markers_for(path: &str) -> (&'static str, &'static str) {
332 if path == ".pre-commit-config.yaml" {
333 (marker::BEGIN, marker::END)
334 } else {
335 (marker::AGENTS_BEGIN, marker::AGENTS_END)
336 }
337}
338
339fn check_integration(
340 target: &Utf8Path,
341 manifest: &Manifest,
342 report: &mut VerifyReport,
343) -> Result<(), AppError> {
344 for block in &manifest.integration_blocks {
345 let path = block.path.as_str();
346 let (begin, end) = markers_for(path);
347 let full = target.join(&block.path);
348 if reached_through_symlink(target, &block.path) {
349 report.fail(format!("FAIL {path} reached through a symlink"));
350 continue;
351 }
352 if !full.is_file() {
353 report.fail(format!("FAIL missing integration host: {path}"));
354 continue;
355 }
356 let host = std::fs::read_to_string(&full)?;
357 let begins = host.lines().filter(|line| *line == begin).count();
358 let ends = host.lines().filter(|line| *line == end).count();
359 if begins != 1 {
360 report.fail(format!("FAIL missing managed block: {path}"));
361 continue;
362 }
363 if ends != 1 {
364 report.fail(format!("FAIL malformed managed block: {path}"));
365 continue;
366 }
367 match marker::block_hash_with(&host, begin, end) {
368 Some(present) if present == block.marker_hash => {
369 if path == ".pre-commit-config.yaml"
370 && let Some(region) = marker::block_region_with(&host, begin, end)
371 {
372 check_block_entries(®ion, report);
373 }
374 }
375 _ => report.fail(format!("FAIL managed block tampered: {path}")),
376 }
377 }
378 Ok(())
379}
380
381fn check_specs(
382 target: &Utf8Path,
383 manifest: &Manifest,
384 report: &mut VerifyReport,
385) -> Result<(), AppError> {
386 let specs = target.join(manifest.docs_root.as_str()).join("specs");
387 if specs.is_dir() {
388 let mut counts = std::collections::BTreeMap::new();
389 let mut names: Vec<_> = specs
390 .read_dir_utf8()?
391 .filter_map(Result::ok)
392 .map(|entry| entry.file_name().to_string())
393 .filter(|name| {
394 #[allow(
395 clippy::case_sensitive_file_extension_comparisons,
396 reason = "the corpus convention is lowercase"
397 )]
398 name.ends_with(".md")
399 })
400 .collect();
401 names.sort();
402 for name in names {
403 let text = std::fs::read_to_string(specs.join(name))?;
404 for id in crate::embedded::rule_ids_in(&text) {
405 *counts.entry(id).or_insert(0usize) += 1;
406 }
407 }
408 let duplicated: Vec<String> = counts
409 .into_iter()
410 .filter(|(_, n)| *n > 1)
411 .map(|(id, _)| id)
412 .collect();
413 if !duplicated.is_empty() {
414 report.fail("FAIL duplicate rule ID in local specs");
415 for id in duplicated {
416 report.note(format!("### `{id}`"));
417 }
418 }
419 } else {
420 report.fail(format!(
421 "FAIL missing local specs: {}/specs",
422 manifest.docs_root
423 ));
424 }
425 Ok(())
426}