Skip to main content

grex_core/doctor/
mod.rs

1//! `grex doctor` — read-only health checks for a grex workspace.
2//!
3//! The doctor runs three pack-health checks by default and one opt-in
4//! config-lint check. Each check is a separate function returning a
5//! [`CheckResult`]; [`run_doctor`] orchestrates them sequentially and
6//! builds a [`DoctorReport`]. The severity roll-up → process exit code
7//! lives in [`DoctorReport::exit_code`].
8//!
9//! # Safety contract for `--fix`
10//!
11//! `--fix` ONLY heals gitignore drift (re-emit the managed block via
12//! the M5-2 writer). It must NOT touch the manifest (user data) or the
13//! filesystem (user state) or any config file. The contract is
14//! enforced by the private `apply_fixes` helper which dispatches
15//! exclusively on [`CheckKind::GitignoreSync`].
16//!
17//! See `openspec/changes/feat-m7-4-import-doctor-license/spec.md`
18//! §"Sub-scope 2 — `grex doctor`".
19
20use std::collections::{BTreeMap, BTreeSet, HashMap};
21use std::path::{Path, PathBuf};
22
23use crate::fs::gitignore::{read_managed_block, upsert_managed_block, GitignoreError};
24use crate::lockfile::{read_lockfile, LockEntry, LockfileError};
25use crate::manifest::{self, Event, ManifestError, PackState};
26use crate::plugin::pack_type::default_managed_gitignore_patterns;
27
28pub mod scan_undeclared;
29pub use scan_undeclared::{scan_undeclared, ScanError, UndeclaredRepo};
30
31const GITIGNORE_EXT_KEY: &str = "x-gitignore";
32
33/// Which check produced this finding.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35pub enum CheckKind {
36    /// Manifest JSONL schema / corruption.
37    ManifestSchema,
38    /// Gitignore managed block drift vs manifest-declared patterns.
39    GitignoreSync,
40    /// Directory listed in manifest missing, or dir present but not
41    /// registered.
42    OnDiskDrift,
43    /// Opt-in config lint (`--lint-config` only).
44    ConfigLint,
45    /// Per-pack synthetic-status row — emitted only for v1.1.1
46    /// plain-git children whose lockfile entry has `synthetic: true`.
47    /// Always reports `OK (synthetic)`; downstream JSON consumers see
48    /// the `synthetic: true` flag on the finding.
49    SyntheticPack,
50}
51
52impl CheckKind {
53    /// Short human label used in the CLI table.
54    pub fn label(self) -> &'static str {
55        match self {
56            CheckKind::ManifestSchema => "manifest-schema",
57            CheckKind::GitignoreSync => "gitignore-sync",
58            CheckKind::OnDiskDrift => "on-disk-drift",
59            CheckKind::ConfigLint => "config-lint",
60            CheckKind::SyntheticPack => "synthetic-pack",
61        }
62    }
63}
64
65/// Severity of a single finding. Worst severity across the report
66/// drives the process exit code.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
68pub enum Severity {
69    /// Check passed cleanly.
70    Ok,
71    /// Non-critical drift. Exit 1.
72    Warning,
73    /// Critical — schema invalid, missing files, etc. Exit 2.
74    Error,
75}
76
77/// One observation from a single check.
78///
79/// Marked `#[non_exhaustive]` so future audit fields (per-finding
80/// timestamp, plugin id, remediation hint) can land without breaking
81/// out-of-crate consumers that destructure or struct-literal-construct
82/// findings. Within `grex-core` the existing struct-literal sites
83/// continue to work unchanged.
84#[non_exhaustive]
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct Finding {
87    /// Which check produced the finding.
88    pub check: CheckKind,
89    /// Severity — drives the exit-code roll-up.
90    pub severity: Severity,
91    /// Optional pack id (None for workspace-wide findings).
92    pub pack: Option<String>,
93    /// Human-readable detail.
94    pub detail: String,
95    /// True if `--fix` can heal this finding. Only
96    /// `CheckKind::GitignoreSync` ever sets this to true; the flag gates
97    /// the safety contract of `apply_fixes`.
98    pub auto_fixable: bool,
99    /// `true` when this finding describes a v1.1.1 synthetic plain-git
100    /// pack (no `.grex/pack.yaml` on disk; manifest synthesised
101    /// in-memory by the walker). Surfaced in `--json` output so
102    /// downstream consumers can branch on the structured signal rather
103    /// than parsing the human-readable detail string.
104    pub synthetic: bool,
105}
106
107impl Finding {
108    /// Build an `Ok` finding for a check that passed cleanly.
109    pub fn ok(check: CheckKind) -> Self {
110        Self {
111            check,
112            severity: Severity::Ok,
113            pack: None,
114            detail: String::new(),
115            auto_fixable: false,
116            synthetic: false,
117        }
118    }
119}
120
121/// One check's outcome — a list of findings (may be empty in the
122/// degenerate case but normally holds at least one `Ok` finding so the
123/// report shows a row per check).
124#[derive(Debug, Clone, Default)]
125pub struct CheckResult {
126    /// Findings produced by this check.
127    pub findings: Vec<Finding>,
128}
129
130impl CheckResult {
131    /// Single-finding helper.
132    pub fn single(finding: Finding) -> Self {
133        Self { findings: vec![finding] }
134    }
135
136    /// Worst severity across the findings.
137    pub fn worst(&self) -> Severity {
138        self.findings.iter().map(|f| f.severity).max().unwrap_or(Severity::Ok)
139    }
140}
141
142/// Full health report.
143#[derive(Debug, Clone, Default)]
144pub struct DoctorReport {
145    /// All findings, in check order.
146    pub findings: Vec<Finding>,
147}
148
149impl DoctorReport {
150    /// Worst severity across all findings. `Ok` when the report is empty.
151    pub fn worst(&self) -> Severity {
152        self.findings.iter().map(|f| f.severity).max().unwrap_or(Severity::Ok)
153    }
154
155    /// Process exit code derived from worst severity.
156    ///
157    /// * `0` — all findings are [`Severity::Ok`] or report is empty.
158    /// * `1` — at least one [`Severity::Warning`] but no `Error`.
159    /// * `2` — at least one [`Severity::Error`].
160    pub fn exit_code(&self) -> i32 {
161        match self.worst() {
162            Severity::Ok => 0,
163            Severity::Warning => 1,
164            Severity::Error => 2,
165        }
166    }
167}
168
169/// Options for [`run_doctor`].
170#[derive(Debug, Clone, Default)]
171pub struct DoctorOpts {
172    /// Heal gitignore drift. Only fixes [`CheckKind::GitignoreSync`]
173    /// findings; all other checks remain read-only.
174    pub fix: bool,
175    /// Run the opt-in config-lint check. When `false`,
176    /// [`CheckKind::ConfigLint`] never appears in the report.
177    pub lint_config: bool,
178    /// v1.2.0 Stage 1.j — depth bound on recursive ManifestTree walk.
179    ///
180    /// * `None` (default) → walk every nested meta exhaustively.
181    /// * `Some(0)` → root meta only (no recursion).
182    /// * `Some(n)` → recurse up to `n` levels of nesting (root is
183    ///   depth 0; depth-`n` metas are visited but their children are
184    ///   not).
185    ///
186    /// The walk is read-only — no clones, fetches, or filesystem
187    /// mutations happen at any frame regardless of `shallow`.
188    pub shallow: Option<usize>,
189}
190
191/// Errors produced during doctor orchestration that are NOT surfaced as
192/// findings. A hard I/O error on the manifest file (other than missing
193/// file or corruption) aborts the run.
194#[derive(Debug, thiserror::Error)]
195pub enum DoctorError {
196    /// Non-recoverable I/O error hitting the manifest.
197    #[error("manifest read failure: {0}")]
198    ManifestIo(#[source] ManifestError),
199    /// Non-recoverable I/O error on a gitignore fix.
200    #[error("gitignore fix failure: {0}")]
201    GitignoreFix(#[source] GitignoreError),
202}
203
204/// Top-level orchestrator. Runs the 3 default checks; adds the 4th when
205/// `opts.lint_config`. Applies `--fix` to gitignore findings after the
206/// initial scan, then re-runs the gitignore check to record the healed
207/// state.
208///
209/// v1.2.0 Stage 1.j: walks the ManifestTree depth-first by default,
210/// running every per-meta check at each frame. `opts.shallow` bounds
211/// the recursion (`None` = unbounded, `Some(0)` = root-only,
212/// `Some(n)` = up to `n` nested levels). Recursion is read-only —
213/// no clones, fetches, or filesystem mutations happen at any frame.
214pub fn run_doctor(workspace: &Path, opts: &DoctorOpts) -> Result<DoctorReport, DoctorError> {
215    // Auto-migrate v1.x `<ws>/grex.jsonl` → v2 `<ws>/.grex/events.jsonl`
216    // before the schema check so doctor sees a consistent canonical
217    // location whether the workspace was synced under v1.x or v2.0+.
218    // Migration is the ONLY write doctor ever performs at the root
219    // frame (and only when a legacy v1.x layout is present); the
220    // recursive `walk_meta` step never migrates sub-meta event logs.
221    manifest::ensure_event_log_migrated(workspace).map_err(DoctorError::ManifestIo)?;
222
223    let mut report = DoctorReport::default();
224    walk_meta(workspace, opts, /* depth */ 0, &mut report);
225
226    if opts.lint_config {
227        let cfg_result = check_config_lint(workspace);
228        report.findings.extend(cfg_result.findings);
229    }
230
231    if opts.fix {
232        // `--fix` heals only the root meta's gitignore. Sub-meta
233        // gitignore drift is reported but never auto-healed — the
234        // recursive walk is read-only by contract.
235        let manifest_path = workspace.join(".grex").join("events.jsonl");
236        let packs = match manifest::read_all(&manifest_path) {
237            Ok(evs) => Some(manifest::fold(evs)),
238            Err(_) => None,
239        };
240        apply_fixes(workspace, packs.as_ref(), &mut report)?;
241    }
242
243    Ok(report)
244}
245
246/// Run the per-meta checks at `meta_dir`, then recurse into every child
247/// whose dest carries its own `<dest>/.grex/pack.yaml` while
248/// `depth + 1 <= shallow_cap`. Mirrors the topology of
249/// [`crate::lockfile::read_lockfile_tree`] (1.h) so doctor and the
250/// distributed-lockfile fold agree on what counts as a sub-meta.
251///
252/// Read-only: no FS mutations. The schema check uses the per-meta
253/// `<meta>/.grex/events.jsonl`; the gitignore-sync, on-disk-drift, and
254/// synthetic-pack checks use the per-meta `<meta>/.grex/grex.lock.jsonl`.
255fn walk_meta(meta_dir: &Path, opts: &DoctorOpts, depth: usize, report: &mut DoctorReport) {
256    run_meta_checks(meta_dir, report);
257
258    if let Some(cap) = opts.shallow {
259        if depth >= cap {
260            return;
261        }
262    }
263
264    // Discover nested metas via the manifest, exactly like
265    // `read_lockfile_tree`'s fold.
266    let manifest_path = meta_dir.join(".grex").join("pack.yaml");
267    let raw = match std::fs::read_to_string(&manifest_path) {
268        Ok(s) => s,
269        Err(_) => return,
270    };
271    let manifest = match crate::pack::parse(&raw) {
272        Ok(m) => m,
273        Err(_) => return,
274    };
275    for child in &manifest.children {
276        let segment = child.path.clone().unwrap_or_else(|| child.effective_path());
277        let child_meta = meta_dir.join(&segment);
278        if child_meta.join(".grex").join("pack.yaml").is_file() {
279            walk_meta(&child_meta, opts, depth + 1, report);
280        }
281    }
282}
283
284/// Run the per-meta checks (schema + gitignore-sync + on-disk-drift +
285/// synthetic-pack) for a single meta directory and append their findings
286/// to `report`. Pure read-only: never mutates the filesystem.
287fn run_meta_checks(meta_dir: &Path, report: &mut DoctorReport) {
288    let manifest_path = meta_dir.join(".grex").join("events.jsonl");
289    let (schema_result, events_opt) = check_manifest_schema(&manifest_path);
290    report.findings.extend(schema_result.findings.clone());
291
292    // Subsequent pack-level checks need the folded state. If the
293    // manifest is malformed, we still surface the schema error and skip
294    // the dependent checks so we don't double-report garbage.
295    let packs = events_opt.map(manifest::fold);
296
297    // v1.1.1 — load the lockfile so per-pack checks can branch on
298    // `LockEntry::synthetic`. A missing lockfile is tolerated silently;
299    // a corrupt / unreadable lockfile produces an empty map AND a
300    // warning finding.
301    let (lock, lock_finding) = read_synthetic_lock(meta_dir);
302    if let Some(f) = lock_finding {
303        report.findings.push(f);
304    }
305
306    let gi_result = match &packs {
307        Some(p) => check_gitignore_sync(meta_dir, p),
308        None => CheckResult::single(Finding {
309            check: CheckKind::GitignoreSync,
310            severity: Severity::Warning,
311            pack: None,
312            detail: "skipped: manifest unreadable".to_string(),
313            auto_fixable: false,
314            synthetic: false,
315        }),
316    };
317    report.findings.extend(gi_result.findings);
318
319    let drift_result = match &packs {
320        Some(p) => check_on_disk_drift(meta_dir, p, &lock),
321        None => CheckResult::single(Finding {
322            check: CheckKind::OnDiskDrift,
323            severity: Severity::Warning,
324            pack: None,
325            detail: "skipped: manifest unreadable".to_string(),
326            auto_fixable: false,
327            synthetic: false,
328        }),
329    };
330    report.findings.extend(drift_result.findings);
331
332    let synth = check_synthetic_packs(&lock);
333    report.findings.extend(synth.findings);
334}
335
336/// Run fixes and rebuild the gitignore-sync rows in `report`. Only
337/// touches [`CheckKind::GitignoreSync`] findings with
338/// `auto_fixable = true`. Other findings are left untouched — this is
339/// the safety contract.
340fn apply_fixes(
341    workspace: &Path,
342    packs: Option<&std::collections::HashMap<String, PackState>>,
343    report: &mut DoctorReport,
344) -> Result<(), DoctorError> {
345    // Collect packs that need healing.
346    let to_fix: Vec<(String, String)> = report
347        .findings
348        .iter()
349        .filter(|f| f.check == CheckKind::GitignoreSync && f.auto_fixable)
350        .filter_map(|f| f.pack.clone().map(|p| (p, f.detail.clone())))
351        .collect();
352
353    let Some(packs) = packs else {
354        return Ok(());
355    };
356
357    for (pack_id, _detail) in to_fix {
358        let Some(state) = packs.get(&pack_id) else { continue };
359        let gi_path = workspace.join(".gitignore");
360        let expected = expected_patterns_for_pack(workspace, state);
361        let patterns_ref: Vec<&str> = expected.iter().map(String::as_str).collect();
362        upsert_managed_block(&gi_path, &state.id, &patterns_ref)
363            .map_err(DoctorError::GitignoreFix)?;
364    }
365
366    // Re-run the gitignore-sync check; replace previous gi findings.
367    let refreshed = check_gitignore_sync(workspace, packs);
368    report.findings.retain(|f| f.check != CheckKind::GitignoreSync);
369    report.findings.extend(refreshed.findings);
370    Ok(())
371}
372
373/// Check 1 — manifest schema. Streams the JSONL log via the M3
374/// corruption-resistant reader and converts the outcome into findings.
375pub fn check_manifest_schema(manifest_path: &Path) -> (CheckResult, Option<Vec<Event>>) {
376    if !manifest_path.exists() {
377        // Empty workspace → no manifest, no findings beyond Ok.
378        return (CheckResult::single(Finding::ok(CheckKind::ManifestSchema)), Some(Vec::new()));
379    }
380    match manifest::read_all(manifest_path) {
381        Ok(evs) => (CheckResult::single(Finding::ok(CheckKind::ManifestSchema)), Some(evs)),
382        Err(ManifestError::Corruption { line, source }) => {
383            let detail = format!("corruption at line {line}: {source}");
384            (
385                CheckResult::single(Finding {
386                    check: CheckKind::ManifestSchema,
387                    severity: Severity::Error,
388                    pack: None,
389                    detail,
390                    auto_fixable: false,
391                    synthetic: false,
392                }),
393                None,
394            )
395        }
396        Err(e) => {
397            let detail = format!("io error: {e}");
398            (
399                CheckResult::single(Finding {
400                    check: CheckKind::ManifestSchema,
401                    severity: Severity::Error,
402                    pack: None,
403                    detail,
404                    auto_fixable: false,
405                    synthetic: false,
406                }),
407                None,
408            )
409        }
410    }
411}
412
413/// Expected managed-block patterns for a single pack.
414///
415/// The built-in pack-type plugins (`meta`, `declarative`, `scripted`) all
416/// call `pack_type::apply_gitignore`, which writes the grex default
417/// patterns first, then appends authored `x-gitignore` entries from the
418/// pack manifest without duplicating defaults. Unknown pack types are
419/// plugin-owned; doctor has no v1 contract for their emitted patterns.
420fn expected_patterns_for_pack(workspace: &Path, state: &PackState) -> Vec<String> {
421    if !is_builtin_pack_type(&state.pack_type) {
422        return Vec::new();
423    }
424
425    let mut expected: Vec<String> =
426        default_managed_gitignore_patterns().iter().map(|p| (*p).to_string()).collect();
427
428    for pattern in authored_gitignore_patterns(workspace, state) {
429        if !expected.iter().any(|p| p == &pattern) {
430            expected.push(pattern);
431        }
432    }
433
434    expected
435}
436
437fn is_builtin_pack_type(pack_type: &str) -> bool {
438    matches!(pack_type, "meta" | "declarative" | "scripted")
439}
440
441fn authored_gitignore_patterns(workspace: &Path, state: &PackState) -> Vec<String> {
442    let pack_yaml = workspace.join(&state.path).join(".grex").join("pack.yaml");
443    let Ok(contents) = std::fs::read_to_string(pack_yaml) else {
444        return Vec::new();
445    };
446    let Ok(pack) = crate::pack::parse(&contents) else {
447        return Vec::new();
448    };
449    let Some(raw) = pack.extensions.get(GITIGNORE_EXT_KEY) else {
450        return Vec::new();
451    };
452    let Some(seq) = raw.as_sequence() else {
453        return Vec::new();
454    };
455    seq.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()
456}
457
458/// Check 2 — gitignore sync. For every pack with a managed block in
459/// the workspace `.gitignore`, compare the body to the expected pattern
460/// list.
461pub fn check_gitignore_sync(
462    workspace: &Path,
463    packs: &std::collections::HashMap<String, PackState>,
464) -> CheckResult {
465    let mut findings = Vec::new();
466    // Stable iteration order - users want deterministic output.
467    let ordered: BTreeMap<_, _> = packs.iter().collect();
468    let gi_path = workspace.join(".gitignore");
469    for (id, state) in ordered {
470        match read_managed_block(&gi_path, id) {
471            Ok(Some(actual)) => {
472                let expected = expected_patterns_for_pack(workspace, state);
473                if actual != expected {
474                    findings.push(Finding {
475                        check: CheckKind::GitignoreSync,
476                        severity: Severity::Warning,
477                        pack: Some(id.clone()),
478                        detail: format!(
479                            "managed block drift: expected {} line(s), got {}",
480                            expected.len(),
481                            actual.len()
482                        ),
483                        auto_fixable: true,
484                        synthetic: false,
485                    });
486                }
487            }
488            Ok(None) => {
489                // Absent block is tolerated — plugins may not emit one.
490            }
491            Err(e) => {
492                findings.push(Finding {
493                    check: CheckKind::GitignoreSync,
494                    severity: Severity::Warning,
495                    pack: Some(id.clone()),
496                    detail: format!("cannot read managed block: {e}"),
497                    auto_fixable: matches!(e, GitignoreError::UnclosedBlock { .. }),
498                    synthetic: false,
499                });
500            }
501        }
502    }
503    if findings.is_empty() {
504        findings.push(Finding::ok(CheckKind::GitignoreSync));
505    }
506    CheckResult { findings }
507}
508
509/// Check 3 — on-disk drift. Detect (a) manifest-registered pack dirs
510/// that are missing, and (b) directories under the workspace root not
511/// registered in the manifest. Both are reported as
512/// [`CheckKind::OnDiskDrift`]; missing dirs are `Error`, unregistered
513/// dirs are `Warning`.
514///
515/// `lock` is consulted to suppress unregistered-drift warnings for
516/// directories whose lockfile entry has `synthetic: true` — those are
517/// v1.1.1 plain-git children that never get an `Event::Add`, so the
518/// lockfile is the authoritative registry for them.
519pub fn check_on_disk_drift(
520    workspace: &Path,
521    packs: &std::collections::HashMap<String, PackState>,
522    lock: &HashMap<String, LockEntry>,
523) -> CheckResult {
524    let mut findings = Vec::new();
525    let registered_paths: BTreeSet<PathBuf> =
526        packs.values().map(|p| PathBuf::from(&p.path)).collect();
527    collect_manifest_to_disk_findings(workspace, packs, &mut findings);
528    collect_disk_to_manifest_findings(workspace, &registered_paths, lock, &mut findings);
529    if findings.is_empty() {
530        findings.push(Finding::ok(CheckKind::OnDiskDrift));
531    }
532    CheckResult { findings }
533}
534
535/// Manifest → disk half of [`check_on_disk_drift`]: every registered
536/// pack dir must exist and be a directory. All failures are `Error`.
537fn collect_manifest_to_disk_findings(
538    workspace: &Path,
539    packs: &std::collections::HashMap<String, PackState>,
540    findings: &mut Vec<Finding>,
541) {
542    let ordered: BTreeMap<_, _> = packs.iter().collect();
543    for (id, state) in ordered {
544        let full = workspace.join(&state.path);
545        if !full.exists() {
546            findings.push(drift_error(id, format!("registered pack dir missing: {}", state.path)));
547            continue;
548        }
549        match std::fs::symlink_metadata(&full) {
550            Ok(md) if !md.is_dir() => findings.push(drift_error(
551                id,
552                format!("registered pack path is not a directory: {}", state.path),
553            )),
554            Ok(_) => {}
555            Err(e) => findings.push(drift_error(id, format!("stat failed: {e}"))),
556        }
557    }
558}
559
560/// Disk → manifest half of [`check_on_disk_drift`]: only direct
561/// children of `workspace` are walked (no pack interiors). Dotfiles
562/// and housekeeping dirs are skipped.
563///
564/// Directories matching a synthetic lockfile entry (`lock[name].synthetic
565/// == true`) are also skipped — v1.1.1 plain-git children never appear
566/// in `Event::Add`, so the lockfile is authoritative for them.
567fn collect_disk_to_manifest_findings(
568    workspace: &Path,
569    registered_paths: &BTreeSet<PathBuf>,
570    lock: &HashMap<String, LockEntry>,
571    findings: &mut Vec<Finding>,
572) {
573    let Ok(entries) = std::fs::read_dir(workspace) else { return };
574    for ent in entries.flatten() {
575        let Ok(ft) = ent.file_type() else { continue };
576        if !ft.is_dir() {
577            continue;
578        }
579        let name = ent.file_name();
580        let Some(name_str) = name.to_str() else { continue };
581        if name_str.starts_with('.') || is_housekeeping_dir(name_str) {
582            continue;
583        }
584        if registered_paths.contains(&PathBuf::from(name_str)) {
585            continue;
586        }
587        if lock.get(name_str).is_some_and(|e| e.synthetic) {
588            continue;
589        }
590        findings.push(Finding {
591            check: CheckKind::OnDiskDrift,
592            severity: Severity::Warning,
593            pack: None,
594            detail: format!("unregistered directory on disk: {name_str}"),
595            auto_fixable: false,
596            synthetic: false,
597        });
598    }
599}
600
601/// Shorthand — build a pack-scoped on-disk-drift error finding.
602fn drift_error(id: &str, detail: String) -> Finding {
603    Finding {
604        check: CheckKind::OnDiskDrift,
605        severity: Severity::Error,
606        pack: Some(id.to_string()),
607        detail,
608        auto_fixable: false,
609        synthetic: false,
610    }
611}
612
613/// Dirs that live beside packs but are workspace meta, not pack roots.
614fn is_housekeeping_dir(name: &str) -> bool {
615    matches!(name, "target" | "node_modules" | "crates" | "openspec" | "dist")
616}
617
618/// Check 4 — config lint (opt-in). Parses `openspec/config.yaml` if
619/// present; walks `.omne/cfg/*.md` for basic syntax validity (we just
620/// read them to prove they're valid UTF-8 — the spec calls out "basic
621/// markdown parse", not a full markdown lint). Missing files/dirs are
622/// no-ops (not findings).
623pub fn check_config_lint(workspace: &Path) -> CheckResult {
624    let mut findings = Vec::new();
625    check_openspec_config_yaml(workspace, &mut findings);
626    check_omne_cfg_markdown(workspace, &mut findings);
627    if findings.is_empty() {
628        findings.push(Finding::ok(CheckKind::ConfigLint));
629    }
630    CheckResult { findings }
631}
632
633/// `openspec/config.yaml` half of [`check_config_lint`] — parses the
634/// file as `serde_yaml::Value`. Absent file is a no-op.
635fn check_openspec_config_yaml(workspace: &Path, findings: &mut Vec<Finding>) {
636    let cfg_yaml = workspace.join("openspec").join("config.yaml");
637    if !cfg_yaml.exists() {
638        return;
639    }
640    match std::fs::read_to_string(&cfg_yaml) {
641        Ok(s) => {
642            if let Err(e) = serde_yaml::from_str::<serde_yaml::Value>(&s) {
643                findings
644                    .push(config_lint_warning(format!("openspec/config.yaml parse error: {e}")));
645            }
646        }
647        Err(e) => {
648            findings.push(config_lint_warning(format!("openspec/config.yaml unreadable: {e}")))
649        }
650    }
651}
652
653/// `.omne/cfg/*.md` half of [`check_config_lint`] — proves each file
654/// is valid UTF-8. Absent dir is a no-op.
655fn check_omne_cfg_markdown(workspace: &Path, findings: &mut Vec<Finding>) {
656    let cfg_dir = workspace.join(".omne").join("cfg");
657    if !cfg_dir.is_dir() {
658        return;
659    }
660    let Ok(entries) = std::fs::read_dir(&cfg_dir) else { return };
661    for ent in entries.flatten() {
662        let path = ent.path();
663        if path.extension().and_then(|s| s.to_str()) != Some("md") {
664            continue;
665        }
666        if let Err(e) = std::fs::read_to_string(&path) {
667            let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?").to_string();
668            findings.push(config_lint_warning(format!(".omne/cfg/{name} unreadable: {e}")));
669        }
670    }
671}
672
673/// Read the workspace's lockfile and return entries keyed by pack id,
674/// alongside an optional finding when the lockfile exists but cannot
675/// be parsed.
676///
677/// Behaviour:
678/// * Missing lockfile → `(empty map, None)`. A workspace that has
679///   never synced is a normal state, not a finding.
680/// * Corruption (`LockfileError::Corruption`) or I/O failure
681///   (`LockfileError::Io`) → `(empty map, Some(Warning))`. The
682///   downstream synthetic / on-disk-drift checks still run against an
683///   empty map, but the operator now sees the root cause instead of
684///   being misled by spurious "unregistered directory on disk"
685///   warnings (the on-disk-drift skip relies on the synthetic flag in
686///   the lockfile entries that just got swallowed).
687fn read_synthetic_lock(workspace: &Path) -> (HashMap<String, LockEntry>, Option<Finding>) {
688    let lock_path = workspace.join(".grex").join("grex.lock.jsonl");
689    match read_lockfile(&lock_path) {
690        Ok(map) => (map, None),
691        Err(err @ LockfileError::Corruption { .. }) | Err(err @ LockfileError::Io(_)) => {
692            let finding = Finding {
693                check: CheckKind::ManifestSchema,
694                severity: Severity::Warning,
695                pack: None,
696                detail: format!("lockfile corruption: {err}"),
697                auto_fixable: false,
698                synthetic: false,
699            };
700            (HashMap::new(), Some(finding))
701        }
702        // `read_lockfile` already maps NotFound → Ok(empty), and
703        // `Serialize` is write-side only, so neither path is reachable
704        // here. Tolerate any future variant by treating it the same as
705        // a corruption warning rather than panicking.
706        Err(err) => {
707            let finding = Finding {
708                check: CheckKind::ManifestSchema,
709                severity: Severity::Warning,
710                pack: None,
711                detail: format!("lockfile corruption: {err}"),
712                auto_fixable: false,
713                synthetic: false,
714            };
715            (HashMap::new(), Some(finding))
716        }
717    }
718}
719
720/// v1.1.1 — emit one `OK (synthetic)` finding per pack whose lockfile
721/// entry has `synthetic: true`.
722///
723/// The lockfile is the canonical synthetic registry: plain-git children
724/// are walked + cloned during `grex sync` and only ever recorded in
725/// `grex.lock.jsonl` (no `Event::Add` fires for them, so they never
726/// appear in the manifest-fold `packs` map). Iterating the lockfile
727/// here means the canonical sync-only flow surfaces the row, and
728/// downstream JSON consumers see the structured `synthetic: true`
729/// signal regardless of whether the pack also has an `Event::Add`.
730pub fn check_synthetic_packs(lock: &HashMap<String, LockEntry>) -> CheckResult {
731    let mut findings = Vec::new();
732    let ordered: BTreeMap<_, _> = lock.iter().collect();
733    for (id, entry) in ordered {
734        if !entry.synthetic {
735            continue;
736        }
737        findings.push(Finding {
738            check: CheckKind::SyntheticPack,
739            severity: Severity::Ok,
740            pack: Some(id.clone()),
741            detail: "OK (synthetic)".to_string(),
742            auto_fixable: false,
743            synthetic: true,
744        });
745    }
746    CheckResult { findings }
747}
748
749/// Shorthand — build a workspace-scoped config-lint warning finding.
750fn config_lint_warning(detail: String) -> Finding {
751    Finding {
752        check: CheckKind::ConfigLint,
753        severity: Severity::Warning,
754        pack: None,
755        detail,
756        auto_fixable: false,
757        synthetic: false,
758    }
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764    use crate::manifest::{append_event, Event, SCHEMA_VERSION};
765    use chrono::{TimeZone, Utc};
766    use std::fs;
767    use tempfile::tempdir;
768
769    fn ts() -> chrono::DateTime<Utc> {
770        Utc.with_ymd_and_hms(2026, 4, 22, 10, 0, 0).unwrap()
771    }
772
773    /// Recursive path+bytes snapshot of a directory, keyed by path
774    /// relative to `root`. Used by `--fix` safety tests to prove that
775    /// a fix attempt left NO write anywhere in the fixture when the
776    /// doctor refused to heal (e.g. schema error, drift error).
777    ///
778    /// Skips `.git/` and `target/` if present, since they are never
779    /// relevant to doctor writes and keep the snapshot deterministic
780    /// on machines that might have stray VCS/build state.
781    fn fs_snapshot(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
782        fn walk(dir: &Path, root: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
783            let entries = match fs::read_dir(dir) {
784                Ok(e) => e,
785                Err(_) => return,
786            };
787            for entry in entries.flatten() {
788                let path = entry.path();
789                let name = entry.file_name();
790                if name == ".git" || name == "target" {
791                    continue;
792                }
793                let ft = match entry.file_type() {
794                    Ok(t) => t,
795                    Err(_) => continue,
796                };
797                if ft.is_dir() {
798                    walk(&path, root, out);
799                } else if ft.is_file() {
800                    let rel = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
801                    let bytes = fs::read(&path).unwrap_or_default();
802                    out.insert(rel, bytes);
803                }
804            }
805        }
806        let mut out = BTreeMap::new();
807        walk(root, root, &mut out);
808        out
809    }
810
811    fn seed_pack(workspace: &Path, id: &str) {
812        seed_pack_with_type(workspace, id, "declarative");
813    }
814
815    fn seed_pack_with_type(workspace: &Path, id: &str, pack_type: &str) {
816        let m = workspace.join(".grex/events.jsonl");
817        append_event(
818            &m,
819            &Event::Add {
820                ts: ts(),
821                id: id.into(),
822                url: format!("https://example/{id}"),
823                path: id.into(),
824                pack_type: pack_type.into(),
825                schema_version: SCHEMA_VERSION.into(),
826            },
827        )
828        .unwrap();
829        fs::create_dir_all(workspace.join(id)).unwrap();
830    }
831
832    fn write_pack_yaml(workspace: &Path, id: &str, yaml: &str) {
833        let dir = workspace.join(id).join(".grex");
834        fs::create_dir_all(&dir).unwrap();
835        fs::write(dir.join("pack.yaml"), yaml).unwrap();
836    }
837
838    // --- Unit: manifest schema ---
839
840    #[test]
841    fn schema_clean_is_ok() {
842        let d = tempdir().unwrap();
843        seed_pack(d.path(), "a");
844        let (r, evs) = check_manifest_schema(&d.path().join(".grex/events.jsonl"));
845        assert_eq!(r.worst(), Severity::Ok);
846        assert_eq!(evs.unwrap().len(), 1);
847    }
848
849    #[test]
850    fn schema_corruption_is_error() {
851        let d = tempdir().unwrap();
852        // Line 1 is garbage (not last — there's a valid line 2), so
853        // M3's reader flags it as Corruption.
854        let m = d.path().join(".grex/events.jsonl");
855        fs::create_dir_all(m.parent().unwrap()).unwrap();
856        fs::write(&m, b"not-json\n").unwrap();
857        append_event(
858            &m,
859            &Event::Add {
860                ts: ts(),
861                id: "x".into(),
862                url: "u".into(),
863                path: "x".into(),
864                pack_type: "declarative".into(),
865                schema_version: SCHEMA_VERSION.into(),
866            },
867        )
868        .unwrap();
869
870        let (r, evs) = check_manifest_schema(&m);
871        assert_eq!(r.worst(), Severity::Error);
872        assert!(evs.is_none(), "corruption must disable downstream checks");
873    }
874
875    #[test]
876    fn schema_missing_manifest_is_ok() {
877        let d = tempdir().unwrap();
878        let (r, evs) = check_manifest_schema(&d.path().join(".grex/events.jsonl"));
879        assert_eq!(r.worst(), Severity::Ok);
880        assert!(evs.unwrap().is_empty());
881    }
882
883    // --- Unit: gitignore sync ---
884
885    #[test]
886    fn expected_patterns_for_pack_populates_builtin_defaults() {
887        for pack_type in ["meta", "declarative", "scripted"] {
888            let d = tempdir().unwrap();
889            seed_pack_with_type(d.path(), pack_type, pack_type);
890            let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
891            let packs = manifest::fold(events);
892            let state = packs.get(pack_type).unwrap();
893            assert_eq!(
894                expected_patterns_for_pack(d.path(), state),
895                vec![".grex-lock".to_string()],
896                "pack type: {pack_type}"
897            );
898        }
899    }
900
901    #[test]
902    fn expected_patterns_for_pack_merges_authored_extensions_for_builtins() {
903        for pack_type in ["meta", "declarative", "scripted"] {
904            let d = tempdir().unwrap();
905            let id = format!("{pack_type}-pack");
906            let authored = format!("{pack_type}-cache/");
907            seed_pack_with_type(d.path(), &id, pack_type);
908            write_pack_yaml(
909                d.path(),
910                &id,
911                &format!(
912                    "schema_version: \"1\"\nname: {id}\ntype: {pack_type}\nx-gitignore:\n  - \".grex-lock\"\n  - {authored}\n",
913                ),
914            );
915            let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
916            let packs = manifest::fold(events);
917            let state = packs.get(&id).unwrap();
918            assert_eq!(
919                expected_patterns_for_pack(d.path(), state),
920                vec![".grex-lock".to_string(), authored],
921                "pack type: {pack_type}"
922            );
923        }
924    }
925
926    #[test]
927    fn gitignore_clean_block_is_ok() {
928        let d = tempdir().unwrap();
929        seed_pack(d.path(), "a");
930        // Upsert the expected workspace-level block.
931        upsert_managed_block(
932            &d.path().join(".gitignore"),
933            "a",
934            default_managed_gitignore_patterns(),
935        )
936        .unwrap();
937        let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
938        let packs = manifest::fold(events);
939        let r = check_gitignore_sync(d.path(), &packs);
940        assert_eq!(r.worst(), Severity::Ok);
941    }
942
943    #[test]
944    fn gitignore_drift_is_warning_and_autofixable() {
945        let d = tempdir().unwrap();
946        seed_pack(d.path(), "a");
947        // Write a drifted workspace-level block body.
948        upsert_managed_block(&d.path().join(".gitignore"), "a", &["unexpected-line"]).unwrap();
949        let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
950        let packs = manifest::fold(events);
951        let r = check_gitignore_sync(d.path(), &packs);
952        assert_eq!(r.worst(), Severity::Warning);
953        assert!(r.findings.iter().any(|f| f.auto_fixable));
954    }
955
956    #[test]
957    fn gitignore_authored_patterns_are_not_reported_as_drift() {
958        let d = tempdir().unwrap();
959        seed_pack(d.path(), "a");
960        write_pack_yaml(
961            d.path(),
962            "a",
963            "schema_version: \"1\"\nname: a\ntype: declarative\nx-gitignore:\n  - target/\n  - \"*.log\"\n",
964        );
965        upsert_managed_block(
966            &d.path().join(".gitignore"),
967            "a",
968            &[".grex-lock", "target/", "*.log"],
969        )
970        .unwrap();
971        let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
972        let packs = manifest::fold(events);
973        let r = check_gitignore_sync(d.path(), &packs);
974        assert_eq!(r.worst(), Severity::Ok);
975    }
976
977    // --- Unit: on-disk drift ---
978
979    #[test]
980    fn on_disk_missing_pack_is_error() {
981        let d = tempdir().unwrap();
982        seed_pack(d.path(), "a");
983        // Delete the pack dir after seeding.
984        fs::remove_dir_all(d.path().join("a")).unwrap();
985        let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
986        let packs = manifest::fold(events);
987        let r = check_on_disk_drift(d.path(), &packs, &HashMap::new());
988        assert_eq!(r.worst(), Severity::Error);
989    }
990
991    #[test]
992    fn on_disk_unregistered_dir_is_warning() {
993        let d = tempdir().unwrap();
994        seed_pack(d.path(), "a");
995        fs::create_dir_all(d.path().join("stranger")).unwrap();
996        let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
997        let packs = manifest::fold(events);
998        let r = check_on_disk_drift(d.path(), &packs, &HashMap::new());
999        assert_eq!(r.worst(), Severity::Warning);
1000    }
1001
1002    #[test]
1003    fn on_disk_clean_workspace_is_ok() {
1004        let d = tempdir().unwrap();
1005        seed_pack(d.path(), "a");
1006        let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
1007        let packs = manifest::fold(events);
1008        let r = check_on_disk_drift(d.path(), &packs, &HashMap::new());
1009        assert_eq!(r.worst(), Severity::Ok);
1010    }
1011
1012    // --- Unit: config lint ---
1013
1014    #[test]
1015    fn config_lint_absent_dir_is_ok() {
1016        let d = tempdir().unwrap();
1017        let r = check_config_lint(d.path());
1018        assert_eq!(r.worst(), Severity::Ok);
1019    }
1020
1021    #[test]
1022    fn config_lint_bad_yaml_is_warning() {
1023        let d = tempdir().unwrap();
1024        fs::create_dir_all(d.path().join("openspec")).unwrap();
1025        fs::write(d.path().join("openspec").join("config.yaml"), "::: bad: : yaml : [").unwrap();
1026        let r = check_config_lint(d.path());
1027        assert_eq!(r.worst(), Severity::Warning);
1028    }
1029
1030    // --- Module: exit code roll-up ---
1031
1032    #[test]
1033    fn exit_code_roll_up_ok_is_zero() {
1034        let mut r = DoctorReport::default();
1035        r.findings.push(Finding::ok(CheckKind::ManifestSchema));
1036        assert_eq!(r.exit_code(), 0);
1037    }
1038
1039    #[test]
1040    fn exit_code_roll_up_warning_is_one() {
1041        let mut r = DoctorReport::default();
1042        r.findings.push(Finding::ok(CheckKind::ManifestSchema));
1043        r.findings.push(Finding {
1044            check: CheckKind::GitignoreSync,
1045            severity: Severity::Warning,
1046            pack: None,
1047            detail: String::new(),
1048            auto_fixable: true,
1049            synthetic: false,
1050        });
1051        assert_eq!(r.exit_code(), 1);
1052    }
1053
1054    #[test]
1055    fn exit_code_roll_up_error_is_two() {
1056        let mut r = DoctorReport::default();
1057        r.findings.push(Finding {
1058            check: CheckKind::OnDiskDrift,
1059            severity: Severity::Error,
1060            pack: None,
1061            detail: String::new(),
1062            auto_fixable: false,
1063            synthetic: false,
1064        });
1065        assert_eq!(r.exit_code(), 2);
1066    }
1067
1068    #[test]
1069    fn exit_code_roll_up_warn_and_error_is_two() {
1070        let mut r = DoctorReport::default();
1071        r.findings.push(Finding {
1072            check: CheckKind::GitignoreSync,
1073            severity: Severity::Warning,
1074            pack: None,
1075            detail: String::new(),
1076            auto_fixable: true,
1077            synthetic: false,
1078        });
1079        r.findings.push(Finding {
1080            check: CheckKind::OnDiskDrift,
1081            severity: Severity::Error,
1082            pack: None,
1083            detail: String::new(),
1084            auto_fixable: false,
1085            synthetic: false,
1086        });
1087        assert_eq!(r.exit_code(), 2);
1088    }
1089
1090    // --- Integration: run_doctor orchestrator ---
1091
1092    #[test]
1093    fn run_doctor_clean_workspace_exits_zero() {
1094        let d = tempdir().unwrap();
1095        seed_pack(d.path(), "a");
1096        upsert_managed_block(
1097            &d.path().join(".gitignore"),
1098            "a",
1099            default_managed_gitignore_patterns(),
1100        )
1101        .unwrap();
1102        let report = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
1103        assert_eq!(report.exit_code(), 0);
1104    }
1105
1106    #[test]
1107    fn run_doctor_gitignore_drift_exits_one() {
1108        let d = tempdir().unwrap();
1109        seed_pack(d.path(), "a");
1110        upsert_managed_block(&d.path().join(".gitignore"), "a", &["drift"]).unwrap();
1111        let report = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
1112        assert_eq!(report.exit_code(), 1);
1113    }
1114
1115    #[test]
1116    fn run_doctor_fix_heals_gitignore_drift() {
1117        let d = tempdir().unwrap();
1118        seed_pack(d.path(), "a");
1119        upsert_managed_block(&d.path().join(".gitignore"), "a", &["drift"]).unwrap();
1120        let opts = DoctorOpts { fix: true, lint_config: false, ..DoctorOpts::default() };
1121        let report = run_doctor(d.path(), &opts).unwrap();
1122        assert_eq!(report.exit_code(), 0, "fix must zero out exit code");
1123        // Confirm idempotence: running again without --fix also returns 0.
1124        let again = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
1125        assert_eq!(again.exit_code(), 0);
1126    }
1127
1128    #[test]
1129    fn run_doctor_fix_does_not_touch_manifest_on_schema_error() {
1130        let d = tempdir().unwrap();
1131        // Seed a corrupt manifest (line 1 garbage, line 2 valid).
1132        let m = d.path().join(".grex/events.jsonl");
1133        fs::create_dir_all(m.parent().unwrap()).unwrap();
1134        fs::write(&m, b"garbage-line\n").unwrap();
1135        append_event(
1136            &m,
1137            &Event::Add {
1138                ts: ts(),
1139                id: "x".into(),
1140                url: "u".into(),
1141                path: "x".into(),
1142                pack_type: "declarative".into(),
1143                schema_version: SCHEMA_VERSION.into(),
1144            },
1145        )
1146        .unwrap();
1147        let before_bytes = fs::read(&m).unwrap();
1148        let before = fs_snapshot(d.path());
1149
1150        let opts = DoctorOpts { fix: true, lint_config: false, ..DoctorOpts::default() };
1151        let report = run_doctor(d.path(), &opts).unwrap();
1152        assert_eq!(report.exit_code(), 2, "schema error → exit 2");
1153
1154        // SAFETY CRITICAL: --fix must NOT touch the manifest OR any
1155        // other file on schema errors. The recursive snapshot proves
1156        // no stray write happened anywhere in the fixture.
1157        let after_bytes = fs::read(&m).unwrap();
1158        assert_eq!(before_bytes, after_bytes, "manifest bytes must be unchanged");
1159        let after = fs_snapshot(d.path());
1160        assert_eq!(before, after, "--fix must not write anywhere on schema error");
1161    }
1162
1163    #[test]
1164    fn run_doctor_fix_does_not_touch_disk_on_drift_error() {
1165        let d = tempdir().unwrap();
1166        seed_pack(d.path(), "a");
1167        // Delete the pack dir → on-disk drift error.
1168        fs::remove_dir_all(d.path().join("a")).unwrap();
1169
1170        // SAFETY CRITICAL: --fix must NOT write anywhere in the
1171        // workspace on drift error — not the missing dir, not
1172        // `.grex/events.jsonl`, not a stray `.gitignore`, nothing. A recursive
1173        // path+bytes snapshot catches any such write, not just the
1174        // presence/absence of the missing pack dir.
1175        let before = fs_snapshot(d.path());
1176
1177        let opts = DoctorOpts { fix: true, lint_config: false, ..DoctorOpts::default() };
1178        let report = run_doctor(d.path(), &opts).unwrap();
1179        assert_eq!(report.exit_code(), 2);
1180
1181        let after = fs_snapshot(d.path());
1182        assert_eq!(before, after, "--fix must not write anywhere on drift error");
1183        assert!(!d.path().join("a").exists(), "missing pack dir must stay missing");
1184    }
1185
1186    #[test]
1187    fn run_doctor_config_lint_skipped_by_default() {
1188        let d = tempdir().unwrap();
1189        seed_pack(d.path(), "a");
1190        upsert_managed_block(
1191            &d.path().join(".gitignore"),
1192            "a",
1193            default_managed_gitignore_patterns(),
1194        )
1195        .unwrap();
1196        // Seed a broken config.yaml; default run must ignore it.
1197        fs::create_dir_all(d.path().join("openspec")).unwrap();
1198        fs::write(d.path().join("openspec").join("config.yaml"), ": : : [bad").unwrap();
1199        let before = fs_snapshot(d.path());
1200        let report = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
1201        assert_eq!(report.exit_code(), 0, "config-lint must be skipped by default");
1202        assert!(
1203            !report.findings.iter().any(|f| f.check == CheckKind::ConfigLint),
1204            "no ConfigLint finding when --lint-config absent"
1205        );
1206        // SAFETY: read-only run — every byte must be untouched.
1207        let after = fs_snapshot(d.path());
1208        assert_eq!(before, after, "default doctor run must be read-only");
1209    }
1210
1211    #[test]
1212    fn run_doctor_lint_config_flag_reports_config() {
1213        let d = tempdir().unwrap();
1214        seed_pack(d.path(), "a");
1215        upsert_managed_block(
1216            &d.path().join(".gitignore"),
1217            "a",
1218            default_managed_gitignore_patterns(),
1219        )
1220        .unwrap();
1221        fs::create_dir_all(d.path().join("openspec")).unwrap();
1222        fs::write(d.path().join("openspec").join("config.yaml"), ": : : [bad").unwrap();
1223        let opts = DoctorOpts { fix: false, lint_config: true, ..DoctorOpts::default() };
1224        let report = run_doctor(d.path(), &opts).unwrap();
1225        assert_eq!(report.exit_code(), 1);
1226        assert!(report.findings.iter().any(|f| f.check == CheckKind::ConfigLint));
1227    }
1228
1229    // --- v1.1.1: synthetic plain-git children ---
1230
1231    /// A workspace whose lockfile carries a `synthetic: true` entry for
1232    /// pack `a` reports `OK (synthetic)` for it, exits 0, and never
1233    /// emits a missing-manifest finding even though no `.grex/pack.yaml`
1234    /// exists on disk for that pack.
1235    #[test]
1236    fn run_doctor_synthetic_pack_reports_ok_synthetic_and_exits_zero() {
1237        use crate::lockfile::{write_lockfile, LockEntry};
1238        use std::collections::HashMap;
1239
1240        let d = tempdir().unwrap();
1241        seed_pack(d.path(), "a");
1242        upsert_managed_block(
1243            &d.path().join(".gitignore"),
1244            "a",
1245            default_managed_gitignore_patterns(),
1246        )
1247        .unwrap();
1248
1249        // Hand-write a lockfile with `synthetic: true` for pack `a`.
1250        let lock_dir = d.path().join(".grex");
1251        fs::create_dir_all(&lock_dir).unwrap();
1252        let lock_path = lock_dir.join("grex.lock.jsonl");
1253        let mut lock = HashMap::new();
1254        lock.insert(
1255            "a".to_string(),
1256            LockEntry {
1257                id: "a".into(),
1258                path: "a".into(),
1259                sha: "deadbeef".into(),
1260                branch: "main".into(),
1261                installed_at: ts(),
1262                actions_hash: String::new(),
1263                schema_version: "1".into(),
1264                synthetic: true,
1265            },
1266        );
1267        write_lockfile(&lock_path, &lock).unwrap();
1268
1269        // Note: we deliberately do NOT write `<pack>/.grex/pack.yaml`,
1270        // matching the v1.1.1 plain-git-child case.
1271        let report = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
1272        assert_eq!(report.exit_code(), 0, "synthetic-only workspace must exit 0");
1273        let synth: Vec<_> =
1274            report.findings.iter().filter(|f| f.check == CheckKind::SyntheticPack).collect();
1275        assert_eq!(synth.len(), 1, "exactly one synthetic-pack finding");
1276        assert_eq!(synth[0].pack.as_deref(), Some("a"));
1277        assert_eq!(synth[0].detail, "OK (synthetic)");
1278        assert!(synth[0].synthetic, "Finding.synthetic must be true");
1279        assert_eq!(synth[0].severity, Severity::Ok);
1280
1281        // Sanity: nothing in the report claims pack `a` is missing or
1282        // schema-invalid.
1283        for f in &report.findings {
1284            assert!(f.severity != Severity::Error, "no error-severity finding allowed; got: {f:?}",);
1285        }
1286    }
1287
1288    /// A workspace whose `.grex/grex.lock.jsonl` is malformed (one line
1289    /// of invalid JSON) must produce a `Severity::Warning` finding
1290    /// mentioning lockfile corruption. The doctor must still complete
1291    /// — lockfile errors are findings, not orchestration aborts —
1292    /// because operators rely on `grex doctor` to surface root causes,
1293    /// not crash on them.
1294    #[test]
1295    fn run_doctor_corrupt_lockfile_emits_warning_finding() {
1296        let d = tempdir().unwrap();
1297        // Seed a clean schema-and-gitignore baseline so the corruption
1298        // finding stands out against an otherwise-OK report.
1299        seed_pack(d.path(), "a");
1300        upsert_managed_block(
1301            &d.path().join(".gitignore"),
1302            "a",
1303            default_managed_gitignore_patterns(),
1304        )
1305        .unwrap();
1306
1307        // Hand-write a malformed lockfile: one line of invalid JSON.
1308        let lock_dir = d.path().join(".grex");
1309        fs::create_dir_all(&lock_dir).unwrap();
1310        fs::write(lock_dir.join("grex.lock.jsonl"), b"not-json-at-all\n").unwrap();
1311
1312        let report = run_doctor(d.path(), &DoctorOpts::default())
1313            .expect("doctor must complete despite lockfile corruption");
1314
1315        // The lockfile corruption is reported as a ManifestSchema
1316        // warning whose detail mentions "lockfile corruption".
1317        let lock_warns: Vec<_> = report
1318            .findings
1319            .iter()
1320            .filter(|f| {
1321                f.check == CheckKind::ManifestSchema
1322                    && f.severity == Severity::Warning
1323                    && f.detail.contains("lockfile corruption")
1324            })
1325            .collect();
1326        assert_eq!(
1327            lock_warns.len(),
1328            1,
1329            "exactly one lockfile-corruption warning expected; got: {:?}",
1330            report.findings,
1331        );
1332
1333        // Doctor still completed: report has the usual gitignore /
1334        // drift / synthetic rows even though the lockfile was unusable.
1335        assert!(
1336            report.findings.iter().any(|f| f.check == CheckKind::GitignoreSync),
1337            "gitignore-sync check must still run",
1338        );
1339        assert!(
1340            report.findings.iter().any(|f| f.check == CheckKind::OnDiskDrift),
1341            "on-disk-drift check must still run",
1342        );
1343    }
1344
1345    // --- v1.2.0 Stage 1.j: recursive ManifestTree walk + --shallow ---
1346
1347    /// Build a meta directory at `meta_dir` with a `pack.yaml` declaring
1348    /// `children`. Each child is `(segment, url)`. The pack type is
1349    /// `meta` so the manifest is shaped like a workspace orchestrator.
1350    fn write_meta_manifest(meta_dir: &Path, name: &str, children: &[(&str, &str)]) {
1351        let grex_dir = meta_dir.join(".grex");
1352        fs::create_dir_all(&grex_dir).unwrap();
1353        let mut yaml = format!("schema_version: \"1\"\nname: {name}\ntype: meta\n");
1354        if !children.is_empty() {
1355            yaml.push_str("children:\n");
1356            for (segment, url) in children {
1357                yaml.push_str(&format!("  - url: {url}\n    path: {segment}\n"));
1358            }
1359        }
1360        fs::write(grex_dir.join("pack.yaml"), yaml).unwrap();
1361    }
1362
1363    /// Build a leaf meta whose own `events.jsonl` registers one pack
1364    /// `pack_id` at sub-path `pack_id` so the per-meta on-disk-drift
1365    /// check sees a clean pack. Does NOT touch the parent.
1366    fn seed_meta_with_pack(meta_dir: &Path, meta_name: &str, pack_id: &str) {
1367        write_meta_manifest(meta_dir, meta_name, &[]);
1368        let m = meta_dir.join(".grex").join("events.jsonl");
1369        append_event(
1370            &m,
1371            &Event::Add {
1372                ts: ts(),
1373                id: pack_id.into(),
1374                url: format!("https://example/{pack_id}"),
1375                path: pack_id.into(),
1376                pack_type: "declarative".into(),
1377                schema_version: SCHEMA_VERSION.into(),
1378            },
1379        )
1380        .unwrap();
1381        fs::create_dir_all(meta_dir.join(pack_id)).unwrap();
1382    }
1383
1384    /// AC: by default, doctor walks every nested meta. A 3-level tree
1385    /// (root → alpha → gamma) yields one ManifestSchema finding per
1386    /// meta (3 total).
1387    #[test]
1388    fn test_doctor_recurses_default() {
1389        let d = tempdir().unwrap();
1390        let root = d.path();
1391
1392        // Root meta declares child `alpha`.
1393        write_meta_manifest(root, "root", &[("alpha", "https://example.invalid/alpha.git")]);
1394        // Root's events.jsonl registers `alpha` so on-disk-drift is clean.
1395        let m = root.join(".grex").join("events.jsonl");
1396        append_event(
1397            &m,
1398            &Event::Add {
1399                ts: ts(),
1400                id: "alpha".into(),
1401                url: "https://example.invalid/alpha.git".into(),
1402                path: "alpha".into(),
1403                pack_type: "meta".into(),
1404                schema_version: SCHEMA_VERSION.into(),
1405            },
1406        )
1407        .unwrap();
1408
1409        // Alpha meta declares child `gamma`.
1410        let alpha = root.join("alpha");
1411        write_meta_manifest(&alpha, "alpha", &[("gamma", "https://example.invalid/gamma.git")]);
1412        let am = alpha.join(".grex").join("events.jsonl");
1413        append_event(
1414            &am,
1415            &Event::Add {
1416                ts: ts(),
1417                id: "gamma".into(),
1418                url: "https://example.invalid/gamma.git".into(),
1419                path: "gamma".into(),
1420                pack_type: "declarative".into(),
1421                schema_version: SCHEMA_VERSION.into(),
1422            },
1423        )
1424        .unwrap();
1425        fs::create_dir_all(alpha.join("gamma")).unwrap();
1426
1427        // Gamma is a leaf meta with one registered pack `delta`.
1428        let gamma = alpha.join("gamma");
1429        seed_meta_with_pack(&gamma, "gamma", "delta");
1430
1431        let report = run_doctor(root, &DoctorOpts::default()).unwrap();
1432
1433        let schema_oks: Vec<_> = report
1434            .findings
1435            .iter()
1436            .filter(|f| f.check == CheckKind::ManifestSchema && f.severity == Severity::Ok)
1437            .collect();
1438        assert_eq!(
1439            schema_oks.len(),
1440            3,
1441            "three metas visited (root + alpha + gamma); got: {:?}",
1442            report.findings,
1443        );
1444    }
1445
1446    /// AC: `--shallow 0` halts at the root meta — only one
1447    /// ManifestSchema finding even when the root has nested metas.
1448    #[test]
1449    fn test_doctor_shallow_zero_root_only() {
1450        let d = tempdir().unwrap();
1451        let root = d.path();
1452
1453        write_meta_manifest(root, "root", &[("alpha", "https://example.invalid/alpha.git")]);
1454        let m = root.join(".grex").join("events.jsonl");
1455        append_event(
1456            &m,
1457            &Event::Add {
1458                ts: ts(),
1459                id: "alpha".into(),
1460                url: "https://example.invalid/alpha.git".into(),
1461                path: "alpha".into(),
1462                pack_type: "meta".into(),
1463                schema_version: SCHEMA_VERSION.into(),
1464            },
1465        )
1466        .unwrap();
1467        let alpha = root.join("alpha");
1468        seed_meta_with_pack(&alpha, "alpha", "leaf");
1469
1470        let opts = DoctorOpts { shallow: Some(0), ..DoctorOpts::default() };
1471        let report = run_doctor(root, &opts).unwrap();
1472        let schema_oks: Vec<_> = report
1473            .findings
1474            .iter()
1475            .filter(|f| f.check == CheckKind::ManifestSchema && f.severity == Severity::Ok)
1476            .collect();
1477        assert_eq!(schema_oks.len(), 1, "shallow=0 must halt at root; got: {:?}", report.findings,);
1478    }
1479
1480    /// AC: `--shallow 1` visits root + depth-1 metas but not deeper.
1481    /// A 3-level tree (root → alpha → gamma) yields 2 ManifestSchema
1482    /// findings (root + alpha) under shallow=1.
1483    #[test]
1484    fn test_doctor_shallow_n_stops_at_n() {
1485        let d = tempdir().unwrap();
1486        let root = d.path();
1487
1488        write_meta_manifest(root, "root", &[("alpha", "https://example.invalid/alpha.git")]);
1489        let m = root.join(".grex").join("events.jsonl");
1490        append_event(
1491            &m,
1492            &Event::Add {
1493                ts: ts(),
1494                id: "alpha".into(),
1495                url: "https://example.invalid/alpha.git".into(),
1496                path: "alpha".into(),
1497                pack_type: "meta".into(),
1498                schema_version: SCHEMA_VERSION.into(),
1499            },
1500        )
1501        .unwrap();
1502
1503        let alpha = root.join("alpha");
1504        write_meta_manifest(&alpha, "alpha", &[("gamma", "https://example.invalid/gamma.git")]);
1505        let am = alpha.join(".grex").join("events.jsonl");
1506        append_event(
1507            &am,
1508            &Event::Add {
1509                ts: ts(),
1510                id: "gamma".into(),
1511                url: "https://example.invalid/gamma.git".into(),
1512                path: "gamma".into(),
1513                pack_type: "meta".into(),
1514                schema_version: SCHEMA_VERSION.into(),
1515            },
1516        )
1517        .unwrap();
1518        fs::create_dir_all(alpha.join("gamma")).unwrap();
1519
1520        let gamma = alpha.join("gamma");
1521        seed_meta_with_pack(&gamma, "gamma", "delta");
1522
1523        let opts = DoctorOpts { shallow: Some(1), ..DoctorOpts::default() };
1524        let report = run_doctor(root, &opts).unwrap();
1525        let schema_oks: Vec<_> = report
1526            .findings
1527            .iter()
1528            .filter(|f| f.check == CheckKind::ManifestSchema && f.severity == Severity::Ok)
1529            .collect();
1530        assert_eq!(
1531            schema_oks.len(),
1532            2,
1533            "shallow=1 must visit root + depth-1; got: {:?}",
1534            report.findings,
1535        );
1536    }
1537
1538    /// AC: doctor performs zero filesystem mutations on a multi-level
1539    /// tree even when sub-meta gitignores have drift. Read-only by
1540    /// contract — only the root frame's `--fix` is allowed to write.
1541    /// Here `--fix` is OFF, so every byte of the fixture is preserved.
1542    #[test]
1543    fn test_doctor_no_fs_mutations() {
1544        let d = tempdir().unwrap();
1545        let root = d.path();
1546
1547        // Root meta with declared child `alpha`.
1548        write_meta_manifest(root, "root", &[("alpha", "https://example.invalid/alpha.git")]);
1549        let m = root.join(".grex").join("events.jsonl");
1550        append_event(
1551            &m,
1552            &Event::Add {
1553                ts: ts(),
1554                id: "alpha".into(),
1555                url: "https://example.invalid/alpha.git".into(),
1556                path: "alpha".into(),
1557                pack_type: "meta".into(),
1558                schema_version: SCHEMA_VERSION.into(),
1559            },
1560        )
1561        .unwrap();
1562
1563        // Alpha meta carries DRIFT in its own .gitignore — its managed
1564        // block body deviates from the expected list. The recursive
1565        // walk must observe it (Warning finding) but mutate nothing.
1566        let alpha = root.join("alpha");
1567        seed_meta_with_pack(&alpha, "alpha", "leaf");
1568        upsert_managed_block(&alpha.join(".gitignore"), "leaf", &["drifted-pattern"]).unwrap();
1569
1570        let before = fs_snapshot(root);
1571        let report = run_doctor(root, &DoctorOpts::default()).unwrap();
1572        let after = fs_snapshot(root);
1573
1574        assert_eq!(before, after, "recursive doctor walk must perform zero writes");
1575        // Sanity: the sub-meta drift was actually observed (so the
1576        // mutation-check isn't passing trivially because the walker
1577        // didn't recurse).
1578        assert!(
1579            report
1580                .findings
1581                .iter()
1582                .any(|f| f.check == CheckKind::GitignoreSync && f.severity == Severity::Warning),
1583            "expected sub-meta gitignore-drift warning; got: {:?}",
1584            report.findings,
1585        );
1586    }
1587
1588    // --- Property: exit code roll-up invariant ---
1589
1590    proptest::proptest! {
1591        #![proptest_config(proptest::prelude::ProptestConfig { cases: 128, ..Default::default() })]
1592
1593        #[test]
1594        fn prop_exit_code_matches_worst_severity(
1595            severities in proptest::collection::vec(0u8..3, 0..20)
1596        ) {
1597            let mut r = DoctorReport::default();
1598            for s in &severities {
1599                let sev = match s {
1600                    0 => Severity::Ok,
1601                    1 => Severity::Warning,
1602                    _ => Severity::Error,
1603                };
1604                r.findings.push(Finding {
1605                    check: CheckKind::ManifestSchema,
1606                    severity: sev,
1607                    pack: None,
1608                    detail: String::new(),
1609                    auto_fixable: false,
1610                    synthetic: false,
1611                });
1612            }
1613            let worst = severities.iter().max().copied().unwrap_or(0);
1614            let expected = match worst { 0 => 0, 1 => 1, _ => 2 };
1615            proptest::prop_assert_eq!(r.exit_code(), expected);
1616        }
1617    }
1618}