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///
35/// Marked `#[non_exhaustive]` so future check kinds (additional
36/// quarantine ops, plugin-contributed checks, etc.) can be added in a
37/// PATCH release without breaking out-of-crate `match` consumers. Within
38/// `grex-core` every match arm is exhaustive.
39#[non_exhaustive]
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
41pub enum CheckKind {
42    /// Manifest JSONL schema / corruption.
43    ManifestSchema,
44    /// Gitignore managed block drift vs manifest-declared patterns.
45    GitignoreSync,
46    /// Directory listed in manifest missing, or dir present but not
47    /// registered.
48    OnDiskDrift,
49    /// Opt-in config lint (`--lint-config` only).
50    ConfigLint,
51    /// Per-pack synthetic-status row — emitted only for v1.1.1
52    /// plain-git children whose lockfile entry has `synthetic: true`.
53    /// Always reports `OK (synthetic)`; downstream JSON consumers see
54    /// the `synthetic: true` flag on the finding.
55    SyntheticPack,
56    /// v1.2.5 — quarantine GC status. Reports `OK` on a clean trash
57    /// bucket (no entries, or all within the retention window) and an
58    /// `Info`-severity Warning carrying the stale-entry count when the
59    /// bucket holds entries older than `--retain-days N`.
60    QuarantineGc,
61    /// v1.2.5 — quarantine restore status. Surfaced exclusively by the
62    /// `--restore-quarantine TS[:BASENAME]` op (operator-requested
63    /// snapshot rehydration). Distinct from [`CheckKind::QuarantineGc`]
64    /// so JSON consumers can branch on `restore` vs `gc` outcomes
65    /// without parsing the human-readable detail string.
66    QuarantineRestore,
67    /// v1.3.1 (B12) — advisory: a pack directory under the meta-repo
68    /// is present in the parent git index (`git ls-files` returns a
69    /// match). The advisory is **informational only**: it carries
70    /// [`Severity::Ok`] so the worst-severity exit-code roll-up is not
71    /// affected. Operators can dismiss the finding by adding the pack
72    /// path to the parent meta-repo's `.gitignore` and (optionally)
73    /// running `git rm --cached <pack>` once. Doctor never auto-mutates
74    /// the parent meta-repo's `.gitignore` — that contract is owned by
75    /// the operator.
76    ParentGitTracksPackContent,
77}
78
79impl CheckKind {
80    /// Short human label used in the CLI table.
81    pub fn label(self) -> &'static str {
82        match self {
83            CheckKind::ManifestSchema => "manifest-schema",
84            CheckKind::GitignoreSync => "gitignore-sync",
85            CheckKind::OnDiskDrift => "on-disk-drift",
86            CheckKind::ConfigLint => "config-lint",
87            CheckKind::SyntheticPack => "synthetic-pack",
88            CheckKind::QuarantineGc => "quarantine-gc",
89            CheckKind::QuarantineRestore => "quarantine-restore",
90            CheckKind::ParentGitTracksPackContent => "parent-git-tracks-pack-content",
91        }
92    }
93}
94
95/// Severity of a single finding. Worst severity across the report
96/// drives the process exit code.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
98pub enum Severity {
99    /// Check passed cleanly.
100    Ok,
101    /// Non-critical drift. Exit 1.
102    Warning,
103    /// Critical — schema invalid, missing files, etc. Exit 2.
104    Error,
105}
106
107/// One observation from a single check.
108///
109/// Marked `#[non_exhaustive]` so future audit fields (per-finding
110/// timestamp, plugin id, remediation hint) can land without breaking
111/// out-of-crate consumers that destructure or struct-literal-construct
112/// findings. Within `grex-core` the existing struct-literal sites
113/// continue to work unchanged.
114#[non_exhaustive]
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct Finding {
117    /// Which check produced the finding.
118    pub check: CheckKind,
119    /// Severity — drives the exit-code roll-up.
120    pub severity: Severity,
121    /// Optional pack id (None for workspace-wide findings).
122    pub pack: Option<String>,
123    /// Human-readable detail.
124    pub detail: String,
125    /// True if `--fix` can heal this finding. Only
126    /// `CheckKind::GitignoreSync` ever sets this to true; the flag gates
127    /// the safety contract of `apply_fixes`.
128    pub auto_fixable: bool,
129    /// `true` when this finding describes a v1.1.1 synthetic plain-git
130    /// pack (no `.grex/pack.yaml` on disk; manifest synthesised
131    /// in-memory by the walker). Surfaced in `--json` output so
132    /// downstream consumers can branch on the structured signal rather
133    /// than parsing the human-readable detail string.
134    pub synthetic: bool,
135}
136
137impl Finding {
138    /// Build an `Ok` finding for a check that passed cleanly.
139    pub fn ok(check: CheckKind) -> Self {
140        Self {
141            check,
142            severity: Severity::Ok,
143            pack: None,
144            detail: String::new(),
145            auto_fixable: false,
146            synthetic: false,
147        }
148    }
149}
150
151/// One check's outcome — a list of findings (may be empty in the
152/// degenerate case but normally holds at least one `Ok` finding so the
153/// report shows a row per check).
154#[derive(Debug, Clone, Default)]
155pub struct CheckResult {
156    /// Findings produced by this check.
157    pub findings: Vec<Finding>,
158}
159
160impl CheckResult {
161    /// Single-finding helper.
162    pub fn single(finding: Finding) -> Self {
163        Self { findings: vec![finding] }
164    }
165
166    /// Worst severity across the findings.
167    pub fn worst(&self) -> Severity {
168        self.findings.iter().map(|f| f.severity).max().unwrap_or(Severity::Ok)
169    }
170}
171
172/// Full health report.
173#[derive(Debug, Clone, Default)]
174pub struct DoctorReport {
175    /// All findings, in check order.
176    pub findings: Vec<Finding>,
177}
178
179impl DoctorReport {
180    /// Worst severity across all findings. `Ok` when the report is empty.
181    pub fn worst(&self) -> Severity {
182        self.findings.iter().map(|f| f.severity).max().unwrap_or(Severity::Ok)
183    }
184
185    /// Process exit code derived from worst severity.
186    ///
187    /// * `0` — all findings are [`Severity::Ok`] or report is empty.
188    /// * `1` — at least one [`Severity::Warning`] but no `Error`.
189    /// * `2` — at least one [`Severity::Error`].
190    pub fn exit_code(&self) -> i32 {
191        match self.worst() {
192            Severity::Ok => 0,
193            Severity::Warning => 1,
194            Severity::Error => 2,
195        }
196    }
197}
198
199/// Options for [`run_doctor`].
200///
201/// Marked `#[non_exhaustive]` so future opt fields (additional
202/// quarantine knobs, audit toggles, etc.) can be added in a PATCH
203/// release without breaking downstream destructuring (`let DoctorOpts
204/// { fix, .. } = opts;`). External callers cannot use struct-literal
205/// construction at all per E0639 — even the `..base` spread shorthand
206/// is rejected. Construct via `DoctorOpts::default()` and mutate fields
207/// on a `mut` binding instead. In-crate code can still struct-literal
208/// freely.
209#[non_exhaustive]
210#[derive(Debug, Clone, Default)]
211pub struct DoctorOpts {
212    /// Heal gitignore drift. Only fixes [`CheckKind::GitignoreSync`]
213    /// findings; all other checks remain read-only.
214    pub fix: bool,
215    /// Run the opt-in config-lint check. When `false`,
216    /// [`CheckKind::ConfigLint`] never appears in the report.
217    pub lint_config: bool,
218    /// v1.2.0 Stage 1.j — depth bound on recursive ManifestTree walk.
219    ///
220    /// * `None` (default) → walk every nested meta exhaustively.
221    /// * `Some(0)` → root meta only (no recursion).
222    /// * `Some(n)` → recurse up to `n` levels of nesting (root is
223    ///   depth 0; depth-`n` metas are visited but their children are
224    ///   not).
225    ///
226    /// The walk is read-only — no clones, fetches, or filesystem
227    /// mutations happen at any frame regardless of `shallow`.
228    pub shallow: Option<usize>,
229    /// v1.2.5 — when `Some(N)`, run the quarantine GC sweep against
230    /// every visited meta's `<meta>/.grex/trash/` bucket using `N`-day
231    /// retention. Reports per-meta findings (`Info`/`Warning` for any
232    /// entries actually pruned) and surfaces an `OK` finding on a
233    /// clean sweep. `None` (default) skips the sweep entirely; the
234    /// default doctor walk stays read-only.
235    pub prune_quarantine: Option<u32>,
236    /// v1.2.5 — when `Some((ts, basename))`, restore the snapshot at
237    /// `<workspace>/.grex/trash/<ts>/<basename>/` back to the
238    /// workspace at `<workspace>/<basename>`. `basename = None`
239    /// requires the `<ts>/` slot to hold exactly one child entry.
240    /// Refuses to clobber an existing dest unless [`Self::force`] is
241    /// also `true`. Run-once at the root meta; not threaded into the
242    /// recursive walk.
243    pub restore_quarantine: Option<(String, Option<String>)>,
244    /// v1.2.5 — paired with [`Self::restore_quarantine`]: when `true`,
245    /// remove the existing dest before the rename. Default `false`
246    /// surfaces [`crate::tree::QuarantineError::DestExists`] instead.
247    /// Not used by other checks.
248    pub force: bool,
249}
250
251/// Errors produced during doctor orchestration that are NOT surfaced as
252/// findings. A hard I/O error on the manifest file (other than missing
253/// file or corruption) aborts the run.
254#[derive(Debug, thiserror::Error)]
255pub enum DoctorError {
256    /// Non-recoverable I/O error hitting the manifest.
257    #[error("manifest read failure: {0}")]
258    ManifestIo(#[source] ManifestError),
259    /// Non-recoverable I/O error on a gitignore fix.
260    #[error("gitignore fix failure: {0}")]
261    GitignoreFix(#[source] GitignoreError),
262}
263
264/// Top-level orchestrator. Runs the 3 default checks; adds the 4th when
265/// `opts.lint_config`. Applies `--fix` to gitignore findings after the
266/// initial scan, then re-runs the gitignore check to record the healed
267/// state.
268///
269/// v1.2.0 Stage 1.j: walks the ManifestTree depth-first by default,
270/// running every per-meta check at each frame. `opts.shallow` bounds
271/// the recursion (`None` = unbounded, `Some(0)` = root-only,
272/// `Some(n)` = up to `n` nested levels). Recursion is read-only —
273/// no clones, fetches, or filesystem mutations happen at any frame.
274#[allow(clippy::too_many_lines)] // 63 lines: linear orchestration over migration → walk → quarantine GC → restore branches; splitting harms readability.
275pub fn run_doctor(workspace: &Path, opts: &DoctorOpts) -> Result<DoctorReport, DoctorError> {
276    // Auto-migrate v1.x `<ws>/grex.jsonl` → v2 `<ws>/.grex/events.jsonl`
277    // before the schema check so doctor sees a consistent canonical
278    // location whether the workspace was synced under v1.x or v2.0+.
279    // Migration is the ONLY write doctor ever performs at the root
280    // frame (and only when a legacy v1.x layout is present); the
281    // recursive `walk_meta` step never migrates sub-meta event logs.
282    manifest::ensure_event_log_migrated(workspace).map_err(DoctorError::ManifestIo)?;
283
284    let mut report = DoctorReport::default();
285    walk_meta(workspace, opts, /* depth */ 0, &mut report);
286
287    if opts.lint_config {
288        let cfg_result = check_config_lint(workspace);
289        report.findings.extend(cfg_result.findings);
290    }
291
292    // v1.2.5 — quarantine-GC check / sweep at the root meta. The
293    // recursive `walk_meta` does not branch into per-meta GC because
294    // sweeps SHOULD only fire when explicitly requested by the
295    // operator (default doctor stays read-only). Run-once at root
296    // matches the `--prune-quarantine [--retain-days N]` design.
297    if let Some(retain_days) = opts.prune_quarantine {
298        let qc = check_quarantine_gc(workspace, retain_days, /* prune */ true);
299        report.findings.extend(qc.findings);
300    }
301
302    // v1.2.5 — operator-requested restore. Single-shot at the root
303    // meta; failures surface as an Error finding so the report still
304    // returns an exit code rather than aborting the orchestration.
305    // Findings are tagged `CheckKind::QuarantineRestore` (distinct from
306    // `QuarantineGc`) so JSON consumers can branch on the structured
307    // op rather than parsing the detail string.
308    if let Some((ts, basename)) = &opts.restore_quarantine {
309        use crate::tree::quarantine::restore_quarantine;
310        let audit_log = crate::manifest::event_log_path(workspace);
311        // Defensive: if the operator pasted an ISO-8601 timestamp with
312        // colons (`2026-05-02T10:30:00Z`) into the `TS:BASENAME`
313        // syntax, we'd see extra colons baked into `ts` (the CLI
314        // splitn(2, ':') gives `ts="2026-05-02T10"` and
315        // `basename="30:00Z"` — almost certainly NOT what the operator
316        // meant). Surface a clearer hint than a downstream
317        // "snapshot not found" lookup. The CLI itself ought to grow
318        // `--restore-quarantine TS [--basename B]` (Reviewer 1 P2-5);
319        // until then this hint catches the common foot-gun in-band.
320        let finding = if ts.contains(':') || basename.as_deref().is_some_and(|b| b.contains(':')) {
321            Finding {
322                check: CheckKind::QuarantineRestore,
323                severity: Severity::Error,
324                pack: None,
325                detail: format!(
326                    "restore failed: malformed `TS[:BASENAME]` argument (ts={ts:?}, basename={basename:?}) — the syntax splits on the FIRST colon, so an ISO-8601 timestamp like `2026-05-02T10:30:00Z` is ambiguous. Use the trash slot directory name verbatim (e.g. `2026-05-02T10-30-00Z`) followed by at most one colon + basename."
327                ),
328                auto_fixable: false,
329                synthetic: false,
330            }
331        } else {
332            let res = restore_quarantine(
333                workspace,
334                ts,
335                basename.as_deref(),
336                opts.force,
337                Some(&audit_log),
338            );
339            match res {
340                Ok(report_inner) => Finding {
341                    check: CheckKind::QuarantineRestore,
342                    severity: Severity::Ok,
343                    pack: None,
344                    detail: format!("restored snapshot to {}", report_inner.dest.display()),
345                    auto_fixable: false,
346                    synthetic: false,
347                },
348                Err(e) => Finding {
349                    check: CheckKind::QuarantineRestore,
350                    severity: Severity::Error,
351                    pack: None,
352                    detail: format!("restore failed: {e}"),
353                    auto_fixable: false,
354                    synthetic: false,
355                },
356            }
357        };
358        report.findings.push(finding);
359    }
360
361    if opts.fix {
362        // `--fix` heals only the root meta's gitignore. Sub-meta
363        // gitignore drift is reported but never auto-healed — the
364        // recursive walk is read-only by contract.
365        let manifest_path = workspace.join(".grex").join("events.jsonl");
366        let packs = match manifest::read_all(&manifest_path) {
367            Ok(evs) => Some(manifest::fold(evs)),
368            Err(_) => None,
369        };
370        apply_fixes(workspace, packs.as_ref(), &mut report)?;
371    }
372
373    Ok(report)
374}
375
376/// Run the per-meta checks at `meta_dir`, then recurse into every child
377/// whose dest carries its own `<dest>/.grex/pack.yaml` while
378/// `depth + 1 <= shallow_cap`. Mirrors the topology of
379/// [`crate::lockfile::read_lockfile_tree`] (1.h) so doctor and the
380/// distributed-lockfile fold agree on what counts as a sub-meta.
381///
382/// Read-only: no FS mutations. The schema check uses the per-meta
383/// `<meta>/.grex/events.jsonl`; the gitignore-sync, on-disk-drift, and
384/// synthetic-pack checks use the per-meta `<meta>/.grex/grex.lock.jsonl`.
385fn walk_meta(meta_dir: &Path, opts: &DoctorOpts, depth: usize, report: &mut DoctorReport) {
386    run_meta_checks(meta_dir, report);
387
388    if let Some(cap) = opts.shallow {
389        if depth >= cap {
390            return;
391        }
392    }
393
394    // Discover nested metas via the manifest, exactly like
395    // `read_lockfile_tree`'s fold.
396    let manifest_path = meta_dir.join(".grex").join("pack.yaml");
397    let raw = match std::fs::read_to_string(&manifest_path) {
398        Ok(s) => s,
399        Err(_) => return,
400    };
401    let manifest = match crate::pack::parse(&raw) {
402        Ok(m) => m,
403        Err(_) => return,
404    };
405    for child in &manifest.children {
406        let segment = child.path.clone().unwrap_or_else(|| child.effective_path());
407        let child_meta = meta_dir.join(&segment);
408        if child_meta.join(".grex").join("pack.yaml").is_file() {
409            walk_meta(&child_meta, opts, depth + 1, report);
410        }
411    }
412}
413
414/// Run the per-meta checks (schema + gitignore-sync + on-disk-drift +
415/// synthetic-pack) for a single meta directory and append their findings
416/// to `report`. Pure read-only: never mutates the filesystem.
417fn run_meta_checks(meta_dir: &Path, report: &mut DoctorReport) {
418    let manifest_path = meta_dir.join(".grex").join("events.jsonl");
419    let (schema_result, events_opt) = check_manifest_schema(&manifest_path);
420    report.findings.extend(schema_result.findings.clone());
421
422    // Subsequent pack-level checks need the folded state. If the
423    // manifest is malformed, we still surface the schema error and skip
424    // the dependent checks so we don't double-report garbage.
425    let packs = events_opt.map(manifest::fold);
426
427    // v1.1.1 — load the lockfile so per-pack checks can branch on
428    // `LockEntry::synthetic`. A missing lockfile is tolerated silently;
429    // a corrupt / unreadable lockfile produces an empty map AND a
430    // warning finding.
431    let (lock, lock_finding) = read_synthetic_lock(meta_dir);
432    if let Some(f) = lock_finding {
433        report.findings.push(f);
434    }
435
436    let gi_result = match &packs {
437        Some(p) => check_gitignore_sync(meta_dir, p),
438        None => CheckResult::single(Finding {
439            check: CheckKind::GitignoreSync,
440            severity: Severity::Warning,
441            pack: None,
442            detail: "skipped: manifest unreadable".to_string(),
443            auto_fixable: false,
444            synthetic: false,
445        }),
446    };
447    report.findings.extend(gi_result.findings);
448
449    let drift_result = match &packs {
450        Some(p) => check_on_disk_drift(meta_dir, p, &lock),
451        None => CheckResult::single(Finding {
452            check: CheckKind::OnDiskDrift,
453            severity: Severity::Warning,
454            pack: None,
455            detail: "skipped: manifest unreadable".to_string(),
456            auto_fixable: false,
457            synthetic: false,
458        }),
459    };
460    report.findings.extend(drift_result.findings);
461
462    let synth = check_synthetic_packs(&lock);
463    report.findings.extend(synth.findings);
464
465    // v1.3.1 (B12) — advisory: report when a pack path under this
466    // meta is tracked by the parent meta-repo's git index. Pure
467    // read-only probe; emits an `Info`-equivalent finding
468    // (`Severity::Ok`) per pack so the exit-code roll-up is not
469    // affected. The advisory is mute when no parent git repo is
470    // visible above `meta_dir`.
471    let parent_findings = check_parent_git_tracks_pack_content(meta_dir, packs.as_ref());
472    report.findings.extend(parent_findings.findings);
473}
474
475/// v1.2.5 — quarantine-GC check. Surveys `<meta>/.grex/trash/` and
476/// reports either a clean `Ok` finding (no aged entries OR no trash
477/// bucket at all) or a `Warning` finding carrying the count of stale
478/// entries that would be swept under the supplied retention window.
479/// When `prune == true`, the check ALSO runs the actual sweep and
480/// records the pruned entries in the finding detail; otherwise the
481/// check is purely informational (matches the default doctor read-only
482/// contract).
483#[allow(clippy::too_many_lines)]
484pub fn check_quarantine_gc(meta_dir: &Path, retain_days: u32, prune: bool) -> CheckResult {
485    use crate::tree::quarantine::{parse_iso8601_quarantine, prune_quarantine, RetentionConfig};
486    use std::time::{Duration, SystemTime};
487
488    let trash_root = meta_dir.join(".grex").join("trash");
489    if !trash_root.is_dir() {
490        return CheckResult::single(Finding::ok(CheckKind::QuarantineGc));
491    }
492    let cutoff = SystemTime::now()
493        .checked_sub(Duration::from_secs(u64::from(retain_days) * 86_400))
494        .unwrap_or(SystemTime::UNIX_EPOCH);
495
496    if prune {
497        let retention = RetentionConfig { retain_days };
498        let audit_log = crate::manifest::event_log_path(meta_dir);
499        let report = match prune_quarantine(meta_dir, retention, Some(&audit_log)) {
500            Ok(r) => r,
501            Err(e) => {
502                return CheckResult::single(Finding {
503                    check: CheckKind::QuarantineGc,
504                    severity: Severity::Warning,
505                    pack: None,
506                    detail: format!("GC sweep failed: {e}"),
507                    auto_fixable: false,
508                    synthetic: false,
509                });
510            }
511        };
512        if report.pruned.is_empty() && report.failed.is_empty() {
513            return CheckResult::single(Finding::ok(CheckKind::QuarantineGc));
514        }
515        let mut detail =
516            format!("pruned {} entr{}", report.pruned.len(), pluralize(report.pruned.len()));
517        if !report.failed.is_empty() {
518            detail.push_str(&format!("; {} failed", report.failed.len()));
519        }
520        return CheckResult::single(Finding {
521            check: CheckKind::QuarantineGc,
522            severity: if report.failed.is_empty() { Severity::Warning } else { Severity::Error },
523            pack: None,
524            detail,
525            auto_fixable: false,
526            synthetic: false,
527        });
528    }
529
530    // Read-only inspection: count stale entries without deleting.
531    let entries = match std::fs::read_dir(&trash_root) {
532        Ok(e) => e,
533        Err(e) => {
534            return CheckResult::single(Finding {
535                check: CheckKind::QuarantineGc,
536                severity: Severity::Warning,
537                pack: None,
538                detail: format!("cannot read trash bucket: {e}"),
539                auto_fixable: false,
540                synthetic: false,
541            });
542        }
543    };
544    let mut stale = 0usize;
545    for ent in entries.flatten() {
546        let name = ent.file_name();
547        let Some(name_str) = name.to_str() else { continue };
548        let Some(ts) = parse_iso8601_quarantine(name_str) else { continue };
549        if ts < cutoff {
550            stale += 1;
551        }
552    }
553    if stale == 0 {
554        CheckResult::single(Finding::ok(CheckKind::QuarantineGc))
555    } else {
556        CheckResult::single(Finding {
557            check: CheckKind::QuarantineGc,
558            severity: Severity::Warning,
559            pack: None,
560            detail: format!(
561                "{stale} stale entr{} older than {retain_days}d (run `grex doctor --prune-quarantine --retain-days {retain_days}` to sweep)",
562                pluralize(stale),
563            ),
564            auto_fixable: false,
565            synthetic: false,
566        })
567    }
568}
569
570fn pluralize(n: usize) -> &'static str {
571    if n == 1 {
572        "y"
573    } else {
574        "ies"
575    }
576}
577
578/// Run fixes and rebuild the gitignore-sync rows in `report`. Only
579/// touches [`CheckKind::GitignoreSync`] findings with
580/// `auto_fixable = true`. Other findings are left untouched — this is
581/// the safety contract.
582fn apply_fixes(
583    workspace: &Path,
584    packs: Option<&std::collections::HashMap<String, PackState>>,
585    report: &mut DoctorReport,
586) -> Result<(), DoctorError> {
587    // Collect packs that need healing.
588    let to_fix: Vec<(String, String)> = report
589        .findings
590        .iter()
591        .filter(|f| f.check == CheckKind::GitignoreSync && f.auto_fixable)
592        .filter_map(|f| f.pack.clone().map(|p| (p, f.detail.clone())))
593        .collect();
594
595    let Some(packs) = packs else {
596        return Ok(());
597    };
598
599    for (pack_id, _detail) in to_fix {
600        let Some(state) = packs.get(&pack_id) else { continue };
601        let gi_path = workspace.join(".gitignore");
602        let expected = expected_patterns_for_pack(workspace, state);
603        let patterns_ref: Vec<&str> = expected.iter().map(String::as_str).collect();
604        upsert_managed_block(&gi_path, &state.id, &patterns_ref)
605            .map_err(DoctorError::GitignoreFix)?;
606    }
607
608    // Re-run the gitignore-sync check; replace previous gi findings.
609    let refreshed = check_gitignore_sync(workspace, packs);
610    report.findings.retain(|f| f.check != CheckKind::GitignoreSync);
611    report.findings.extend(refreshed.findings);
612    Ok(())
613}
614
615/// Check 1 — manifest schema. Streams the JSONL log via the M3
616/// corruption-resistant reader and converts the outcome into findings.
617pub fn check_manifest_schema(manifest_path: &Path) -> (CheckResult, Option<Vec<Event>>) {
618    if !manifest_path.exists() {
619        // Empty workspace → no manifest, no findings beyond Ok.
620        return (CheckResult::single(Finding::ok(CheckKind::ManifestSchema)), Some(Vec::new()));
621    }
622    match manifest::read_all(manifest_path) {
623        Ok(evs) => (CheckResult::single(Finding::ok(CheckKind::ManifestSchema)), Some(evs)),
624        Err(ManifestError::Corruption { line, source }) => {
625            let detail = format!("corruption at line {line}: {source}");
626            (
627                CheckResult::single(Finding {
628                    check: CheckKind::ManifestSchema,
629                    severity: Severity::Error,
630                    pack: None,
631                    detail,
632                    auto_fixable: false,
633                    synthetic: false,
634                }),
635                None,
636            )
637        }
638        Err(e) => {
639            let detail = format!("io error: {e}");
640            (
641                CheckResult::single(Finding {
642                    check: CheckKind::ManifestSchema,
643                    severity: Severity::Error,
644                    pack: None,
645                    detail,
646                    auto_fixable: false,
647                    synthetic: false,
648                }),
649                None,
650            )
651        }
652    }
653}
654
655/// Expected managed-block patterns for a single pack.
656///
657/// The built-in pack-type plugins (`meta`, `declarative`, `scripted`) all
658/// call `pack_type::apply_gitignore`, which writes the grex default
659/// patterns first, then appends authored `x-gitignore` entries from the
660/// pack manifest without duplicating defaults. Unknown pack types are
661/// plugin-owned; doctor has no v1 contract for their emitted patterns.
662fn expected_patterns_for_pack(workspace: &Path, state: &PackState) -> Vec<String> {
663    if !is_builtin_pack_type(&state.pack_type) {
664        return Vec::new();
665    }
666
667    let mut expected: Vec<String> =
668        default_managed_gitignore_patterns().iter().map(|p| (*p).to_string()).collect();
669
670    for pattern in authored_gitignore_patterns(workspace, state) {
671        if !expected.iter().any(|p| p == &pattern) {
672            expected.push(pattern);
673        }
674    }
675
676    expected
677}
678
679fn is_builtin_pack_type(pack_type: &str) -> bool {
680    matches!(pack_type, "meta" | "declarative" | "scripted")
681}
682
683fn authored_gitignore_patterns(workspace: &Path, state: &PackState) -> Vec<String> {
684    let pack_yaml = workspace.join(&state.path).join(".grex").join("pack.yaml");
685    let Ok(contents) = std::fs::read_to_string(pack_yaml) else {
686        return Vec::new();
687    };
688    let Ok(pack) = crate::pack::parse(&contents) else {
689        return Vec::new();
690    };
691    let Some(raw) = pack.extensions.get(GITIGNORE_EXT_KEY) else {
692        return Vec::new();
693    };
694    let Some(seq) = raw.as_sequence() else {
695        return Vec::new();
696    };
697    seq.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()
698}
699
700/// Check 2 — gitignore sync. For every pack with a managed block in
701/// the workspace `.gitignore`, compare the body to the expected pattern
702/// list.
703pub fn check_gitignore_sync(
704    workspace: &Path,
705    packs: &std::collections::HashMap<String, PackState>,
706) -> CheckResult {
707    let mut findings = Vec::new();
708    // Stable iteration order - users want deterministic output.
709    let ordered: BTreeMap<_, _> = packs.iter().collect();
710    let gi_path = workspace.join(".gitignore");
711    for (id, state) in ordered {
712        match read_managed_block(&gi_path, id) {
713            Ok(Some(actual)) => {
714                let expected = expected_patterns_for_pack(workspace, state);
715                if actual != expected {
716                    findings.push(Finding {
717                        check: CheckKind::GitignoreSync,
718                        severity: Severity::Warning,
719                        pack: Some(id.clone()),
720                        detail: format!(
721                            "managed block drift: expected {} line(s), got {}",
722                            expected.len(),
723                            actual.len()
724                        ),
725                        auto_fixable: true,
726                        synthetic: false,
727                    });
728                }
729            }
730            Ok(None) => {
731                // Absent block is tolerated — plugins may not emit one.
732            }
733            Err(e) => {
734                findings.push(Finding {
735                    check: CheckKind::GitignoreSync,
736                    severity: Severity::Warning,
737                    pack: Some(id.clone()),
738                    detail: format!("cannot read managed block: {e}"),
739                    auto_fixable: matches!(e, GitignoreError::UnclosedBlock { .. }),
740                    synthetic: false,
741                });
742            }
743        }
744    }
745    if findings.is_empty() {
746        findings.push(Finding::ok(CheckKind::GitignoreSync));
747    }
748    CheckResult { findings }
749}
750
751/// Check 3 — on-disk drift. Detect (a) manifest-registered pack dirs
752/// that are missing, and (b) directories under the workspace root not
753/// registered in the manifest. Both are reported as
754/// [`CheckKind::OnDiskDrift`]; missing dirs are `Error`, unregistered
755/// dirs are `Warning`.
756///
757/// `lock` is consulted to suppress unregistered-drift warnings for
758/// directories whose lockfile entry has `synthetic: true` — those are
759/// v1.1.1 plain-git children that never get an `Event::Add`, so the
760/// lockfile is the authoritative registry for them.
761pub fn check_on_disk_drift(
762    workspace: &Path,
763    packs: &std::collections::HashMap<String, PackState>,
764    lock: &HashMap<String, LockEntry>,
765) -> CheckResult {
766    let mut findings = Vec::new();
767    let registered_paths: BTreeSet<PathBuf> =
768        packs.values().map(|p| PathBuf::from(&p.path)).collect();
769    collect_manifest_to_disk_findings(workspace, packs, &mut findings);
770    collect_disk_to_manifest_findings(workspace, &registered_paths, lock, &mut findings);
771    if findings.is_empty() {
772        findings.push(Finding::ok(CheckKind::OnDiskDrift));
773    }
774    CheckResult { findings }
775}
776
777/// Manifest → disk half of [`check_on_disk_drift`]: every registered
778/// pack dir must exist and be a directory. All failures are `Error`.
779fn collect_manifest_to_disk_findings(
780    workspace: &Path,
781    packs: &std::collections::HashMap<String, PackState>,
782    findings: &mut Vec<Finding>,
783) {
784    let ordered: BTreeMap<_, _> = packs.iter().collect();
785    for (id, state) in ordered {
786        let full = workspace.join(&state.path);
787        if !full.exists() {
788            findings.push(drift_error(id, format!("registered pack dir missing: {}", state.path)));
789            continue;
790        }
791        match std::fs::symlink_metadata(&full) {
792            Ok(md) if !md.is_dir() => findings.push(drift_error(
793                id,
794                format!("registered pack path is not a directory: {}", state.path),
795            )),
796            Ok(_) => {}
797            Err(e) => findings.push(drift_error(id, format!("stat failed: {e}"))),
798        }
799    }
800}
801
802/// Disk → manifest half of [`check_on_disk_drift`]: only direct
803/// children of `workspace` are walked (no pack interiors). Dotfiles
804/// and housekeeping dirs are skipped.
805///
806/// Directories matching a synthetic lockfile entry (`lock[name].synthetic
807/// == true`) are also skipped — v1.1.1 plain-git children never appear
808/// in `Event::Add`, so the lockfile is authoritative for them.
809fn collect_disk_to_manifest_findings(
810    workspace: &Path,
811    registered_paths: &BTreeSet<PathBuf>,
812    lock: &HashMap<String, LockEntry>,
813    findings: &mut Vec<Finding>,
814) {
815    let Ok(entries) = std::fs::read_dir(workspace) else { return };
816    for ent in entries.flatten() {
817        let Ok(ft) = ent.file_type() else { continue };
818        if !ft.is_dir() {
819            continue;
820        }
821        let name = ent.file_name();
822        let Some(name_str) = name.to_str() else { continue };
823        if name_str.starts_with('.') || is_housekeeping_dir(name_str) {
824            continue;
825        }
826        if registered_paths.contains(&PathBuf::from(name_str)) {
827            continue;
828        }
829        if lock.get(name_str).is_some_and(|e| e.synthetic) {
830            continue;
831        }
832        findings.push(Finding {
833            check: CheckKind::OnDiskDrift,
834            severity: Severity::Warning,
835            pack: None,
836            detail: format!("unregistered directory on disk: {name_str}"),
837            auto_fixable: false,
838            synthetic: false,
839        });
840    }
841}
842
843/// Shorthand — build a pack-scoped on-disk-drift error finding.
844fn drift_error(id: &str, detail: String) -> Finding {
845    Finding {
846        check: CheckKind::OnDiskDrift,
847        severity: Severity::Error,
848        pack: Some(id.to_string()),
849        detail,
850        auto_fixable: false,
851        synthetic: false,
852    }
853}
854
855/// Dirs that live beside packs but are workspace meta, not pack roots.
856fn is_housekeeping_dir(name: &str) -> bool {
857    matches!(name, "target" | "node_modules" | "crates" | "openspec" | "dist")
858}
859
860/// Check 4 — config lint (opt-in). Parses `openspec/config.yaml` if
861/// present; walks `.omne/cfg/*.md` for basic syntax validity (we just
862/// read them to prove they're valid UTF-8 — the spec calls out "basic
863/// markdown parse", not a full markdown lint). Missing files/dirs are
864/// no-ops (not findings).
865pub fn check_config_lint(workspace: &Path) -> CheckResult {
866    let mut findings = Vec::new();
867    check_openspec_config_yaml(workspace, &mut findings);
868    check_omne_cfg_markdown(workspace, &mut findings);
869    if findings.is_empty() {
870        findings.push(Finding::ok(CheckKind::ConfigLint));
871    }
872    CheckResult { findings }
873}
874
875/// `openspec/config.yaml` half of [`check_config_lint`] — parses the
876/// file as `serde_yaml::Value`. Absent file is a no-op.
877fn check_openspec_config_yaml(workspace: &Path, findings: &mut Vec<Finding>) {
878    let cfg_yaml = workspace.join("openspec").join("config.yaml");
879    if !cfg_yaml.exists() {
880        return;
881    }
882    match std::fs::read_to_string(&cfg_yaml) {
883        Ok(s) => {
884            if let Err(e) = serde_yaml::from_str::<serde_yaml::Value>(&s) {
885                findings
886                    .push(config_lint_warning(format!("openspec/config.yaml parse error: {e}")));
887            }
888        }
889        Err(e) => {
890            findings.push(config_lint_warning(format!("openspec/config.yaml unreadable: {e}")))
891        }
892    }
893}
894
895/// `.omne/cfg/*.md` half of [`check_config_lint`] — proves each file
896/// is valid UTF-8. Absent dir is a no-op.
897fn check_omne_cfg_markdown(workspace: &Path, findings: &mut Vec<Finding>) {
898    let cfg_dir = workspace.join(".omne").join("cfg");
899    if !cfg_dir.is_dir() {
900        return;
901    }
902    let Ok(entries) = std::fs::read_dir(&cfg_dir) else { return };
903    for ent in entries.flatten() {
904        let path = ent.path();
905        if path.extension().and_then(|s| s.to_str()) != Some("md") {
906            continue;
907        }
908        if let Err(e) = std::fs::read_to_string(&path) {
909            let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?").to_string();
910            findings.push(config_lint_warning(format!(".omne/cfg/{name} unreadable: {e}")));
911        }
912    }
913}
914
915/// Read the workspace's lockfile and return entries keyed by pack id,
916/// alongside an optional finding when the lockfile exists but cannot
917/// be parsed.
918///
919/// Behaviour:
920/// * Missing lockfile → `(empty map, None)`. A workspace that has
921///   never synced is a normal state, not a finding.
922/// * Corruption (`LockfileError::Corruption`) or I/O failure
923///   (`LockfileError::Io`) → `(empty map, Some(Warning))`. The
924///   downstream synthetic / on-disk-drift checks still run against an
925///   empty map, but the operator now sees the root cause instead of
926///   being misled by spurious "unregistered directory on disk"
927///   warnings (the on-disk-drift skip relies on the synthetic flag in
928///   the lockfile entries that just got swallowed).
929fn read_synthetic_lock(workspace: &Path) -> (HashMap<String, LockEntry>, Option<Finding>) {
930    let lock_path = workspace.join(".grex").join("grex.lock.jsonl");
931    match read_lockfile(&lock_path) {
932        Ok(map) => (map, None),
933        Err(err @ LockfileError::Corruption { .. }) | Err(err @ LockfileError::Io(_)) => {
934            let finding = Finding {
935                check: CheckKind::ManifestSchema,
936                severity: Severity::Warning,
937                pack: None,
938                detail: format!("lockfile corruption: {err}"),
939                auto_fixable: false,
940                synthetic: false,
941            };
942            (HashMap::new(), Some(finding))
943        }
944        // `read_lockfile` already maps NotFound → Ok(empty), and
945        // `Serialize` is write-side only, so neither path is reachable
946        // here. Tolerate any future variant by treating it the same as
947        // a corruption warning rather than panicking.
948        Err(err) => {
949            let finding = Finding {
950                check: CheckKind::ManifestSchema,
951                severity: Severity::Warning,
952                pack: None,
953                detail: format!("lockfile corruption: {err}"),
954                auto_fixable: false,
955                synthetic: false,
956            };
957            (HashMap::new(), Some(finding))
958        }
959    }
960}
961
962/// v1.1.1 — emit one `OK (synthetic)` finding per pack whose lockfile
963/// entry has `synthetic: true`.
964///
965/// The lockfile is the canonical synthetic registry: plain-git children
966/// are walked + cloned during `grex sync` and only ever recorded in
967/// `grex.lock.jsonl` (no `Event::Add` fires for them, so they never
968/// appear in the manifest-fold `packs` map). Iterating the lockfile
969/// here means the canonical sync-only flow surfaces the row, and
970/// downstream JSON consumers see the structured `synthetic: true`
971/// signal regardless of whether the pack also has an `Event::Add`.
972pub fn check_synthetic_packs(lock: &HashMap<String, LockEntry>) -> CheckResult {
973    let mut findings = Vec::new();
974    let ordered: BTreeMap<_, _> = lock.iter().collect();
975    for (id, entry) in ordered {
976        if !entry.synthetic {
977            continue;
978        }
979        findings.push(Finding {
980            check: CheckKind::SyntheticPack,
981            severity: Severity::Ok,
982            pack: Some(id.clone()),
983            detail: "OK (synthetic)".to_string(),
984            auto_fixable: false,
985            synthetic: true,
986        });
987    }
988    CheckResult { findings }
989}
990
991/// v1.3.1 (B12) — advisory check: report packs whose on-disk content
992/// is tracked by the parent meta-repo's git index. Pure read-only.
993///
994/// Behaviour:
995/// * If `<meta_dir>` is not inside any git repo (no `.git/` walking up
996///   the ancestors), no findings are emitted — the advisory is mute
997///   when there is no parent to advise about.
998/// * For each registered pack at `<meta_dir>/<state.path>`, run
999///   `git -C <parent_repo> ls-files --error-unmatch <pack_rel_path>`.
1000///   A zero exit indicates the path is tracked → emit one
1001///   `ParentGitTracksPackContent` finding with `Severity::Ok` (advisory
1002///   only — does NOT change exit code).
1003/// * Per-pack git failures (binary missing, etc.) silently degrade to
1004///   "no finding for this pack" so the doctor walk completes.
1005///
1006/// The check runs against `packs` produced by [`crate::manifest::fold::fold`]; if
1007/// `packs` is `None` (manifest unreadable) the check is skipped — the
1008/// schema-error finding already informs the operator.
1009pub fn check_parent_git_tracks_pack_content(
1010    meta_dir: &Path,
1011    packs: Option<&HashMap<String, PackState>>,
1012) -> CheckResult {
1013    let Some(packs) = packs else {
1014        return CheckResult::default();
1015    };
1016    let Some(parent_repo) = find_parent_git_repo(meta_dir) else {
1017        return CheckResult::default();
1018    };
1019    let mut findings = Vec::new();
1020    let ordered: BTreeMap<_, _> = packs.iter().collect();
1021    for (id, state) in ordered {
1022        // Compute the pack path relative to the parent git repo root.
1023        let pack_abs = meta_dir.join(&state.path);
1024        let Ok(pack_rel) = pack_abs.strip_prefix(&parent_repo) else {
1025            continue;
1026        };
1027        let rel_str = pack_rel.to_string_lossy();
1028        if rel_str.is_empty() {
1029            continue;
1030        }
1031        if parent_git_path_tracked(&parent_repo, rel_str.as_ref()) {
1032            findings.push(Finding {
1033                check: CheckKind::ParentGitTracksPackContent,
1034                severity: Severity::Ok,
1035                pack: Some(id.clone()),
1036                detail: format!(
1037                    "advisory: pack `{id}` at `{rel_str}` is tracked by the parent meta-repo's git index. Add it to the meta-repo's `.gitignore` (and `git rm --cached` once) to clear this finding. grex never writes to the parent `.gitignore` automatically."
1038                ),
1039                auto_fixable: false,
1040                synthetic: false,
1041            });
1042        }
1043    }
1044    CheckResult { findings }
1045}
1046
1047/// Walk parent directories of `start` looking for the nearest ancestor
1048/// that contains a `.git/` entry (directory or worktree gitlink file).
1049/// Returns the ancestor path, NOT the `.git/` itself. None if no parent
1050/// git repo is found.
1051///
1052/// Note: this deliberately walks STRICTLY upward starting from
1053/// `start.parent()` — a pack-managed meta-repo with its own `.git/` at
1054/// `meta_dir/.git/` is NOT the "parent" in the sense the advisory
1055/// cares about (the advisory is "the meta-repo above me tracks my
1056/// content", not "my own repo tracks my own content").
1057fn find_parent_git_repo(start: &Path) -> Option<PathBuf> {
1058    let mut cur = start.parent()?;
1059    loop {
1060        if cur.join(".git").exists() {
1061            return Some(cur.to_path_buf());
1062        }
1063        cur = cur.parent()?;
1064    }
1065}
1066
1067/// Best-effort `git -C <repo> ls-files --error-unmatch <rel_path>`
1068/// probe. Returns `true` when the path is tracked, `false` otherwise
1069/// (untracked, ignored, missing git binary, etc.). Stderr is silenced
1070/// so the doctor output stays clean.
1071fn parent_git_path_tracked(repo: &Path, rel_path: &str) -> bool {
1072    use std::process::{Command, Stdio};
1073    let normalised = rel_path.replace('\\', "/");
1074    let status = Command::new("git")
1075        .arg("-C")
1076        .arg(repo)
1077        .args(["ls-files", "--error-unmatch", "--"])
1078        .arg(&normalised)
1079        .stdout(Stdio::null())
1080        .stderr(Stdio::null())
1081        .status();
1082    matches!(status, Ok(s) if s.success())
1083}
1084
1085/// Shorthand — build a workspace-scoped config-lint warning finding.
1086fn config_lint_warning(detail: String) -> Finding {
1087    Finding {
1088        check: CheckKind::ConfigLint,
1089        severity: Severity::Warning,
1090        pack: None,
1091        detail,
1092        auto_fixable: false,
1093        synthetic: false,
1094    }
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099    use super::*;
1100    use crate::manifest::{append_event, Event, SCHEMA_VERSION};
1101    use chrono::{TimeZone, Utc};
1102    use std::fs;
1103    use tempfile::tempdir;
1104
1105    fn ts() -> chrono::DateTime<Utc> {
1106        Utc.with_ymd_and_hms(2026, 4, 22, 10, 0, 0).unwrap()
1107    }
1108
1109    /// Recursive path+bytes snapshot of a directory, keyed by path
1110    /// relative to `root`. Used by `--fix` safety tests to prove that
1111    /// a fix attempt left NO write anywhere in the fixture when the
1112    /// doctor refused to heal (e.g. schema error, drift error).
1113    ///
1114    /// Skips `.git/` and `target/` if present, since they are never
1115    /// relevant to doctor writes and keep the snapshot deterministic
1116    /// on machines that might have stray VCS/build state.
1117    fn fs_snapshot(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
1118        fn walk(dir: &Path, root: &Path, out: &mut BTreeMap<PathBuf, Vec<u8>>) {
1119            let entries = match fs::read_dir(dir) {
1120                Ok(e) => e,
1121                Err(_) => return,
1122            };
1123            for entry in entries.flatten() {
1124                let path = entry.path();
1125                let name = entry.file_name();
1126                if name == ".git" || name == "target" {
1127                    continue;
1128                }
1129                let ft = match entry.file_type() {
1130                    Ok(t) => t,
1131                    Err(_) => continue,
1132                };
1133                if ft.is_dir() {
1134                    walk(&path, root, out);
1135                } else if ft.is_file() {
1136                    let rel = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
1137                    let bytes = fs::read(&path).unwrap_or_default();
1138                    out.insert(rel, bytes);
1139                }
1140            }
1141        }
1142        let mut out = BTreeMap::new();
1143        walk(root, root, &mut out);
1144        out
1145    }
1146
1147    fn seed_pack(workspace: &Path, id: &str) {
1148        seed_pack_with_type(workspace, id, "declarative");
1149    }
1150
1151    fn seed_pack_with_type(workspace: &Path, id: &str, pack_type: &str) {
1152        let m = workspace.join(".grex/events.jsonl");
1153        append_event(
1154            &m,
1155            &Event::Add {
1156                ts: ts(),
1157                id: id.into(),
1158                url: format!("https://example/{id}"),
1159                path: id.into(),
1160                pack_type: pack_type.into(),
1161                schema_version: SCHEMA_VERSION.into(),
1162            },
1163        )
1164        .unwrap();
1165        fs::create_dir_all(workspace.join(id)).unwrap();
1166    }
1167
1168    fn write_pack_yaml(workspace: &Path, id: &str, yaml: &str) {
1169        let dir = workspace.join(id).join(".grex");
1170        fs::create_dir_all(&dir).unwrap();
1171        fs::write(dir.join("pack.yaml"), yaml).unwrap();
1172    }
1173
1174    // --- Unit: manifest schema ---
1175
1176    #[test]
1177    fn schema_clean_is_ok() {
1178        let d = tempdir().unwrap();
1179        seed_pack(d.path(), "a");
1180        let (r, evs) = check_manifest_schema(&d.path().join(".grex/events.jsonl"));
1181        assert_eq!(r.worst(), Severity::Ok);
1182        assert_eq!(evs.unwrap().len(), 1);
1183    }
1184
1185    #[test]
1186    fn schema_corruption_is_error() {
1187        let d = tempdir().unwrap();
1188        // Line 1 is garbage (not last — there's a valid line 2), so
1189        // M3's reader flags it as Corruption.
1190        let m = d.path().join(".grex/events.jsonl");
1191        fs::create_dir_all(m.parent().unwrap()).unwrap();
1192        fs::write(&m, b"not-json\n").unwrap();
1193        append_event(
1194            &m,
1195            &Event::Add {
1196                ts: ts(),
1197                id: "x".into(),
1198                url: "u".into(),
1199                path: "x".into(),
1200                pack_type: "declarative".into(),
1201                schema_version: SCHEMA_VERSION.into(),
1202            },
1203        )
1204        .unwrap();
1205
1206        let (r, evs) = check_manifest_schema(&m);
1207        assert_eq!(r.worst(), Severity::Error);
1208        assert!(evs.is_none(), "corruption must disable downstream checks");
1209    }
1210
1211    #[test]
1212    fn schema_missing_manifest_is_ok() {
1213        let d = tempdir().unwrap();
1214        let (r, evs) = check_manifest_schema(&d.path().join(".grex/events.jsonl"));
1215        assert_eq!(r.worst(), Severity::Ok);
1216        assert!(evs.unwrap().is_empty());
1217    }
1218
1219    // --- Unit: gitignore sync ---
1220
1221    #[test]
1222    fn expected_patterns_for_pack_populates_builtin_defaults() {
1223        for pack_type in ["meta", "declarative", "scripted"] {
1224            let d = tempdir().unwrap();
1225            seed_pack_with_type(d.path(), pack_type, pack_type);
1226            let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
1227            let packs = manifest::fold(events);
1228            let state = packs.get(pack_type).unwrap();
1229            assert_eq!(
1230                expected_patterns_for_pack(d.path(), state),
1231                vec![".grex-lock".to_string()],
1232                "pack type: {pack_type}"
1233            );
1234        }
1235    }
1236
1237    #[test]
1238    fn expected_patterns_for_pack_merges_authored_extensions_for_builtins() {
1239        for pack_type in ["meta", "declarative", "scripted"] {
1240            let d = tempdir().unwrap();
1241            let id = format!("{pack_type}-pack");
1242            let authored = format!("{pack_type}-cache/");
1243            seed_pack_with_type(d.path(), &id, pack_type);
1244            write_pack_yaml(
1245                d.path(),
1246                &id,
1247                &format!(
1248                    "schema_version: \"1\"\nname: {id}\ntype: {pack_type}\nx-gitignore:\n  - \".grex-lock\"\n  - {authored}\n",
1249                ),
1250            );
1251            let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
1252            let packs = manifest::fold(events);
1253            let state = packs.get(&id).unwrap();
1254            assert_eq!(
1255                expected_patterns_for_pack(d.path(), state),
1256                vec![".grex-lock".to_string(), authored],
1257                "pack type: {pack_type}"
1258            );
1259        }
1260    }
1261
1262    #[test]
1263    fn gitignore_clean_block_is_ok() {
1264        let d = tempdir().unwrap();
1265        seed_pack(d.path(), "a");
1266        // Upsert the expected workspace-level block.
1267        upsert_managed_block(
1268            &d.path().join(".gitignore"),
1269            "a",
1270            default_managed_gitignore_patterns(),
1271        )
1272        .unwrap();
1273        let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
1274        let packs = manifest::fold(events);
1275        let r = check_gitignore_sync(d.path(), &packs);
1276        assert_eq!(r.worst(), Severity::Ok);
1277    }
1278
1279    #[test]
1280    fn gitignore_drift_is_warning_and_autofixable() {
1281        let d = tempdir().unwrap();
1282        seed_pack(d.path(), "a");
1283        // Write a drifted workspace-level block body.
1284        upsert_managed_block(&d.path().join(".gitignore"), "a", &["unexpected-line"]).unwrap();
1285        let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
1286        let packs = manifest::fold(events);
1287        let r = check_gitignore_sync(d.path(), &packs);
1288        assert_eq!(r.worst(), Severity::Warning);
1289        assert!(r.findings.iter().any(|f| f.auto_fixable));
1290    }
1291
1292    #[test]
1293    fn gitignore_authored_patterns_are_not_reported_as_drift() {
1294        let d = tempdir().unwrap();
1295        seed_pack(d.path(), "a");
1296        write_pack_yaml(
1297            d.path(),
1298            "a",
1299            "schema_version: \"1\"\nname: a\ntype: declarative\nx-gitignore:\n  - target/\n  - \"*.log\"\n",
1300        );
1301        upsert_managed_block(
1302            &d.path().join(".gitignore"),
1303            "a",
1304            &[".grex-lock", "target/", "*.log"],
1305        )
1306        .unwrap();
1307        let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
1308        let packs = manifest::fold(events);
1309        let r = check_gitignore_sync(d.path(), &packs);
1310        assert_eq!(r.worst(), Severity::Ok);
1311    }
1312
1313    // --- Unit: on-disk drift ---
1314
1315    #[test]
1316    fn on_disk_missing_pack_is_error() {
1317        let d = tempdir().unwrap();
1318        seed_pack(d.path(), "a");
1319        // Delete the pack dir after seeding.
1320        fs::remove_dir_all(d.path().join("a")).unwrap();
1321        let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
1322        let packs = manifest::fold(events);
1323        let r = check_on_disk_drift(d.path(), &packs, &HashMap::new());
1324        assert_eq!(r.worst(), Severity::Error);
1325    }
1326
1327    #[test]
1328    fn on_disk_unregistered_dir_is_warning() {
1329        let d = tempdir().unwrap();
1330        seed_pack(d.path(), "a");
1331        fs::create_dir_all(d.path().join("stranger")).unwrap();
1332        let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
1333        let packs = manifest::fold(events);
1334        let r = check_on_disk_drift(d.path(), &packs, &HashMap::new());
1335        assert_eq!(r.worst(), Severity::Warning);
1336    }
1337
1338    #[test]
1339    fn on_disk_clean_workspace_is_ok() {
1340        let d = tempdir().unwrap();
1341        seed_pack(d.path(), "a");
1342        let events = manifest::read_all(&d.path().join(".grex/events.jsonl")).unwrap();
1343        let packs = manifest::fold(events);
1344        let r = check_on_disk_drift(d.path(), &packs, &HashMap::new());
1345        assert_eq!(r.worst(), Severity::Ok);
1346    }
1347
1348    // --- Unit: config lint ---
1349
1350    #[test]
1351    fn config_lint_absent_dir_is_ok() {
1352        let d = tempdir().unwrap();
1353        let r = check_config_lint(d.path());
1354        assert_eq!(r.worst(), Severity::Ok);
1355    }
1356
1357    #[test]
1358    fn config_lint_bad_yaml_is_warning() {
1359        let d = tempdir().unwrap();
1360        fs::create_dir_all(d.path().join("openspec")).unwrap();
1361        fs::write(d.path().join("openspec").join("config.yaml"), "::: bad: : yaml : [").unwrap();
1362        let r = check_config_lint(d.path());
1363        assert_eq!(r.worst(), Severity::Warning);
1364    }
1365
1366    // --- Module: exit code roll-up ---
1367
1368    #[test]
1369    fn exit_code_roll_up_ok_is_zero() {
1370        let mut r = DoctorReport::default();
1371        r.findings.push(Finding::ok(CheckKind::ManifestSchema));
1372        assert_eq!(r.exit_code(), 0);
1373    }
1374
1375    #[test]
1376    fn exit_code_roll_up_warning_is_one() {
1377        let mut r = DoctorReport::default();
1378        r.findings.push(Finding::ok(CheckKind::ManifestSchema));
1379        r.findings.push(Finding {
1380            check: CheckKind::GitignoreSync,
1381            severity: Severity::Warning,
1382            pack: None,
1383            detail: String::new(),
1384            auto_fixable: true,
1385            synthetic: false,
1386        });
1387        assert_eq!(r.exit_code(), 1);
1388    }
1389
1390    #[test]
1391    fn exit_code_roll_up_error_is_two() {
1392        let mut r = DoctorReport::default();
1393        r.findings.push(Finding {
1394            check: CheckKind::OnDiskDrift,
1395            severity: Severity::Error,
1396            pack: None,
1397            detail: String::new(),
1398            auto_fixable: false,
1399            synthetic: false,
1400        });
1401        assert_eq!(r.exit_code(), 2);
1402    }
1403
1404    #[test]
1405    fn exit_code_roll_up_warn_and_error_is_two() {
1406        let mut r = DoctorReport::default();
1407        r.findings.push(Finding {
1408            check: CheckKind::GitignoreSync,
1409            severity: Severity::Warning,
1410            pack: None,
1411            detail: String::new(),
1412            auto_fixable: true,
1413            synthetic: false,
1414        });
1415        r.findings.push(Finding {
1416            check: CheckKind::OnDiskDrift,
1417            severity: Severity::Error,
1418            pack: None,
1419            detail: String::new(),
1420            auto_fixable: false,
1421            synthetic: false,
1422        });
1423        assert_eq!(r.exit_code(), 2);
1424    }
1425
1426    // --- Integration: run_doctor orchestrator ---
1427
1428    #[test]
1429    fn run_doctor_clean_workspace_exits_zero() {
1430        let d = tempdir().unwrap();
1431        seed_pack(d.path(), "a");
1432        upsert_managed_block(
1433            &d.path().join(".gitignore"),
1434            "a",
1435            default_managed_gitignore_patterns(),
1436        )
1437        .unwrap();
1438        let report = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
1439        assert_eq!(report.exit_code(), 0);
1440    }
1441
1442    #[test]
1443    fn run_doctor_gitignore_drift_exits_one() {
1444        let d = tempdir().unwrap();
1445        seed_pack(d.path(), "a");
1446        upsert_managed_block(&d.path().join(".gitignore"), "a", &["drift"]).unwrap();
1447        let report = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
1448        assert_eq!(report.exit_code(), 1);
1449    }
1450
1451    #[test]
1452    fn run_doctor_fix_heals_gitignore_drift() {
1453        let d = tempdir().unwrap();
1454        seed_pack(d.path(), "a");
1455        upsert_managed_block(&d.path().join(".gitignore"), "a", &["drift"]).unwrap();
1456        let opts = DoctorOpts { fix: true, lint_config: false, ..DoctorOpts::default() };
1457        let report = run_doctor(d.path(), &opts).unwrap();
1458        assert_eq!(report.exit_code(), 0, "fix must zero out exit code");
1459        // Confirm idempotence: running again without --fix also returns 0.
1460        let again = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
1461        assert_eq!(again.exit_code(), 0);
1462    }
1463
1464    #[test]
1465    fn run_doctor_fix_does_not_touch_manifest_on_schema_error() {
1466        let d = tempdir().unwrap();
1467        // Seed a corrupt manifest (line 1 garbage, line 2 valid).
1468        let m = d.path().join(".grex/events.jsonl");
1469        fs::create_dir_all(m.parent().unwrap()).unwrap();
1470        fs::write(&m, b"garbage-line\n").unwrap();
1471        append_event(
1472            &m,
1473            &Event::Add {
1474                ts: ts(),
1475                id: "x".into(),
1476                url: "u".into(),
1477                path: "x".into(),
1478                pack_type: "declarative".into(),
1479                schema_version: SCHEMA_VERSION.into(),
1480            },
1481        )
1482        .unwrap();
1483        let before_bytes = fs::read(&m).unwrap();
1484        let before = fs_snapshot(d.path());
1485
1486        let opts = DoctorOpts { fix: true, lint_config: false, ..DoctorOpts::default() };
1487        let report = run_doctor(d.path(), &opts).unwrap();
1488        assert_eq!(report.exit_code(), 2, "schema error → exit 2");
1489
1490        // SAFETY CRITICAL: --fix must NOT touch the manifest OR any
1491        // other file on schema errors. The recursive snapshot proves
1492        // no stray write happened anywhere in the fixture.
1493        let after_bytes = fs::read(&m).unwrap();
1494        assert_eq!(before_bytes, after_bytes, "manifest bytes must be unchanged");
1495        let after = fs_snapshot(d.path());
1496        assert_eq!(before, after, "--fix must not write anywhere on schema error");
1497    }
1498
1499    #[test]
1500    fn run_doctor_fix_does_not_touch_disk_on_drift_error() {
1501        let d = tempdir().unwrap();
1502        seed_pack(d.path(), "a");
1503        // Delete the pack dir → on-disk drift error.
1504        fs::remove_dir_all(d.path().join("a")).unwrap();
1505
1506        // SAFETY CRITICAL: --fix must NOT write anywhere in the
1507        // workspace on drift error — not the missing dir, not
1508        // `.grex/events.jsonl`, not a stray `.gitignore`, nothing. A recursive
1509        // path+bytes snapshot catches any such write, not just the
1510        // presence/absence of the missing pack dir.
1511        let before = fs_snapshot(d.path());
1512
1513        let opts = DoctorOpts { fix: true, lint_config: false, ..DoctorOpts::default() };
1514        let report = run_doctor(d.path(), &opts).unwrap();
1515        assert_eq!(report.exit_code(), 2);
1516
1517        let after = fs_snapshot(d.path());
1518        assert_eq!(before, after, "--fix must not write anywhere on drift error");
1519        assert!(!d.path().join("a").exists(), "missing pack dir must stay missing");
1520    }
1521
1522    #[test]
1523    fn run_doctor_config_lint_skipped_by_default() {
1524        let d = tempdir().unwrap();
1525        seed_pack(d.path(), "a");
1526        upsert_managed_block(
1527            &d.path().join(".gitignore"),
1528            "a",
1529            default_managed_gitignore_patterns(),
1530        )
1531        .unwrap();
1532        // Seed a broken config.yaml; default run must ignore it.
1533        fs::create_dir_all(d.path().join("openspec")).unwrap();
1534        fs::write(d.path().join("openspec").join("config.yaml"), ": : : [bad").unwrap();
1535        let before = fs_snapshot(d.path());
1536        let report = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
1537        assert_eq!(report.exit_code(), 0, "config-lint must be skipped by default");
1538        assert!(
1539            !report.findings.iter().any(|f| f.check == CheckKind::ConfigLint),
1540            "no ConfigLint finding when --lint-config absent"
1541        );
1542        // SAFETY: read-only run — every byte must be untouched.
1543        let after = fs_snapshot(d.path());
1544        assert_eq!(before, after, "default doctor run must be read-only");
1545    }
1546
1547    #[test]
1548    fn run_doctor_lint_config_flag_reports_config() {
1549        let d = tempdir().unwrap();
1550        seed_pack(d.path(), "a");
1551        upsert_managed_block(
1552            &d.path().join(".gitignore"),
1553            "a",
1554            default_managed_gitignore_patterns(),
1555        )
1556        .unwrap();
1557        fs::create_dir_all(d.path().join("openspec")).unwrap();
1558        fs::write(d.path().join("openspec").join("config.yaml"), ": : : [bad").unwrap();
1559        let opts = DoctorOpts { fix: false, lint_config: true, ..DoctorOpts::default() };
1560        let report = run_doctor(d.path(), &opts).unwrap();
1561        assert_eq!(report.exit_code(), 1);
1562        assert!(report.findings.iter().any(|f| f.check == CheckKind::ConfigLint));
1563    }
1564
1565    // --- v1.1.1: synthetic plain-git children ---
1566
1567    /// A workspace whose lockfile carries a `synthetic: true` entry for
1568    /// pack `a` reports `OK (synthetic)` for it, exits 0, and never
1569    /// emits a missing-manifest finding even though no `.grex/pack.yaml`
1570    /// exists on disk for that pack.
1571    #[test]
1572    fn run_doctor_synthetic_pack_reports_ok_synthetic_and_exits_zero() {
1573        use crate::lockfile::{write_lockfile, LockEntry};
1574        use std::collections::HashMap;
1575
1576        let d = tempdir().unwrap();
1577        seed_pack(d.path(), "a");
1578        upsert_managed_block(
1579            &d.path().join(".gitignore"),
1580            "a",
1581            default_managed_gitignore_patterns(),
1582        )
1583        .unwrap();
1584
1585        // Hand-write a lockfile with `synthetic: true` for pack `a`.
1586        let lock_dir = d.path().join(".grex");
1587        fs::create_dir_all(&lock_dir).unwrap();
1588        let lock_path = lock_dir.join("grex.lock.jsonl");
1589        let mut lock = HashMap::new();
1590        lock.insert(
1591            "a".to_string(),
1592            LockEntry {
1593                id: "a".into(),
1594                path: "a".into(),
1595                sha: "deadbeef".into(),
1596                branch: "main".into(),
1597                installed_at: ts(),
1598                actions_hash: String::new(),
1599                schema_version: "1".into(),
1600                synthetic: true,
1601            },
1602        );
1603        write_lockfile(&lock_path, &lock).unwrap();
1604
1605        // Note: we deliberately do NOT write `<pack>/.grex/pack.yaml`,
1606        // matching the v1.1.1 plain-git-child case.
1607        let report = run_doctor(d.path(), &DoctorOpts::default()).unwrap();
1608        assert_eq!(report.exit_code(), 0, "synthetic-only workspace must exit 0");
1609        let synth: Vec<_> =
1610            report.findings.iter().filter(|f| f.check == CheckKind::SyntheticPack).collect();
1611        assert_eq!(synth.len(), 1, "exactly one synthetic-pack finding");
1612        assert_eq!(synth[0].pack.as_deref(), Some("a"));
1613        assert_eq!(synth[0].detail, "OK (synthetic)");
1614        assert!(synth[0].synthetic, "Finding.synthetic must be true");
1615        assert_eq!(synth[0].severity, Severity::Ok);
1616
1617        // Sanity: nothing in the report claims pack `a` is missing or
1618        // schema-invalid.
1619        for f in &report.findings {
1620            assert!(f.severity != Severity::Error, "no error-severity finding allowed; got: {f:?}",);
1621        }
1622    }
1623
1624    /// A workspace whose `.grex/grex.lock.jsonl` is malformed (one line
1625    /// of invalid JSON) must produce a `Severity::Warning` finding
1626    /// mentioning lockfile corruption. The doctor must still complete
1627    /// — lockfile errors are findings, not orchestration aborts —
1628    /// because operators rely on `grex doctor` to surface root causes,
1629    /// not crash on them.
1630    #[test]
1631    fn run_doctor_corrupt_lockfile_emits_warning_finding() {
1632        let d = tempdir().unwrap();
1633        // Seed a clean schema-and-gitignore baseline so the corruption
1634        // finding stands out against an otherwise-OK report.
1635        seed_pack(d.path(), "a");
1636        upsert_managed_block(
1637            &d.path().join(".gitignore"),
1638            "a",
1639            default_managed_gitignore_patterns(),
1640        )
1641        .unwrap();
1642
1643        // Hand-write a malformed lockfile: one line of invalid JSON.
1644        let lock_dir = d.path().join(".grex");
1645        fs::create_dir_all(&lock_dir).unwrap();
1646        fs::write(lock_dir.join("grex.lock.jsonl"), b"not-json-at-all\n").unwrap();
1647
1648        let report = run_doctor(d.path(), &DoctorOpts::default())
1649            .expect("doctor must complete despite lockfile corruption");
1650
1651        // The lockfile corruption is reported as a ManifestSchema
1652        // warning whose detail mentions "lockfile corruption".
1653        let lock_warns: Vec<_> = report
1654            .findings
1655            .iter()
1656            .filter(|f| {
1657                f.check == CheckKind::ManifestSchema
1658                    && f.severity == Severity::Warning
1659                    && f.detail.contains("lockfile corruption")
1660            })
1661            .collect();
1662        assert_eq!(
1663            lock_warns.len(),
1664            1,
1665            "exactly one lockfile-corruption warning expected; got: {:?}",
1666            report.findings,
1667        );
1668
1669        // Doctor still completed: report has the usual gitignore /
1670        // drift / synthetic rows even though the lockfile was unusable.
1671        assert!(
1672            report.findings.iter().any(|f| f.check == CheckKind::GitignoreSync),
1673            "gitignore-sync check must still run",
1674        );
1675        assert!(
1676            report.findings.iter().any(|f| f.check == CheckKind::OnDiskDrift),
1677            "on-disk-drift check must still run",
1678        );
1679    }
1680
1681    // --- v1.2.5: --restore-quarantine TS:BASENAME input validation ---
1682
1683    /// Operator pasted a raw ISO-8601 timestamp containing colons into
1684    /// the `--restore-quarantine TS:BASENAME` slot. The CLI splits on
1685    /// the FIRST colon, so `ts` reaches doctor still carrying colons.
1686    /// Doctor must short-circuit with a `QuarantineRestore` Error
1687    /// finding whose detail names the foot-gun rather than dispatching
1688    /// to `restore_quarantine` (which would surface a confusing
1689    /// `SnapshotNotFound`).
1690    #[test]
1691    fn run_doctor_restore_quarantine_rejects_colon_in_timestamp() {
1692        let d = tempdir().unwrap();
1693        seed_pack(d.path(), "a");
1694        upsert_managed_block(
1695            &d.path().join(".gitignore"),
1696            "a",
1697            default_managed_gitignore_patterns(),
1698        )
1699        .unwrap();
1700
1701        let opts = DoctorOpts {
1702            restore_quarantine: Some(("2026-05-02T10:30:00Z".into(), Some("pack-a".into()))),
1703            ..DoctorOpts::default()
1704        };
1705        let report = run_doctor(d.path(), &opts).unwrap();
1706
1707        let restore_findings: Vec<_> =
1708            report.findings.iter().filter(|f| f.check == CheckKind::QuarantineRestore).collect();
1709        assert_eq!(restore_findings.len(), 1, "exactly one restore finding expected");
1710        let f = restore_findings[0];
1711        assert_eq!(f.severity, Severity::Error);
1712        assert!(
1713            f.detail.contains("malformed") && f.detail.contains("FIRST colon"),
1714            "detail must explain the colon foot-gun: {}",
1715            f.detail,
1716        );
1717        assert_eq!(report.exit_code(), 2, "Error severity rolls up to exit 2");
1718    }
1719
1720    // --- v1.2.0 Stage 1.j: recursive ManifestTree walk + --shallow ---
1721
1722    /// Build a meta directory at `meta_dir` with a `pack.yaml` declaring
1723    /// `children`. Each child is `(segment, url)`. The pack type is
1724    /// `meta` so the manifest is shaped like a workspace orchestrator.
1725    fn write_meta_manifest(meta_dir: &Path, name: &str, children: &[(&str, &str)]) {
1726        let grex_dir = meta_dir.join(".grex");
1727        fs::create_dir_all(&grex_dir).unwrap();
1728        let mut yaml = format!("schema_version: \"1\"\nname: {name}\ntype: meta\n");
1729        if !children.is_empty() {
1730            yaml.push_str("children:\n");
1731            for (segment, url) in children {
1732                yaml.push_str(&format!("  - url: {url}\n    path: {segment}\n"));
1733            }
1734        }
1735        fs::write(grex_dir.join("pack.yaml"), yaml).unwrap();
1736    }
1737
1738    /// Build a leaf meta whose own `events.jsonl` registers one pack
1739    /// `pack_id` at sub-path `pack_id` so the per-meta on-disk-drift
1740    /// check sees a clean pack. Does NOT touch the parent.
1741    fn seed_meta_with_pack(meta_dir: &Path, meta_name: &str, pack_id: &str) {
1742        write_meta_manifest(meta_dir, meta_name, &[]);
1743        let m = meta_dir.join(".grex").join("events.jsonl");
1744        append_event(
1745            &m,
1746            &Event::Add {
1747                ts: ts(),
1748                id: pack_id.into(),
1749                url: format!("https://example/{pack_id}"),
1750                path: pack_id.into(),
1751                pack_type: "declarative".into(),
1752                schema_version: SCHEMA_VERSION.into(),
1753            },
1754        )
1755        .unwrap();
1756        fs::create_dir_all(meta_dir.join(pack_id)).unwrap();
1757    }
1758
1759    /// AC: by default, doctor walks every nested meta. A 3-level tree
1760    /// (root → alpha → gamma) yields one ManifestSchema finding per
1761    /// meta (3 total).
1762    #[test]
1763    fn test_doctor_recurses_default() {
1764        let d = tempdir().unwrap();
1765        let root = d.path();
1766
1767        // Root meta declares child `alpha`.
1768        write_meta_manifest(root, "root", &[("alpha", "https://example.invalid/alpha.git")]);
1769        // Root's events.jsonl registers `alpha` so on-disk-drift is clean.
1770        let m = root.join(".grex").join("events.jsonl");
1771        append_event(
1772            &m,
1773            &Event::Add {
1774                ts: ts(),
1775                id: "alpha".into(),
1776                url: "https://example.invalid/alpha.git".into(),
1777                path: "alpha".into(),
1778                pack_type: "meta".into(),
1779                schema_version: SCHEMA_VERSION.into(),
1780            },
1781        )
1782        .unwrap();
1783
1784        // Alpha meta declares child `gamma`.
1785        let alpha = root.join("alpha");
1786        write_meta_manifest(&alpha, "alpha", &[("gamma", "https://example.invalid/gamma.git")]);
1787        let am = alpha.join(".grex").join("events.jsonl");
1788        append_event(
1789            &am,
1790            &Event::Add {
1791                ts: ts(),
1792                id: "gamma".into(),
1793                url: "https://example.invalid/gamma.git".into(),
1794                path: "gamma".into(),
1795                pack_type: "declarative".into(),
1796                schema_version: SCHEMA_VERSION.into(),
1797            },
1798        )
1799        .unwrap();
1800        fs::create_dir_all(alpha.join("gamma")).unwrap();
1801
1802        // Gamma is a leaf meta with one registered pack `delta`.
1803        let gamma = alpha.join("gamma");
1804        seed_meta_with_pack(&gamma, "gamma", "delta");
1805
1806        let report = run_doctor(root, &DoctorOpts::default()).unwrap();
1807
1808        let schema_oks: Vec<_> = report
1809            .findings
1810            .iter()
1811            .filter(|f| f.check == CheckKind::ManifestSchema && f.severity == Severity::Ok)
1812            .collect();
1813        assert_eq!(
1814            schema_oks.len(),
1815            3,
1816            "three metas visited (root + alpha + gamma); got: {:?}",
1817            report.findings,
1818        );
1819    }
1820
1821    /// AC: `--shallow 0` halts at the root meta — only one
1822    /// ManifestSchema finding even when the root has nested metas.
1823    #[test]
1824    fn test_doctor_shallow_zero_root_only() {
1825        let d = tempdir().unwrap();
1826        let root = d.path();
1827
1828        write_meta_manifest(root, "root", &[("alpha", "https://example.invalid/alpha.git")]);
1829        let m = root.join(".grex").join("events.jsonl");
1830        append_event(
1831            &m,
1832            &Event::Add {
1833                ts: ts(),
1834                id: "alpha".into(),
1835                url: "https://example.invalid/alpha.git".into(),
1836                path: "alpha".into(),
1837                pack_type: "meta".into(),
1838                schema_version: SCHEMA_VERSION.into(),
1839            },
1840        )
1841        .unwrap();
1842        let alpha = root.join("alpha");
1843        seed_meta_with_pack(&alpha, "alpha", "leaf");
1844
1845        let opts = DoctorOpts { shallow: Some(0), ..DoctorOpts::default() };
1846        let report = run_doctor(root, &opts).unwrap();
1847        let schema_oks: Vec<_> = report
1848            .findings
1849            .iter()
1850            .filter(|f| f.check == CheckKind::ManifestSchema && f.severity == Severity::Ok)
1851            .collect();
1852        assert_eq!(schema_oks.len(), 1, "shallow=0 must halt at root; got: {:?}", report.findings,);
1853    }
1854
1855    /// AC: `--shallow 1` visits root + depth-1 metas but not deeper.
1856    /// A 3-level tree (root → alpha → gamma) yields 2 ManifestSchema
1857    /// findings (root + alpha) under shallow=1.
1858    #[test]
1859    fn test_doctor_shallow_n_stops_at_n() {
1860        let d = tempdir().unwrap();
1861        let root = d.path();
1862
1863        write_meta_manifest(root, "root", &[("alpha", "https://example.invalid/alpha.git")]);
1864        let m = root.join(".grex").join("events.jsonl");
1865        append_event(
1866            &m,
1867            &Event::Add {
1868                ts: ts(),
1869                id: "alpha".into(),
1870                url: "https://example.invalid/alpha.git".into(),
1871                path: "alpha".into(),
1872                pack_type: "meta".into(),
1873                schema_version: SCHEMA_VERSION.into(),
1874            },
1875        )
1876        .unwrap();
1877
1878        let alpha = root.join("alpha");
1879        write_meta_manifest(&alpha, "alpha", &[("gamma", "https://example.invalid/gamma.git")]);
1880        let am = alpha.join(".grex").join("events.jsonl");
1881        append_event(
1882            &am,
1883            &Event::Add {
1884                ts: ts(),
1885                id: "gamma".into(),
1886                url: "https://example.invalid/gamma.git".into(),
1887                path: "gamma".into(),
1888                pack_type: "meta".into(),
1889                schema_version: SCHEMA_VERSION.into(),
1890            },
1891        )
1892        .unwrap();
1893        fs::create_dir_all(alpha.join("gamma")).unwrap();
1894
1895        let gamma = alpha.join("gamma");
1896        seed_meta_with_pack(&gamma, "gamma", "delta");
1897
1898        let opts = DoctorOpts { shallow: Some(1), ..DoctorOpts::default() };
1899        let report = run_doctor(root, &opts).unwrap();
1900        let schema_oks: Vec<_> = report
1901            .findings
1902            .iter()
1903            .filter(|f| f.check == CheckKind::ManifestSchema && f.severity == Severity::Ok)
1904            .collect();
1905        assert_eq!(
1906            schema_oks.len(),
1907            2,
1908            "shallow=1 must visit root + depth-1; got: {:?}",
1909            report.findings,
1910        );
1911    }
1912
1913    /// AC: doctor performs zero filesystem mutations on a multi-level
1914    /// tree even when sub-meta gitignores have drift. Read-only by
1915    /// contract — only the root frame's `--fix` is allowed to write.
1916    /// Here `--fix` is OFF, so every byte of the fixture is preserved.
1917    #[test]
1918    fn test_doctor_no_fs_mutations() {
1919        let d = tempdir().unwrap();
1920        let root = d.path();
1921
1922        // Root meta with declared child `alpha`.
1923        write_meta_manifest(root, "root", &[("alpha", "https://example.invalid/alpha.git")]);
1924        let m = root.join(".grex").join("events.jsonl");
1925        append_event(
1926            &m,
1927            &Event::Add {
1928                ts: ts(),
1929                id: "alpha".into(),
1930                url: "https://example.invalid/alpha.git".into(),
1931                path: "alpha".into(),
1932                pack_type: "meta".into(),
1933                schema_version: SCHEMA_VERSION.into(),
1934            },
1935        )
1936        .unwrap();
1937
1938        // Alpha meta carries DRIFT in its own .gitignore — its managed
1939        // block body deviates from the expected list. The recursive
1940        // walk must observe it (Warning finding) but mutate nothing.
1941        let alpha = root.join("alpha");
1942        seed_meta_with_pack(&alpha, "alpha", "leaf");
1943        upsert_managed_block(&alpha.join(".gitignore"), "leaf", &["drifted-pattern"]).unwrap();
1944
1945        let before = fs_snapshot(root);
1946        let report = run_doctor(root, &DoctorOpts::default()).unwrap();
1947        let after = fs_snapshot(root);
1948
1949        assert_eq!(before, after, "recursive doctor walk must perform zero writes");
1950        // Sanity: the sub-meta drift was actually observed (so the
1951        // mutation-check isn't passing trivially because the walker
1952        // didn't recurse).
1953        assert!(
1954            report
1955                .findings
1956                .iter()
1957                .any(|f| f.check == CheckKind::GitignoreSync && f.severity == Severity::Warning),
1958            "expected sub-meta gitignore-drift warning; got: {:?}",
1959            report.findings,
1960        );
1961    }
1962
1963    // --- Property: exit code roll-up invariant ---
1964
1965    proptest::proptest! {
1966        #![proptest_config(proptest::prelude::ProptestConfig { cases: 128, ..Default::default() })]
1967
1968        #[test]
1969        fn prop_exit_code_matches_worst_severity(
1970            severities in proptest::collection::vec(0u8..3, 0..20)
1971        ) {
1972            let mut r = DoctorReport::default();
1973            for s in &severities {
1974                let sev = match s {
1975                    0 => Severity::Ok,
1976                    1 => Severity::Warning,
1977                    _ => Severity::Error,
1978                };
1979                r.findings.push(Finding {
1980                    check: CheckKind::ManifestSchema,
1981                    severity: sev,
1982                    pack: None,
1983                    detail: String::new(),
1984                    auto_fixable: false,
1985                    synthetic: false,
1986                });
1987            }
1988            let worst = severities.iter().max().copied().unwrap_or(0);
1989            let expected = match worst { 0 => 0, 1 => 1, _ => 2 };
1990            proptest::prop_assert_eq!(r.exit_code(), expected);
1991        }
1992    }
1993}