Skip to main content

spec_driven_docs/services/
verifier.rs

1//! Offline instance verification.
2//!
3//! Reads the manifest, compares every recorded projection to the disk, holds
4//! the managed block to its recorded hash, checks the block's `sdd` entries
5//! resolve, scans for duplicate rule IDs, and compares the instance's canon
6//! version to this binary's. Everything is local: no network, no canon
7//! checkout, no ambient tools. What gets recorded is the installer's
8//! business; this only holds the record to the disk.
9
10use 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/// What a verification run reports.
20#[derive(Debug, Default)]
21pub struct VerifyReport {
22    /// Every line to print, failures and notes alike, in order.
23    pub lines: Vec<String>,
24    /// How many lines are failures.
25    pub failures: usize,
26    /// How many managed files are missing, symlinked, or byte-drifted.
27    pub managed_drift: usize,
28    /// How many adopted files await reconciliation.
29    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
94/// The manifest must record every projection the profile declares — an
95/// omitted record is an owned file the verifier would silently stop
96/// holding. Applies when the instance is at this binary's version, in
97/// whichever of the two layouts the record claims: the installed layout,
98/// or the canon's self-manifest layout where every owned file sits at its
99/// authored path. Either way the complete expected set for that layout is
100/// required, so no hand edit of the record can shrink what is held —
101/// masquerading as the other layout only changes which files must exist
102/// and hash clean.
103/// The declaration and the managed block must say the same thing.
104///
105/// The block is rendered from the declaration, so a difference means the
106/// project edited the declaration and nothing reached the block. That is a
107/// real disagreement between two artifacts that claim to agree, not drift in
108/// a file the project owns, so it fails and names the command that fixes it.
109///
110/// A declaration that does not parse fails once here rather than once from
111/// every always-run gate.
112fn 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    // The documentation block carries the writing-style route the
122    // declaration selects, so a stale route is the same disagreement as a
123    // stale hook filter, and the same command repairs it.
124    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    // Measure from the host stripped of its block, which is what the
154    // installer measures. With the block still in place the first item under
155    // `repos:` is the block's own, and the depth read back differs.
156    let Ok((base, _)) = crate::domain::marker::split_block(&host) else {
157        return;
158    };
159    // The indentation is the host file's, measured the same way the
160    // installer measures it. Rendering with the default would report every
161    // instance whose `repos:` items sit at another depth.
162    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    // Compare per gate rather than over the whole region. A region may
174    // carry hooks this renderer never emits, and this repository's own does,
175    // so byte equality would report every such instance as stale.
176    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(&region);
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    // Every installed instance carries both integration blocks. The canon's
256    // own layout carries the pre-commit block by hand; its root AGENTS.md is
257    // release-kit-owned and outside this projection.
258    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
276/// Verify an installed instance offline.
277///
278/// # Errors
279///
280/// [`AppError::ManifestMissing`] / [`AppError::ManifestInvalid`] when the
281/// record itself cannot be trusted, and I/O errors when the disk cannot be
282/// read; recorded-versus-disk differences are reported, not raised.
283pub 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
362/// The debt file must be readable, and it must be the only debt format.
363///
364/// A file no budget gate can read fails once here rather than once from
365/// every budget gate. The legacy list alone is a note naming its migration:
366/// it still works, and nothing about it is wrong until the day the project
367/// wants a ceiling that only shrinks.
368fn 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
382/// The marker pair a host file's managed region uses.
383fn 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(&region, 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}