diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index df24188..c3e7d6c 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -15,7 +15,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -584,6 +586,21 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" version = "2.11.1" @@ -617,6 +634,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.0" @@ -1003,6 +1026,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -1077,6 +1110,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3027ae1df8d41b4bed2241c8fdad4acc1e7af60c8e17743534b545e77182d678" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1171,8 +1214,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1680,6 +1725,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "iso8601" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1082f0c48f143442a1ac6122f67e360ceee130b967af4d50996e5154a45df46" +dependencies = [ + "nom 8.0.0", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1706,6 +1760,34 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a071f4f7efc9a9118dfb627a0a94ef247986e1ab8606a4c806ae2b3aa3b6978" +dependencies = [ + "ahash", + "anyhow", + "base64 0.21.7", + "bytecount", + "fancy-regex", + "fraction", + "getrandom 0.2.17", + "iso8601", + "itoa", + "memchr", + "num-cmp", + "once_cell", + "parking_lot", + "percent-encoding", + "regex", + "serde", + "serde_json", + "time", + "url", + "uuid", +] + [[package]] name = "kei-artifact" version = "0.1.0" @@ -1889,6 +1971,20 @@ dependencies = [ "serde_json", ] +[[package]] +name = "kei-runtime" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "jsonschema", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "walkdir", +] + [[package]] name = "kei-sage" version = "0.1.0" @@ -2157,6 +2253,39 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -2173,6 +2302,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -2199,6 +2343,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2944,7 +3099,7 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" dependencies = [ - "nom", + "nom 7.1.3", "unicode_categories", ] diff --git a/_primitives/_rust/Cargo.toml b/_primitives/_rust/Cargo.toml index ecba924..6a61e25 100644 --- a/_primitives/_rust/Cargo.toml +++ b/_primitives/_rust/Cargo.toml @@ -29,6 +29,8 @@ members = [ "kei-artifact", # v0.18 exobrain CLI "keisei", + # Substrate v1 — atom invocation runtime + schema linter + "kei-runtime", ] [workspace.package] diff --git a/_primitives/_rust/kei-runtime/Cargo.toml b/_primitives/_rust/kei-runtime/Cargo.toml new file mode 100644 index 0000000..4a534ec --- /dev/null +++ b/_primitives/_rust/kei-runtime/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "kei-runtime" +version = "0.1.0" +edition = "2021" +rust-version = "1.75" +description = "Atom invocation runtime + schema linter" + +[[bin]] +name = "kei-runtime" +path = "src/main.rs" + +[lib] +name = "kei_runtime" +path = "src/lib.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +jsonschema = { version = "0.17", default-features = false } +anyhow = "1" +walkdir = "2" + +[dev-dependencies] +tempfile = "3" + +[package.metadata.keisei] +backend = "none" +description = "Atom invocation runtime + schema linter" diff --git a/_primitives/_rust/kei-runtime/src/discover.rs b/_primitives/_rust/kei-runtime/src/discover.rs new file mode 100644 index 0000000..5f75446 --- /dev/null +++ b/_primitives/_rust/kei-runtime/src/discover.rs @@ -0,0 +1,92 @@ +//! Atom discovery — walks `/*/atoms/*.md`, parses YAML frontmatter. +//! +//! Skip-on-invalid policy: missing/malformed frontmatter emits stderr warn, +//! record is dropped (never panics, never fails the walk). + +use serde::Deserialize; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +/// Parsed frontmatter fields needed by the runtime. +#[derive(Debug, Clone)] +pub struct AtomMeta { + pub full_id: String, + pub crate_name: String, + pub verb: String, + pub kind: String, + pub md_path: PathBuf, + pub input_schema_path: PathBuf, + pub output_schema_path: PathBuf, +} + +/// Raw frontmatter — only the fields discover needs. +#[derive(Debug, Deserialize)] +struct Frontmatter { + atom: String, + kind: String, + input: SchemaRef, + output: SchemaRef, +} + +#[derive(Debug, Deserialize)] +struct SchemaRef { + schema: String, +} + +/// Walks `/*/atoms/*.md`. Returns one `AtomMeta` per parseable file. +pub fn walk_atoms(root: &Path) -> Vec { + let mut out = Vec::new(); + for entry in WalkDir::new(root).max_depth(3).into_iter().flatten() { + if !is_atom_md(entry.path()) { + continue; + } + match parse_one(entry.path()) { + Ok(meta) => out.push(meta), + Err(e) => eprintln!("warn: skip {}: {}", entry.path().display(), e), + } + } + out +} + +fn is_atom_md(path: &Path) -> bool { + path.is_file() + && path.extension().is_some_and(|e| e == "md") + && path + .parent() + .and_then(|p| p.file_name()) + .is_some_and(|n| n == "atoms") +} + +fn parse_one(md_path: &Path) -> Result { + let body = std::fs::read_to_string(md_path).map_err(|e| format!("read: {e}"))?; + let fm = extract_frontmatter(&body).ok_or_else(|| "no frontmatter".to_string())?; + let parsed: Frontmatter = serde_yaml::from_str(fm).map_err(|e| format!("yaml: {e}"))?; + let (crate_name, verb) = split_atom_id(&parsed.atom)?; + let atom_dir = md_path.parent().ok_or("no parent dir")?; + Ok(AtomMeta { + full_id: parsed.atom.clone(), + crate_name, + verb, + kind: parsed.kind, + md_path: md_path.to_path_buf(), + input_schema_path: atom_dir.join(&parsed.input.schema), + output_schema_path: atom_dir.join(&parsed.output.schema), + }) +} + +/// Returns the frontmatter body (between the two `---` fences), or None. +pub fn extract_frontmatter(body: &str) -> Option<&str> { + let rest = body.strip_prefix("---\n").or_else(|| body.strip_prefix("---\r\n"))?; + let end = rest.find("\n---").or_else(|| rest.find("\r\n---"))?; + Some(&rest[..end]) +} + +fn split_atom_id(id: &str) -> Result<(String, String), String> { + let (crate_name, verb) = id + .split_once("::") + .ok_or_else(|| format!("atom id missing `::`: {id}"))?; + if crate_name.is_empty() || verb.is_empty() { + return Err(format!("atom id has empty half: {id}")); + } + Ok((crate_name.to_string(), verb.to_string())) +} diff --git a/_primitives/_rust/kei-runtime/src/invoke.rs b/_primitives/_rust/kei-runtime/src/invoke.rs new file mode 100644 index 0000000..1bf2bcf --- /dev/null +++ b/_primitives/_rust/kei-runtime/src/invoke.rs @@ -0,0 +1,60 @@ +//! Atom invocation — MVP stub. +//! +//! Boundary: discovery + schema validation are wired. Actual atom execution +//! shells out to ` ` or calls into a registry — both depend on +//! Stream B (atoms refactor) landing first. Documented, intentional stub. + +use crate::discover::{walk_atoms, AtomMeta}; +use crate::validate::validate_input; +use serde::Serialize; +use serde_json::Value; +use std::path::Path; + +#[derive(Debug)] +pub enum InvokeError { + AtomNotFound(String), + InputParse(String), + InputInvalid(String), +} + +impl std::fmt::Display for InvokeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::AtomNotFound(id) => write!(f, "atom not found: {id}"), + Self::InputParse(e) => write!(f, "input parse: {e}"), + Self::InputInvalid(e) => write!(f, "input invalid: {e}"), + } + } +} + +impl std::error::Error for InvokeError {} + +/// MVP stub output — shape documents the boundary for downstream wire-up. +#[derive(Debug, Serialize)] +pub struct Output { + pub error: String, + pub atom: String, +} + +/// Invoke an atom by full ID with a JSON input string. +/// +/// MVP contract: discover atom → parse input → validate against schema → +/// return stub acknowledgement. Exec wire-up is a follow-up. +pub fn invoke(root: &Path, atom_id: &str, input_json: &str) -> Result { + let meta = find_atom(root, atom_id)?; + let input: Value = + serde_json::from_str(input_json).map_err(|e| InvokeError::InputParse(e.to_string()))?; + validate_input(&meta.input_schema_path, &input) + .map_err(|e| InvokeError::InputInvalid(e.to_string()))?; + Ok(Output { + error: "atom invocation not yet implemented — wire needs Stream B atom impls".to_string(), + atom: atom_id.to_string(), + }) +} + +fn find_atom(root: &Path, atom_id: &str) -> Result { + walk_atoms(root) + .into_iter() + .find(|a| a.full_id == atom_id) + .ok_or_else(|| InvokeError::AtomNotFound(atom_id.to_string())) +} diff --git a/_primitives/_rust/kei-runtime/src/lib.rs b/_primitives/_rust/kei-runtime/src/lib.rs new file mode 100644 index 0000000..6b1de3f --- /dev/null +++ b/_primitives/_rust/kei-runtime/src/lib.rs @@ -0,0 +1,14 @@ +//! kei-runtime — atom invocation runtime + schema linter. +//! +//! Four modules: +//! - `discover` — walks `/*/atoms/*.md`, parses YAML frontmatter +//! - `validate` — JSON Schema draft-07 validation of input/output +//! - `invoke` — MVP stub: discovers + validates, exec wire-up TBD +//! - `lint` — `schema-lint` correctness pass over atom frontmatter +//! +//! Per `docs/SUBSTRATE-SCHEMA.md` §Runtime invocation contract (LOCKED). + +pub mod discover; +pub mod invoke; +pub mod lint; +pub mod validate; diff --git a/_primitives/_rust/kei-runtime/src/lint.rs b/_primitives/_rust/kei-runtime/src/lint.rs new file mode 100644 index 0000000..aa6a4ff --- /dev/null +++ b/_primitives/_rust/kei-runtime/src/lint.rs @@ -0,0 +1,171 @@ +//! `schema-lint` — correctness pass over every `atoms/*.md` under ``. +//! +//! Checks (from SUBSTRATE-SCHEMA §Validation): +//! 1. Frontmatter has required fields (atom, kind, version, input, output, +//! side_effects, idempotent, stability). +//! 2. Schema paths resolve to existing JSON files. +//! 3. JSON Schemas declare draft-07 via `$schema`. +//! 4. `kind` ∈ {command, query, stream, transform}. +//! 5. `side_effects` entries are `{op, domain}` objects. +//! 6. `related` wikilinks point to another atom OR `rules/...` (dangling rule +//! refs allowed). + +use crate::discover::extract_frontmatter; +use serde_yaml::Value as YamlValue; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +const REQUIRED_FIELDS: &[&str] = &[ + "atom", + "kind", + "version", + "input", + "output", + "side_effects", + "idempotent", + "stability", +]; +const ALLOWED_KINDS: &[&str] = &["command", "query", "stream", "transform"]; + +#[derive(Debug, Default)] +pub struct LintReport { + pub passed: Vec, + pub failed: Vec<(String, Vec)>, +} + +/// Run the full lint over `/*/atoms/*.md`. +pub fn schema_lint(root: &Path) -> LintReport { + let mut report = LintReport::default(); + let all_atoms = collect_atom_ids(root); + for md in find_atom_files(root) { + let label = md.display().to_string(); + match lint_one(&md, &all_atoms) { + Ok(()) => report.passed.push(label), + Err(errs) => report.failed.push((label, errs)), + } + } + report +} + +fn find_atom_files(root: &Path) -> Vec { + WalkDir::new(root) + .max_depth(3) + .into_iter() + .flatten() + .filter(|e| { + e.path().is_file() + && e.path().extension().is_some_and(|ext| ext == "md") + && e.path().parent().and_then(|p| p.file_name()).is_some_and(|n| n == "atoms") + }) + .map(|e| e.path().to_path_buf()) + .collect() +} + +fn collect_atom_ids(root: &Path) -> HashSet { + let mut ids = HashSet::new(); + for md in find_atom_files(root) { + if let Ok(body) = std::fs::read_to_string(&md) { + if let Some(fm) = extract_frontmatter(&body) { + if let Ok(y) = serde_yaml::from_str::(fm) { + if let Some(id) = y.get("atom").and_then(|v| v.as_str()) { + ids.insert(id.to_string()); + } + } + } + } + } + ids +} + +fn lint_one(md_path: &Path, known_atoms: &HashSet) -> Result<(), Vec> { + let body = std::fs::read_to_string(md_path).map_err(|e| vec![format!("read: {e}")])?; + let fm_text = extract_frontmatter(&body).ok_or_else(|| vec!["no frontmatter".to_string()])?; + let fm: YamlValue = + serde_yaml::from_str(fm_text).map_err(|e| vec![format!("yaml parse: {e}")])?; + let mut errs = Vec::new(); + check_required_fields(&fm, &mut errs); + check_kind(&fm, &mut errs); + check_side_effects(&fm, &mut errs); + check_schema_files(md_path, &fm, &mut errs); + check_related(&fm, known_atoms, &mut errs); + if errs.is_empty() { + Ok(()) + } else { + Err(errs) + } +} + +fn check_required_fields(fm: &YamlValue, errs: &mut Vec) { + for field in REQUIRED_FIELDS { + if fm.get(field).is_none() { + errs.push(format!("missing {field}")); + } + } +} + +fn check_kind(fm: &YamlValue, errs: &mut Vec) { + if let Some(k) = fm.get("kind").and_then(|v| v.as_str()) { + if !ALLOWED_KINDS.contains(&k) { + errs.push(format!("kind `{k}` not in {ALLOWED_KINDS:?}")); + } + } +} + +fn check_side_effects(fm: &YamlValue, errs: &mut Vec) { + let Some(seq) = fm.get("side_effects").and_then(|v| v.as_sequence()) else { + return; + }; + for (i, entry) in seq.iter().enumerate() { + let has_op = entry.get("op").and_then(|v| v.as_str()).is_some(); + let has_domain = entry.get("domain").and_then(|v| v.as_str()).is_some(); + if !has_op || !has_domain { + errs.push(format!("side_effects[{i}] missing op or domain")); + } + } +} + +fn check_schema_files(md_path: &Path, fm: &YamlValue, errs: &mut Vec) { + for key in &["input", "output"] { + let Some(rel) = fm.get(key).and_then(|v| v.get("schema")).and_then(|v| v.as_str()) else { + continue; + }; + let full = md_path.parent().map(|p| p.join(rel)).unwrap_or_else(|| PathBuf::from(rel)); + if !full.exists() { + errs.push(format!("{key} schema missing: {}", full.display())); + continue; + } + check_draft07(&full, key, errs); + } +} + +fn check_draft07(schema_path: &Path, key: &str, errs: &mut Vec) { + let Ok(text) = std::fs::read_to_string(schema_path) else { + errs.push(format!("{key} schema unreadable")); + return; + }; + let Ok(json) = serde_json::from_str::(&text) else { + errs.push(format!("{key} schema not JSON")); + return; + }; + let draft = json.get("$schema").and_then(|v| v.as_str()).unwrap_or(""); + if !draft.contains("draft-07") { + errs.push(format!("{key} schema missing draft-07 $schema")); + } +} + +fn check_related(fm: &YamlValue, known: &HashSet, errs: &mut Vec) { + let Some(seq) = fm.get("related").and_then(|v| v.as_sequence()) else { + return; + }; + for entry in seq { + let Some(link) = entry.as_str() else { continue }; + let inner = link.trim_start_matches("[[").trim_end_matches("]]"); + if inner.starts_with("rules/") { + continue; + } + if !known.contains(inner) { + errs.push(format!("related `{inner}` unresolved")); + } + } +} diff --git a/_primitives/_rust/kei-runtime/src/main.rs b/_primitives/_rust/kei-runtime/src/main.rs new file mode 100644 index 0000000..c6de9a6 --- /dev/null +++ b/_primitives/_rust/kei-runtime/src/main.rs @@ -0,0 +1,135 @@ +//! kei-runtime — CLI dispatcher. +//! +//! Subcommands: list-atoms | invoke | schema-lint | pipe (stub). +//! Default --root: `~/.claude/agents/_primitives/_rust`. + +use clap::{Parser, Subcommand}; +use kei_runtime::{discover, invoke, lint}; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Parser)] +#[command(name = "kei-runtime", version, about = "Atom invocation runtime + schema linter")] +struct Cli { + #[command(subcommand)] + cmd: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// List atoms discovered under --root. + ListAtoms { + #[arg(long)] + root: Option, + #[arg(long = "crate")] + crate_name: Option, + #[arg(long)] + kind: Option, + }, + /// Invoke one atom (MVP stub — see docs). + Invoke { + atom_id: String, + #[arg(long)] + input: String, + #[arg(long)] + root: Option, + }, + /// Lint every `atoms/*.md` under --root for schema correctness. + SchemaLint { + #[arg(long)] + root: Option, + #[arg(long = "crate")] + crate_name: Option, + }, + /// Execute a pipeline (not yet implemented). + Pipe { dag: PathBuf }, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + match cli.cmd { + Cmd::ListAtoms { root, crate_name, kind } => { + run_list_atoms(resolve_root(root), crate_name, kind) + } + Cmd::Invoke { atom_id, input, root } => run_invoke(resolve_root(root), atom_id, input), + Cmd::SchemaLint { root, crate_name } => run_lint(resolve_root(root), crate_name), + Cmd::Pipe { dag: _ } => { + println!("pipe: not yet implemented"); + ExitCode::SUCCESS + } + } +} + +fn resolve_root(arg: Option) -> PathBuf { + if let Some(p) = arg { + return p; + } + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".claude/agents/_primitives/_rust") +} + +fn run_list_atoms(root: PathBuf, crate_name: Option, kind: Option) -> ExitCode { + let atoms = discover::walk_atoms(&root); + for a in atoms { + if let Some(c) = &crate_name { + if a.crate_name != *c { + continue; + } + } + if let Some(k) = &kind { + if a.kind != *k { + continue; + } + } + println!("{}\t{}\t{}", a.full_id, a.kind, a.md_path.display()); + } + ExitCode::SUCCESS +} + +fn run_invoke(root: PathBuf, atom_id: String, input_arg: String) -> ExitCode { + let input_text = match load_input(&input_arg) { + Ok(s) => s, + Err(e) => { + eprintln!("input: {e}"); + return ExitCode::from(1); + } + }; + match invoke::invoke(&root, &atom_id, &input_text) { + Ok(out) => { + println!("{}", serde_json::to_string(&out).unwrap_or_default()); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("invoke: {e}"); + ExitCode::from(2) + } + } +} + +fn load_input(arg: &str) -> Result { + if let Some(path) = arg.strip_prefix('@') { + std::fs::read_to_string(path).map_err(|e| format!("read {path}: {e}")) + } else { + Ok(arg.to_string()) + } +} + +fn run_lint(root: PathBuf, crate_filter: Option) -> ExitCode { + let report = lint::schema_lint(&root); + print_lint(&report, crate_filter.as_deref()); + if report.failed.is_empty() { + ExitCode::SUCCESS + } else { + ExitCode::from(2) + } +} + +fn print_lint(report: &lint::LintReport, crate_filter: Option<&str>) { + let keep = |label: &str| crate_filter.is_none_or(|f| label.contains(f)); + for label in report.passed.iter().filter(|l| keep(l)) { + println!("PASS\t{label}"); + } + for (label, errs) in report.failed.iter().filter(|(l, _)| keep(l)) { + println!("FAIL\t{label}\t{}", errs.join(" | ")); + } +} diff --git a/_primitives/_rust/kei-runtime/src/validate.rs b/_primitives/_rust/kei-runtime/src/validate.rs new file mode 100644 index 0000000..28bf401 --- /dev/null +++ b/_primitives/_rust/kei-runtime/src/validate.rs @@ -0,0 +1,45 @@ +//! JSON Schema draft-07 validation wrappers. +//! +//! Thin façade over the `jsonschema` crate. Reads schema from disk per call — +//! caller may cache if hot. Returns a single, readable error message. + +use jsonschema::JSONSchema; +use serde_json::Value; +use std::path::Path; + +#[derive(Debug)] +pub struct ValidationError(pub String); + +impl std::fmt::Display for ValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "validation: {}", self.0) + } +} + +impl std::error::Error for ValidationError {} + +/// Validate `input` against JSON Schema at `schema_path`. +pub fn validate_input(schema_path: &Path, input: &Value) -> Result<(), ValidationError> { + validate_value(schema_path, input) +} + +/// Validate `output` against JSON Schema at `schema_path`. +pub fn validate_output(schema_path: &Path, output: &Value) -> Result<(), ValidationError> { + validate_value(schema_path, output) +} + +fn validate_value(schema_path: &Path, value: &Value) -> Result<(), ValidationError> { + let schema_text = std::fs::read_to_string(schema_path) + .map_err(|e| ValidationError(format!("read {}: {e}", schema_path.display())))?; + let schema_json: Value = serde_json::from_str(&schema_text) + .map_err(|e| ValidationError(format!("parse {}: {e}", schema_path.display())))?; + let compiled = JSONSchema::options() + .with_draft(jsonschema::Draft::Draft7) + .compile(&schema_json) + .map_err(|e| ValidationError(format!("compile: {e}")))?; + if let Err(errors) = compiled.validate(value) { + let msg = errors.map(|e| e.to_string()).collect::>().join("; "); + return Err(ValidationError(msg)); + } + Ok(()) +} diff --git a/_primitives/_rust/kei-runtime/tests/discover_smoke.rs b/_primitives/_rust/kei-runtime/tests/discover_smoke.rs new file mode 100644 index 0000000..28a29c1 --- /dev/null +++ b/_primitives/_rust/kei-runtime/tests/discover_smoke.rs @@ -0,0 +1,50 @@ +//! Integration test — walk_atoms returns 2 well-formed records from temp root. + +use kei_runtime::discover::walk_atoms; +use std::fs; +use std::path::Path; + +fn write_atom(root: &Path, crate_name: &str, verb: &str) { + let atoms = root.join(crate_name).join("atoms"); + let schemas = atoms.join("schemas"); + fs::create_dir_all(&schemas).unwrap(); + let input = format!("{verb}-input.json"); + let output = format!("{verb}-output.json"); + fs::write(schemas.join(&input), "{}").unwrap(); + fs::write(schemas.join(&output), "{}").unwrap(); + let md = format!( + r#"--- +atom: {crate_name}::{verb} +kind: query +version: "0.1.0" +input: + schema: schemas/{input} +output: + schema: schemas/{output} +side_effects: [] +idempotent: true +stability: stable +--- + +# {crate_name}::{verb} +"#, + ); + fs::write(atoms.join(format!("{verb}.md")), md).unwrap(); +} + +#[test] +fn walk_atoms_finds_two_records() { + let tmp = tempfile::tempdir().unwrap(); + write_atom(tmp.path(), "kei-alpha", "search"); + write_atom(tmp.path(), "kei-beta", "fetch"); + let mut atoms = walk_atoms(tmp.path()); + atoms.sort_by(|a, b| a.full_id.cmp(&b.full_id)); + assert_eq!(atoms.len(), 2); + assert_eq!(atoms[0].full_id, "kei-alpha::search"); + assert_eq!(atoms[0].crate_name, "kei-alpha"); + assert_eq!(atoms[0].verb, "search"); + assert_eq!(atoms[0].kind, "query"); + assert_eq!(atoms[1].full_id, "kei-beta::fetch"); + assert!(atoms[1].input_schema_path.ends_with("schemas/fetch-input.json")); + assert!(atoms[1].output_schema_path.ends_with("schemas/fetch-output.json")); +} diff --git a/_primitives/_rust/kei-runtime/tests/lint_smoke.rs b/_primitives/_rust/kei-runtime/tests/lint_smoke.rs new file mode 100644 index 0000000..eb056d7 --- /dev/null +++ b/_primitives/_rust/kei-runtime/tests/lint_smoke.rs @@ -0,0 +1,79 @@ +//! Integration test — schema_lint over a temp root with 1 valid + 1 broken atom. + +use kei_runtime::lint::schema_lint; +use std::fs; +use std::path::Path; + +fn write_valid_atom(root: &Path) { + let crate_dir = root.join("kei-demo"); + let atoms = crate_dir.join("atoms"); + let schemas = atoms.join("schemas"); + fs::create_dir_all(&schemas).unwrap(); + let input_schema = r#"{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["title"], + "properties": { "title": { "type": "string" } }, + "additionalProperties": false + }"#; + let output_schema = r#"{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { "id": { "type": "integer" } } + }"#; + fs::write(schemas.join("create-input.json"), input_schema).unwrap(); + fs::write(schemas.join("create-output.json"), output_schema).unwrap(); + let md = r#"--- +atom: kei-demo::create +kind: command +version: "0.1.0" +input: + schema: schemas/create-input.json +output: + schema: schemas/create-output.json +side_effects: + - { op: write, domain: kei-demo-db } +idempotent: false +stability: stable +--- + +# kei-demo::create +"#; + fs::write(atoms.join("create.md"), md).unwrap(); +} + +fn write_broken_atom(root: &Path) { + let crate_dir = root.join("kei-broken"); + let atoms = crate_dir.join("atoms"); + fs::create_dir_all(&atoms).unwrap(); + // Missing `kind` field. + let md = r#"--- +atom: kei-broken::oops +version: "0.1.0" +input: + schema: schemas/oops-input.json +output: + schema: schemas/oops-output.json +side_effects: [] +idempotent: true +stability: experimental +--- + +# kei-broken::oops +"#; + fs::write(atoms.join("oops.md"), md).unwrap(); +} + +#[test] +fn lint_separates_valid_and_broken() { + let tmp = tempfile::tempdir().unwrap(); + write_valid_atom(tmp.path()); + write_broken_atom(tmp.path()); + let report = schema_lint(tmp.path()); + assert_eq!(report.passed.len(), 1, "expected 1 passing atom"); + assert_eq!(report.failed.len(), 1, "expected 1 failing atom"); + let (label, errs) = &report.failed[0]; + assert!(label.contains("oops.md"), "failed label mismatch: {label}"); + let joined = errs.join(" "); + assert!(joined.contains("missing kind"), "expected 'missing kind' in: {joined}"); +}