Skip to main content

omne_cli/commands/
validate.rs

1//! `omne validate` — check volume integrity against the v2 layout.
2//!
3//! Walks up from cwd to find `.omne/`, runs integrity checks: required
4//! directories, docs baseline, 1-hop boot chain, core manifest, distro
5//! content, pipe schema validation, depth rule scoped to `lib/cfg/`,
6//! and the legacy gate runner.
7
8use std::path::{Component, Path};
9
10use clap::Args as ClapArgs;
11use path_clean::PathClean;
12use walkdir::WalkDir;
13
14use crate::error::CliError;
15use crate::manifest;
16use crate::python;
17use crate::volume;
18
19/// Maximum nesting depth for user-authored content under `lib/cfg/`.
20/// Measured as path components relative to `.omne/` (includes the
21/// `lib/cfg/` prefix). e.g. `lib/cfg/sub1` = depth 3, allowed.
22const MAX_DEPTH: usize = 3;
23
24/// Required top-level directories under `.omne/`.
25const REQUIRED_DIRS: &[&str] = &["core", "dist", "lib", "var"];
26
27/// Required fields in `.omne/omne.md` frontmatter.
28const REQUIRED_MANIFEST_FIELDS: &[&str] = &[
29    "volume",
30    "distro",
31    "distro-version",
32    "created",
33    "kernel-source",
34    "distro-source",
35];
36
37/// Arguments for `omne validate`.
38#[derive(Debug, ClapArgs)]
39pub struct Args {}
40
41pub fn run(_args: &Args) -> Result<(), CliError> {
42    let cwd = std::env::current_dir()
43        .map_err(|e| CliError::Io(format!("cannot determine current directory: {e}")))?;
44    validate_at_root(&cwd)
45}
46
47/// Test seam: validate a volume rooted at (or walked up from) `start`.
48pub fn validate_at_root(start: &Path) -> Result<(), CliError> {
49    let root = volume::find_omne_root(start).ok_or(CliError::NotAVolume)?;
50    let omne = root.join(".omne");
51
52    let mut issues = Vec::new();
53    check_required_dirs(&omne, &mut issues);
54    check_core(&omne, &mut issues);
55    check_dist(&omne, &mut issues);
56    check_boot_chain(&root, &mut issues);
57    check_docs_baseline(&omne, &mut issues);
58    check_manifest(&omne, &mut issues);
59    check_depth(&omne, &mut issues);
60    check_gate_runner(&omne, &mut issues);
61    check_pipes(&root, &mut issues);
62
63    if issues.is_empty() {
64        eprintln!("\x1b[32mVolume is valid.\x1b[0m");
65        Ok(())
66    } else {
67        Err(CliError::ValidationFailed { issues })
68    }
69}
70
71/// Walk `.omne/dist/pipes/*.md` and surface every parse / structural
72/// / volume-aware validation issue per pipe. Missing `dist/pipes/`
73/// directory is silently skipped — a fresh volume with no distro
74/// pipes is a valid state.
75fn check_pipes(root: &Path, issues: &mut Vec<String>) {
76    let pipes_dir = volume::dist_dir(root).join("pipes");
77    if !pipes_dir.is_dir() {
78        return;
79    }
80    let entries = match std::fs::read_dir(&pipes_dir) {
81        Ok(e) => e,
82        Err(e) => {
83            issues.push(format!("cannot read {}: {e}", pipes_dir.display()));
84            return;
85        }
86    };
87    for entry in entries.flatten() {
88        let path = entry.path();
89        if path.extension().is_none_or(|ext| ext != "md") {
90            continue;
91        }
92        match crate::pipe::load(&path, root) {
93            Ok(pipe) => {
94                for warning in crate::pipe::collect_warnings(&pipe) {
95                    eprintln!("\x1b[33mwarning:\x1b[0m pipe {}: {warning}", path.display());
96                }
97            }
98            Err(crate::pipe::LoadError::Parse(e)) => {
99                issues.push(format!("pipe {}: {e}", path.display()));
100            }
101            Err(crate::pipe::LoadError::Invalid(errs)) => {
102                for err in errs {
103                    issues.push(format!("pipe {}: {err}", path.display()));
104                }
105            }
106        }
107    }
108}
109
110/// Check that all required top-level directories exist under `.omne/`.
111fn check_required_dirs(omne: &Path, issues: &mut Vec<String>) {
112    for &dir in REQUIRED_DIRS {
113        if !omne.join(dir).is_dir() {
114            issues.push(format!("missing required directory: .omne/{dir}/"));
115        }
116    }
117}
118
119/// Check that `.omne/core/manifest.json` exists.
120fn check_core(omne: &Path, issues: &mut Vec<String>) {
121    let core = omne.join("core");
122    if !core.is_dir() {
123        return;
124    }
125    if !core.join("manifest.json").is_file() {
126        issues.push("missing kernel manifest: core/manifest.json".to_string());
127    }
128}
129
130/// Check that `.omne/dist/AGENTS.md` exists (boot chain target).
131fn check_dist(omne: &Path, issues: &mut Vec<String>) {
132    let dist = omne.join("dist");
133    if !dist.is_dir() {
134        return;
135    }
136    if !dist.join("AGENTS.md").is_file() {
137        issues.push("missing distro entry point: dist/AGENTS.md".to_string());
138    }
139}
140
141/// Check the 1-hop boot chain: volume root `CLAUDE.md` must contain
142/// exactly `@.omne/dist/AGENTS.md`. Detects legacy multi-hop chains
143/// and suggests migration.
144fn check_boot_chain(root: &Path, issues: &mut Vec<String>) {
145    let bootloader = root.join("CLAUDE.md");
146    if !bootloader.is_file() {
147        issues.push("missing CLAUDE.md at volume root".to_string());
148        return;
149    }
150    let content = match std::fs::read_to_string(&bootloader) {
151        Ok(c) => c,
152        Err(e) => {
153            issues.push(format!("cannot read CLAUDE.md: {e}"));
154            return;
155        }
156    };
157
158    let imports: Vec<&str> = content
159        .lines()
160        .map(str::trim)
161        .filter(|l| l.starts_with('@'))
162        .collect();
163
164    if imports.is_empty() {
165        issues.push("CLAUDE.md has no @import — expected @.omne/dist/AGENTS.md".to_string());
166        return;
167    }
168
169    let has_v2_import = imports.contains(&"@.omne/dist/AGENTS.md");
170
171    if !has_v2_import {
172        let is_legacy = imports.iter().any(|&l| {
173            l.contains("MANIFEST.md") || l.contains("SYSTEM.md") || l.contains(".omne/image/")
174        });
175        if is_legacy {
176            issues.push(
177                "legacy boot chain detected — run `omne init` to migrate to 1-hop @.omne/dist/AGENTS.md"
178                    .to_string(),
179            );
180        } else {
181            issues.push(format!(
182                "CLAUDE.md @import does not reference .omne/dist/AGENTS.md — found: {}",
183                imports.join(", ")
184            ));
185        }
186    }
187}
188
189/// Warn (not error) if docs baseline is incomplete.
190fn check_docs_baseline(omne: &Path, issues: &mut Vec<String>) {
191    let docs = omne.join("lib").join("docs");
192    if !docs.is_dir() {
193        return;
194    }
195    if !docs.join("index.md").is_file() {
196        eprintln!("\x1b[33mwarning:\x1b[0m missing lib/docs/index.md");
197    }
198    for subdir in ["raw", "inter", "wiki"] {
199        if !docs.join(subdir).is_dir() {
200            eprintln!("\x1b[33mwarning:\x1b[0m missing lib/docs/{subdir}/");
201        }
202    }
203    let _ = issues; // docs baseline is warning-only
204}
205
206/// Check that `.omne/omne.md` exists, has frontmatter, and contains
207/// all required fields.
208fn check_manifest(omne: &Path, issues: &mut Vec<String>) {
209    let readme = omne.join("omne.md");
210    if !readme.is_file() {
211        issues.push("missing .omne/omne.md".to_string());
212        return;
213    }
214
215    let content = match std::fs::read_to_string(&readme) {
216        Ok(c) => c,
217        Err(e) => {
218            issues.push(format!("cannot read .omne/omne.md: {e}"));
219            return;
220        }
221    };
222
223    let yaml_body = match manifest::extract_frontmatter_block(&content) {
224        Ok(body) => body,
225        Err(_) => {
226            issues.push(".omne/omne.md has no YAML frontmatter (---...---)".to_string());
227            return;
228        }
229    };
230
231    for &field in REQUIRED_MANIFEST_FIELDS {
232        let has_field = yaml_body
233            .lines()
234            .any(|line| line.starts_with(field) && line[field.len()..].starts_with(':'));
235        if !has_field {
236            issues.push(format!(".omne/omne.md missing required field: {field}"));
237        }
238    }
239}
240
241/// Check depth of directories under `lib/cfg/` only. MAX_DEPTH = 3.
242/// `core/`, `dist/`, and `lib/docs/` are excluded — release artifacts
243/// and knowledge-base content may have deep nesting.
244fn check_depth(omne: &Path, issues: &mut Vec<String>) {
245    let base = omne.join("lib").join("cfg");
246    if !base.is_dir() {
247        return;
248    }
249    for entry in WalkDir::new(&base).min_depth(1) {
250        let entry = match entry {
251            Ok(e) => e,
252            Err(_) => continue,
253        };
254        if !entry.file_type().is_dir() {
255            continue;
256        }
257        let rel = match entry.path().strip_prefix(omne) {
258            Ok(r) => r,
259            Err(_) => continue,
260        };
261        let depth = rel.components().count();
262        if depth > MAX_DEPTH {
263            issues.push(format!(
264                "depth violation ({depth} > {MAX_DEPTH}): .omne/{}",
265                rel.display()
266            ));
267        }
268    }
269}
270
271/// Read gate_runner from core/manifest.json, validate path safety, and
272/// invoke it with the discovered Python interpreter.
273fn check_gate_runner(omne: &Path, issues: &mut Vec<String>) {
274    let core_manifest = omne.join("core/manifest.json");
275    if !core_manifest.is_file() {
276        return;
277    }
278
279    let content = match std::fs::read_to_string(&core_manifest) {
280        Ok(c) => c,
281        Err(_) => return,
282    };
283
284    let data: serde_json::Value = match serde_json::from_str(&content) {
285        Ok(d) => d,
286        Err(_) => {
287            issues.push("core/manifest.json is invalid JSON".to_string());
288            return;
289        }
290    };
291
292    let gate_runner = match data.get("gate_runner").and_then(|v| v.as_str()) {
293        Some(gr) => gr,
294        None => return,
295    };
296
297    let dist_dir = omne.join("dist");
298    if !is_safe_gate_runner_path(gate_runner, &dist_dir) {
299        issues.push(format!("gate runner path escapes dist/: {gate_runner}"));
300        return;
301    }
302
303    let runner_path = dist_dir.join(gate_runner);
304    if !runner_path.is_file() {
305        eprintln!("\x1b[33mwarning:\x1b[0m gate runner not found: dist/{gate_runner} (skipping)");
306        return;
307    }
308
309    let interpreter = match python::find_interpreter() {
310        Some(interp) => interp,
311        None => {
312            eprintln!("\x1b[33m{}\x1b[0m", python::missing_python_warning());
313            return;
314        }
315    };
316
317    if let Err(e) = python::run_gate_runner(&interpreter, &runner_path, &dist_dir) {
318        match e {
319            python::Error::GateRunnerFailed {
320                exit_code,
321                stdout,
322                stderr,
323            } => {
324                let mut msg = format!("gate runner failed (exit {exit_code}):");
325                for line in stdout.trim().lines() {
326                    msg.push_str(&format!("\n  {line}"));
327                }
328                for line in stderr.trim().lines() {
329                    msg.push_str(&format!("\n  {line}"));
330                }
331                issues.push(msg);
332            }
333            python::Error::GateRunnerTimedOut { elapsed_seconds } => {
334                issues.push(format!(
335                    "gate runner timed out after {elapsed_seconds} seconds"
336                ));
337            }
338            python::Error::InterpreterInvocation(io_err) => {
339                issues.push(format!("failed to invoke gate runner: {io_err}"));
340            }
341        }
342    }
343}
344
345/// Check that a gate runner path is safe (no traversal, no absolute paths).
346fn is_safe_gate_runner_path(gate_runner: &str, base_dir: &Path) -> bool {
347    let path = Path::new(gate_runner);
348
349    if path.is_absolute() {
350        return false;
351    }
352
353    for component in path.components() {
354        match component {
355            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
356                return false;
357            }
358            _ => {}
359        }
360    }
361
362    let resolved = base_dir.join(path).clean();
363    let base_clean = base_dir.clean();
364    resolved.starts_with(&base_clean)
365}