Skip to main content

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