feat(stream-d): kei-runtime — discover + validate + lint (invoke stub)
New crate _primitives/_rust/kei-runtime/ implementing §Runtime invocation contract from locked substrate schema. CLI (clap-derive): - list-atoms [--root] [--crate] [--kind] → walk + print - invoke <atom-id> --input <json|@file> → discover + validate input (stub exec) - schema-lint [--root] [--crate] → 6-check validator - pipe <dag.toml> → "not yet implemented" stub Modules (≤ 200 LOC each, largest lint.rs @ 171): - src/discover.rs — walk_atoms walks <root>/*/atoms/*.md, parses frontmatter - src/validate.rs — JSONSchema draft-07 via jsonschema 0.17.1 - src/invoke.rs — MVP stub: discover → parse → validate_input → boundary ack - src/lint.rs — 6 checks: required fields, kind enum, side_effects shape, schema path existence + draft-07 declaration, wikilink resolution - src/main.rs — clap CLI, exit 0|1|2 per §Runtime contract Intentional stub boundary: invoke returns structured JSON ack (exit 0), wire-up to concrete atom impls deferred to integration pass (needs Stream B atoms landed first). Registered kei-runtime in workspace members. Tests: 2/2 integration smoke (lint_smoke, discover_smoke) green. Stream D of substrate v1 parallel build. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9f6ba0cbfc
commit
d68fddb59a
11 changed files with 834 additions and 1 deletions
157
_primitives/_rust/Cargo.lock
generated
157
_primitives/_rust/Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ members = [
|
|||
"kei-artifact",
|
||||
# v0.18 exobrain CLI
|
||||
"keisei",
|
||||
# Substrate v1 — atom invocation runtime + schema linter
|
||||
"kei-runtime",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
|
|
|||
30
_primitives/_rust/kei-runtime/Cargo.toml
Normal file
30
_primitives/_rust/kei-runtime/Cargo.toml
Normal file
|
|
@ -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"
|
||||
92
_primitives/_rust/kei-runtime/src/discover.rs
Normal file
92
_primitives/_rust/kei-runtime/src/discover.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
//! Atom discovery — walks `<root>/*/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 `<root>/*/atoms/*.md`. Returns one `AtomMeta` per parseable file.
|
||||
pub fn walk_atoms(root: &Path) -> Vec<AtomMeta> {
|
||||
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<AtomMeta, String> {
|
||||
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()))
|
||||
}
|
||||
60
_primitives/_rust/kei-runtime/src/invoke.rs
Normal file
60
_primitives/_rust/kei-runtime/src/invoke.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
//! Atom invocation — MVP stub.
|
||||
//!
|
||||
//! Boundary: discovery + schema validation are wired. Actual atom execution
|
||||
//! shells out to `<crate> <verb>` 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<Output, InvokeError> {
|
||||
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<AtomMeta, InvokeError> {
|
||||
walk_atoms(root)
|
||||
.into_iter()
|
||||
.find(|a| a.full_id == atom_id)
|
||||
.ok_or_else(|| InvokeError::AtomNotFound(atom_id.to_string()))
|
||||
}
|
||||
14
_primitives/_rust/kei-runtime/src/lib.rs
Normal file
14
_primitives/_rust/kei-runtime/src/lib.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
//! kei-runtime — atom invocation runtime + schema linter.
|
||||
//!
|
||||
//! Four modules:
|
||||
//! - `discover` — walks `<root>/*/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;
|
||||
171
_primitives/_rust/kei-runtime/src/lint.rs
Normal file
171
_primitives/_rust/kei-runtime/src/lint.rs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
//! `schema-lint` — correctness pass over every `atoms/*.md` under `<root>`.
|
||||
//!
|
||||
//! 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<String>,
|
||||
pub failed: Vec<(String, Vec<String>)>,
|
||||
}
|
||||
|
||||
/// Run the full lint over `<root>/*/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<PathBuf> {
|
||||
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<String> {
|
||||
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::<YamlValue>(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<String>) -> Result<(), Vec<String>> {
|
||||
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<String>) {
|
||||
for field in REQUIRED_FIELDS {
|
||||
if fm.get(field).is_none() {
|
||||
errs.push(format!("missing {field}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_kind(fm: &YamlValue, errs: &mut Vec<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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::<serde_json::Value>(&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<String>, errs: &mut Vec<String>) {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
135
_primitives/_rust/kei-runtime/src/main.rs
Normal file
135
_primitives/_rust/kei-runtime/src/main.rs
Normal file
|
|
@ -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<PathBuf>,
|
||||
#[arg(long = "crate")]
|
||||
crate_name: Option<String>,
|
||||
#[arg(long)]
|
||||
kind: Option<String>,
|
||||
},
|
||||
/// Invoke one atom (MVP stub — see docs).
|
||||
Invoke {
|
||||
atom_id: String,
|
||||
#[arg(long)]
|
||||
input: String,
|
||||
#[arg(long)]
|
||||
root: Option<PathBuf>,
|
||||
},
|
||||
/// Lint every `atoms/*.md` under --root for schema correctness.
|
||||
SchemaLint {
|
||||
#[arg(long)]
|
||||
root: Option<PathBuf>,
|
||||
#[arg(long = "crate")]
|
||||
crate_name: Option<String>,
|
||||
},
|
||||
/// 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>) -> 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<String>, kind: Option<String>) -> 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<String, String> {
|
||||
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<String>) -> 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(" | "));
|
||||
}
|
||||
}
|
||||
45
_primitives/_rust/kei-runtime/src/validate.rs
Normal file
45
_primitives/_rust/kei-runtime/src/validate.rs
Normal file
|
|
@ -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::<Vec<_>>().join("; ");
|
||||
return Err(ValidationError(msg));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
50
_primitives/_rust/kei-runtime/tests/discover_smoke.rs
Normal file
50
_primitives/_rust/kei-runtime/tests/discover_smoke.rs
Normal file
|
|
@ -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"));
|
||||
}
|
||||
79
_primitives/_rust/kei-runtime/tests/lint_smoke.rs
Normal file
79
_primitives/_rust/kei-runtime/tests/lint_smoke.rs
Normal file
|
|
@ -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}");
|
||||
}
|
||||
Loading…
Reference in a new issue