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::paths::{AGENTS_DIGEST_PATH, HOOKS_CONFIG_PATH};
17use crate::domain::version::CanonVersion;
18use crate::error::AppError;
19use crate::release::ReleaseBundle;
20
21/// What a verification run reports.
22#[derive(Debug, Default)]
23pub struct VerifyReport {
24    /// Every line to print, failures and notes alike, in order.
25    pub lines: Vec<String>,
26    /// How many lines are failures.
27    pub failures: usize,
28    /// How many managed files are missing, symlinked, or byte-drifted.
29    pub managed_drift: usize,
30    /// How many adopted files await reconciliation.
31    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
96/// The manifest must record every projection the profile declares — an
97/// omitted record is an owned file the verifier would silently stop
98/// holding. Applies when the instance is at this binary's version, in
99/// whichever of the two layouts the record claims: the installed layout,
100/// or the canon's self-manifest layout where every owned file sits at its
101/// authored path. Either way the complete expected set for that layout is
102/// required, so no hand edit of the record can shrink what is held —
103/// masquerading as the other layout only changes which files must exist
104/// and hash clean.
105/// The declaration and the managed block must say the same thing.
106///
107/// The block is rendered from the declaration, so a difference means the
108/// project edited the declaration and nothing reached the block. That is a
109/// real disagreement between two artifacts that claim to agree, not drift in
110/// a file the project owns, so it fails and names the command that fixes it.
111///
112/// A declaration that does not parse fails once here rather than once from
113/// every always-run gate.
114fn 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    // The documentation block carries the writing-style route the
124    // declaration selects, so a stale route is the same disagreement as a
125    // stale hook filter, and the same command repairs it.
126    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    // Measure from the host stripped of its block, which is what the
156    // installer measures. With the block still in place the first item under
157    // `repos:` is the block's own, and the depth read back differs.
158    let Ok((base, _)) = crate::domain::marker::split_block(&host) else {
159        return;
160    };
161    // The indentation is the host file's, measured the same way the
162    // installer measures it. Rendering with the default would report every
163    // instance whose `repos:` items sit at another depth.
164    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    // Compare per gate rather than over the whole region. A region may
176    // carry hooks this renderer never emits, and this repository's own does,
177    // so byte equality would report every such instance as stale.
178    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(&region);
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    // Every installed instance carries both integration blocks. The canon's
264    // own layout carries the pre-commit block by hand; its root AGENTS.md is
265    // release-kit-owned and outside this projection.
266    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
284/// Verify an installed instance offline.
285///
286/// # Errors
287///
288/// [`AppError::ManifestMissing`] / [`AppError::ManifestInvalid`] when the
289/// record itself cannot be trusted, and I/O errors when the disk cannot be
290/// read; recorded-versus-disk differences are reported, not raised.
291pub 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
371/// The debt file must be readable, and it must be the only debt format.
372///
373/// A file no budget gate can read fails once here rather than once from
374/// every budget gate. The legacy list alone is a note naming its migration:
375/// it still works, and nothing about it is wrong until the day the project
376/// wants a ceiling that only shrinks.
377fn 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
391/// The marker pair a host file's managed region uses.
392fn 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(&region, 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}