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