diff --git a/.github/workflows/live-contract.yml b/.github/workflows/live-contract.yml new file mode 100644 index 0000000..3b8aaa5 --- /dev/null +++ b/.github/workflows/live-contract.yml @@ -0,0 +1,25 @@ +# HOOK PARITY note: this workflow is the OPT-IN tier-3 live-contract run +# (#36) — deliberately host-dependent, never part of PR CI (ci.yml is the +# host-independent gate mirrored by .githooks/pre-push). Scheduled so mock +# fixture drift against real CLIs surfaces on a cadence, not at 7am. +name: Live contract + +on: + workflow_dispatch: + schedule: + - cron: "17 9 * * 1" # weekly, Monday + +jobs: + live: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Live-contract tests (tools present on the runner run; absent skip) + env: + MODULEX_LIVE_TESTS: "1" + # gh is preinstalled on GitHub runners; authenticate it with the + # workflow token so the gh --json shape test runs. + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: cargo test -p modulex-core --test live_contract -- --nocapture diff --git a/CLAUDE.md b/CLAUDE.md index 8425f33..2958c8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,10 @@ Standing law from it: for engine faults (bad config, unknown routine, leash denial of the run). 6. **Unit tests never spawn real processes.** Use `exec::test_support::MockSpawner`. Real-subprocess coverage lives in the - dedicated gated integration test only. + dedicated gated integration test plus the opt-in live-contract tier + (`just live-test`, `MODULEX_LIVE_TESTS=1`) which verifies mock fixtures + against real tools — mock fixtures mimicking a real CLI carry a + FIXTURE-SYNC comment citing their live test. ## Build & validate diff --git a/Cargo.lock b/Cargo.lock index cdbf584..153cb21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,11 +66,22 @@ 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", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -245,12 +256,24 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytes" version = "1.11.1" @@ -649,6 +672,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -698,6 +730,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -742,6 +785,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent-uri" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "foldhash" version = "0.2.0" @@ -757,6 +811,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1264,6 +1328,30 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26a960f0c34d5423581d858ce94815cc11f0171b09939409097969ed269ede1b" +dependencies = [ + "ahash", + "base64", + "bytecount", + "email_address", + "fancy-regex", + "fraction", + "idna", + "itoa", + "num-cmp", + "once_cell", + "percent-encoding", + "referencing", + "regex-syntax", + "serde", + "serde_json", + "uuid-simd", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1414,6 +1502,7 @@ dependencies = [ "async-trait", "blake3", "chrono", + "jsonschema", "rusqlite", "serde", "serde_json", @@ -1462,6 +1551,30 @@ 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" @@ -1478,6 +1591,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-integer" version = "0.1.46" @@ -1498,6 +1626,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" @@ -1520,6 +1659,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "p256" version = "0.13.2" @@ -1926,6 +2071,56 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "referencing" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8e15af8558cb157432dd3d88c1d1e982d0a5755cf80ce593b6499260aebc49" +dependencies = [ + "ahash", + "fluent-uri", + "once_cell", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "reqwest" version = "0.12.28" @@ -2749,6 +2944,27 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "uuid", + "vsimd", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -2761,6 +2977,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "want" version = "0.3.1" diff --git a/crates/modulex-cli/src/main.rs b/crates/modulex-cli/src/main.rs index 269277a..7452820 100644 --- a/crates/modulex-cli/src/main.rs +++ b/crates/modulex-cli/src/main.rs @@ -43,6 +43,9 @@ enum Command { /// Emit the report as compact JSON instead of markdown. #[arg(long)] json: bool, + /// Emit ONLY the structured data payloads (the agent-native view). + #[arg(long, conflicts_with = "json")] + data: bool, }, /// Run a single step of a routine (debugging aid). Step { @@ -56,6 +59,9 @@ enum Command { /// Emit JSON. #[arg(long)] json: bool, + /// Emit ONLY the structured data payloads (the agent-native view). + #[arg(long, conflicts_with = "json")] + data: bool, }, /// List configured routines. List, @@ -120,8 +126,10 @@ fn load(config_path: Option<&PathBuf>) -> anyhow::Result<(Engine, PathBuf, Strin Ok((Engine::new(config, registry, granted.caveats), path, banner)) } -fn print_report(report: &modulex_core::Report, json: bool) { - if json { +fn print_report(report: &modulex_core::Report, json: bool, data: bool) { + if data { + println!("{}", report.to_data_json()); + } else if json { println!("{}", report.to_json()); } else { println!("{}", report.to_text()); @@ -158,6 +166,7 @@ async fn run(cli: Cli) -> anyhow::Result { skip, dry_run, json, + data, } => { eprintln!("{banner}"); let report = engine @@ -170,7 +179,7 @@ async fn run(cli: Cli) -> anyhow::Result { }, ) .await?; - print_report(&report, json); + print_report(&report, json, data); Ok(report.success) } Command::Step { @@ -178,10 +187,11 @@ async fn run(cli: Cli) -> anyhow::Result { step, dry_run, json, + data, } => { eprintln!("{banner}"); let report = engine.run_step(&routine, &step, dry_run).await?; - print_report(&report, json); + print_report(&report, json, data); Ok(report.success) } Command::List => { diff --git a/crates/modulex-core/Cargo.toml b/crates/modulex-core/Cargo.toml index 8ec24c1..7ab3023 100644 --- a/crates/modulex-core/Cargo.toml +++ b/crates/modulex-core/Cargo.toml @@ -31,3 +31,8 @@ shell-words = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } toml = { workspace = true } + +[dev-dependencies] +# Schema validation for the data-contract tests ONLY — runtime never +# validates (schemas are contracts, the test suite is the enforcement). +jsonschema = { version = "0.26", default-features = false } diff --git a/crates/modulex-core/src/engine.rs b/crates/modulex-core/src/engine.rs index db744ec..fd685d6 100644 --- a/crates/modulex-core/src/engine.rs +++ b/crates/modulex-core/src/engine.rs @@ -18,7 +18,7 @@ use agent_bridle_core::{Caveats, Gate, Tool, ToolContext, ToolResult}; use async_trait::async_trait; use crate::config::{Config, StepSpec}; -use crate::exec::{program_available, ExecGate, Spawner, TokioSpawner}; +use crate::exec::{ExecGate, Spawner, TokioSpawner}; use crate::registry::StepRegistry; use crate::report::{Report, StepResult}; use crate::step::RunContext; @@ -176,6 +176,12 @@ impl Engine { self.registry.type_names() } + /// Machine-readable step specs: `(type_name, description, data_schema)`. + #[must_use] + pub fn step_specs(&self) -> Vec<(String, String, serde_json::Value)> { + self.registry.specs() + } + /// Configured routines as `(name, description, step_count)`, sorted. #[must_use] pub fn list_routines(&self) -> Vec<(String, String, usize)> { @@ -385,10 +391,11 @@ async fn run_with( }; // Soft-skip probe: a missing external tool skips the step, it does not - // fail the routine. (Dry runs skip the probe — nothing will spawn.) + // fail the routine. Answered by the spawner seam, so mocked engines are + // host-independent. (Dry runs skip the probe — nothing will spawn.) if !cx.dry_run { for program in handler.required_programs(step) { - if !program_available(&program) { + if !cx.exec.program_available(&program) { return StepResult::skip( &step.name, &step.step_type, @@ -536,14 +543,28 @@ type = "no-such-type" #[tokio::test] async fn missing_tool_soft_skips_the_step() { - let engine = engine_with( + // Regression: the probe must come from the spawner seam, not the + // host PATH — mocked engines answer identically on every machine. + let config = Config::from_toml( r#" [[routines.r.steps]] name = "ghost" type = "script" -command = "definitely-not-a-real-binary-xyzzy" +command = "ghost-tool" "#, - vec![], + ) + .unwrap(); + let registry = crate::steps::builtin_registry(); + let declared = config.declared_programs(®istry); + let granted = Caveats { + exec: Scope::only(declared), + ..Caveats::top() + }; + let engine = Engine::with_spawner( + config, + registry, + granted, + Arc::new(MockSpawner::default().missing(["ghost-tool"])), ); let report = engine .run_routine("r", RunOptions::default()) diff --git a/crates/modulex-core/src/exec.rs b/crates/modulex-core/src/exec.rs index 5ac03c2..ae75fd4 100644 --- a/crates/modulex-core/src/exec.rs +++ b/crates/modulex-core/src/exec.rs @@ -134,6 +134,15 @@ pub enum ExecError { pub trait Spawner: Send + Sync { /// Run the request to completion (or timeout) and capture output. async fn spawn(&self, req: &ExecRequest) -> std::io::Result; + + /// Is `program` runnable in this spawner's environment? Drives the + /// engine's soft-skip probe. The probe is an environment dependency, so + /// it lives on the SAME seam as the spawn — a mocked spawner answers for + /// its scripted world, never the host PATH (regression: the contract + /// test skipped per-host depending on which CLIs were installed). + fn program_available(&self, program: &str) -> bool { + program_available(program) + } } /// Real subprocess execution on tokio. @@ -218,6 +227,12 @@ impl ExecGate { &self.cx } + /// Soft-skip probe, answered by the spawner (the environment seam). + #[must_use] + pub fn program_available(&self, program: &str) -> bool { + self.spawner.program_available(program) + } + /// Leash-check, spawn, scrub. The ONLY subprocess path in modulex. /// /// # Errors @@ -282,11 +297,15 @@ pub mod test_support { use super::*; /// Returns canned outputs in order; records every request's program+args. + /// Every program is "available" unless named in `missing` — the mock + /// answers for its scripted world, never the host PATH. #[derive(Default)] pub struct MockSpawner { outputs: Mutex>, /// Recorded `(program, args)` per call, for assertions. pub calls: Mutex)>>, + /// Programs the soft-skip probe reports as absent. + missing: std::collections::HashSet, } impl MockSpawner { @@ -296,9 +315,17 @@ pub mod test_support { Self { outputs: Mutex::new(outputs.into()), calls: Mutex::new(Vec::new()), + missing: std::collections::HashSet::new(), } } + /// Mark programs as absent for the soft-skip probe (builder). + #[must_use] + pub fn missing, S: Into>(mut self, programs: I) -> Self { + self.missing.extend(programs.into_iter().map(Into::into)); + self + } + /// A canned success with this stdout. #[must_use] pub fn ok(stdout: &str) -> ExecOutput { @@ -322,6 +349,10 @@ pub mod test_support { #[async_trait] impl Spawner for MockSpawner { + fn program_available(&self, program: &str) -> bool { + !self.missing.contains(program) + } + async fn spawn(&self, req: &ExecRequest) -> std::io::Result { self.calls .lock() diff --git a/crates/modulex-core/src/registry.rs b/crates/modulex-core/src/registry.rs index b36902f..0627435 100644 --- a/crates/modulex-core/src/registry.rs +++ b/crates/modulex-core/src/registry.rs @@ -40,6 +40,23 @@ impl StepRegistry { pub fn type_names(&self) -> Vec { self.handlers.keys().cloned().collect() } + + /// Full specs for every registered step type, sorted by name: + /// `(type_name, description, data_schema)` — the machine-readable step + /// surface (FOUNDATION pillar A). + #[must_use] + pub fn specs(&self) -> Vec<(String, String, serde_json::Value)> { + self.handlers + .values() + .map(|h| { + ( + h.type_name().to_string(), + h.description().to_string(), + h.data_schema(), + ) + }) + .collect() + } } #[cfg(test)] @@ -57,6 +74,12 @@ mod tests { fn type_name(&self) -> &'static str { self.0 } + fn description(&self) -> &'static str { + "fake" + } + fn data_schema(&self) -> serde_json::Value { + serde_json::json!({}) + } fn required_programs(&self, _spec: &StepSpec) -> Vec { vec![] } diff --git a/crates/modulex-core/src/report.rs b/crates/modulex-core/src/report.rs index 494cc2d..8e70211 100644 --- a/crates/modulex-core/src/report.rs +++ b/crates/modulex-core/src/report.rs @@ -207,6 +207,36 @@ impl Report { pub fn to_json(&self) -> String { serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string()) } + + /// Render the agent-native view: ONLY the structured payloads — no + /// markdown bodies, no per-repo prose. The data contract's `format: + /// "data"` surface (FOUNDATION pillar A). + #[must_use] + pub fn to_data_json(&self) -> String { + let steps: Vec = self + .step_results + .iter() + .map(|r| { + serde_json::json!({ + "name": r.step_name, + "type": r.step_type, + "success": r.success, + "skipped": r.skipped, + "error": r.error, + "data": r.data, + }) + }) + .collect(); + serde_json::to_string(&serde_json::json!({ + "generation": self.generation, + "routine": self.routine, + "dry_run": self.dry_run, + "success": self.success, + "summary": self.summary, + "steps": steps, + })) + .unwrap_or_else(|_| "{}".to_string()) + } } #[cfg(test)] diff --git a/crates/modulex-core/src/step.rs b/crates/modulex-core/src/step.rs index b3f5895..b5658e6 100644 --- a/crates/modulex-core/src/step.rs +++ b/crates/modulex-core/src/step.rs @@ -30,11 +30,29 @@ pub struct RunContext { /// A step implementation, registered in a [`crate::registry::StepRegistry`] /// under [`Self::type_name`]. +/// +/// ## The data contract (FOUNDATION pillar A) +/// +/// Reports serve humans AND agents: the markdown `output` is for the human; +/// [`Self::data_schema`] describes the typed `StepResult::data` payload an +/// executed step emits for agents. **Agents never parse prose.** Schemas are +/// versioned contracts — they are pinned by the golden-schema regression +/// harness, and breaking a shape is a breaking release. Dry-run and skipped +/// results may omit `data`; executed results MUST match the schema. #[async_trait] pub trait StepHandler: Send + Sync { /// The registry key, e.g. `"git-status"`. fn type_name(&self) -> &'static str; + /// One-line human description, surfaced by `steps_list`. + fn description(&self) -> &'static str; + + /// JSON Schema for this step's `StepResult::data` payload (executed, + /// non-skipped results). Passthrough steps (external tools/plugins that + /// own their payload) return a permissive schema and say so in the + /// description. + fn data_schema(&self) -> serde_json::Value; + /// The external programs this step will spawn for `spec` (e.g. `["git"]`). /// Drives the declared-default exec grant and the engine's soft-skip /// probe. Pure steps return an empty list. diff --git a/crates/modulex-core/src/steps/board.rs b/crates/modulex-core/src/steps/board.rs index 6ec30a2..cd33381 100644 --- a/crates/modulex-core/src/steps/board.rs +++ b/crates/modulex-core/src/steps/board.rs @@ -21,6 +21,31 @@ impl StepHandler for BoardScan { "board-scan" } + fn description(&self) -> &'static str { + "Task stems per configured board lane" + } + + fn data_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "required": ["lanes"], + "properties": { + "lanes": { + "type": "array", + "items": { + "type": "object", + "required": ["lane", "found", "tasks"], + "properties": { + "lane": { "type": "string" }, + "found": { "type": "boolean", "description": "lane directory exists" }, + "tasks": { "type": "array", "items": { "type": "string" } } + } + } + } + } + }) + } + fn required_programs(&self, _spec: &StepSpec) -> Vec { vec![] } @@ -28,7 +53,10 @@ impl StepHandler for BoardScan { async fn run(&self, spec: &StepSpec, cx: &RunContext) -> StepResult { let board = &cx.config.board; if board.path.is_empty() { - return StepResult::ok(&spec.name, &spec.step_type, "No board path configured."); + let mut result = + StepResult::ok(&spec.name, &spec.step_type, "No board path configured."); + result.data = Some(serde_json::json!({ "lanes": [] })); + return result; } let board_path = expand_tilde(&board.path); @@ -45,10 +73,14 @@ impl StepHandler for BoardScan { } let mut lines = Vec::new(); + let mut data_lanes = Vec::new(); for lane in &board.lanes { let lane_dir = board_path.join(lane); if !lane_dir.is_dir() { lines.push(format!("### {lane}: (directory not found)")); + data_lanes.push(serde_json::json!({ + "lane": lane, "found": false, "tasks": [], + })); continue; } let mut tasks: Vec = std::fs::read_dir(&lane_dir) @@ -66,12 +98,15 @@ impl StepHandler for BoardScan { .unwrap_or_default(); tasks.sort(); lines.push(format!("### {lane} ({} tasks)", tasks.len())); - for task in tasks { + for task in &tasks { lines.push(format!(" - {task}")); } + data_lanes.push(serde_json::json!({ + "lane": lane, "found": true, "tasks": tasks, + })); } - StepResult::ok( + let mut result = StepResult::ok( &spec.name, &spec.step_type, if lines.is_empty() { @@ -79,7 +114,9 @@ impl StepHandler for BoardScan { } else { lines.join("\n") }, - ) + ); + result.data = Some(serde_json::json!({ "lanes": data_lanes })); + result } } @@ -136,6 +173,32 @@ impl StepHandler for ChoresCheck { "chores-check" } + fn description(&self) -> &'static str { + "Due and overdue chores from `due:` lines in the chores directory" + } + + fn data_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "required": ["overdue", "due_today", "upcoming"], + "properties": { + "overdue": { + "type": "array", + "items": { + "type": "object", + "required": ["chore", "days_overdue"], + "properties": { + "chore": { "type": "string" }, + "days_overdue": { "type": "integer", "minimum": 1 } + } + } + }, + "due_today": { "type": "array", "items": { "type": "string" } }, + "upcoming": { "type": "integer", "minimum": 0 } + } + }) + } + fn required_programs(&self, _spec: &StepSpec) -> Vec { vec![] } @@ -143,7 +206,12 @@ impl StepHandler for ChoresCheck { async fn run(&self, spec: &StepSpec, cx: &RunContext) -> StepResult { let chores = &cx.config.chores; if chores.path.is_empty() { - return StepResult::ok(&spec.name, &spec.step_type, "No chores path configured."); + let mut result = + StepResult::ok(&spec.name, &spec.step_type, "No chores path configured."); + result.data = Some(serde_json::json!({ + "overdue": [], "due_today": [], "upcoming": 0, + })); + return result; } let dir = expand_tilde(&chores.path); @@ -163,9 +231,31 @@ impl StepHandler for ChoresCheck { } let today = chrono::Local::now().date_naive(); - let body = render_chores(&scan_due_items(&dir), today); - StepResult::ok(&spec.name, &spec.step_type, body) + let items = scan_due_items(&dir); + let mut result = StepResult::ok(&spec.name, &spec.step_type, render_chores(&items, today)); + result.data = Some(chores_data(&items, today)); + result + } +} + +/// Typed buckets for the data contract (same bucketing as the renderer). +fn chores_data(items: &[DueItem], today: NaiveDate) -> serde_json::Value { + let mut overdue = Vec::new(); + let mut due_today = Vec::new(); + let mut upcoming = 0usize; + for item in items { + if item.due < today { + overdue.push(serde_json::json!({ + "chore": item.chore, + "days_overdue": (today - item.due).num_days(), + })); + } else if item.due == today { + due_today.push(item.chore.clone()); + } else { + upcoming += 1; + } } + serde_json::json!({ "overdue": overdue, "due_today": due_today, "upcoming": upcoming }) } /// Pure renderer, factored so tests pin `today`. diff --git a/crates/modulex-core/src/steps/dates.rs b/crates/modulex-core/src/steps/dates.rs index 7327b56..462d41d 100644 --- a/crates/modulex-core/src/steps/dates.rs +++ b/crates/modulex-core/src/steps/dates.rs @@ -46,6 +46,38 @@ impl StepHandler for DeadlineCalc { "deadline-calc" } + fn description(&self) -> &'static str { + "Days remaining per configured deadline; past deadlines dropped" + } + + fn data_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "required": ["deadlines", "invalid"], + "properties": { + "deadlines": { + "type": "array", + "items": { + "type": "object", + "required": ["label", "date", "days_left"], + "properties": { + "label": { "type": "string" }, + "date": { "type": "string", "description": "ISO YYYY-MM-DD" }, + "end_date": { "type": ["string", "null"] }, + "days_left": { "type": "integer", "minimum": 0 }, + "notes": { "type": "string" } + } + } + }, + "invalid": { + "type": "array", + "items": { "type": "string" }, + "description": "labels with unparsable dates" + } + } + }) + } + fn required_programs(&self, _spec: &StepSpec) -> Vec { vec![] } @@ -53,7 +85,10 @@ impl StepHandler for DeadlineCalc { async fn run(&self, spec: &StepSpec, cx: &RunContext) -> StepResult { let deadlines = &cx.config.deadlines; if deadlines.is_empty() { - return StepResult::ok(&spec.name, &spec.step_type, "No deadlines configured."); + let mut result = + StepResult::ok(&spec.name, &spec.step_type, "No deadlines configured."); + result.data = Some(serde_json::json!({ "deadlines": [], "invalid": [] })); + return result; } if cx.dry_run { let labels: Vec<&str> = deadlines.iter().map(|d| d.label.as_str()).collect(); @@ -67,42 +102,105 @@ impl StepHandler for DeadlineCalc { ); } - let output = render_deadlines(deadlines, today()); - StepResult::ok(&spec.name, &spec.step_type, output) + let computed = compute_deadlines(deadlines, today()); + let mut result = StepResult::ok(&spec.name, &spec.step_type, render_deadlines(&computed)); + result.data = Some(deadlines_data(&computed)); + result } } -/// Pure renderer, factored so tests pin `today`. -fn render_deadlines(deadlines: &[crate::config::DeadlineEntry], today: NaiveDate) -> String { - let mut lines = Vec::new(); +/// One computed upcoming deadline (or an invalid entry). +enum DeadlineRow { + Upcoming { + label: String, + date: String, + end_date: Option, + days_left: i64, + notes: String, + }, + Invalid(String), +} + +/// Pure computation, factored so tests pin `today`. Past deadlines dropped. +fn compute_deadlines( + deadlines: &[crate::config::DeadlineEntry], + today: NaiveDate, +) -> Vec { + let mut rows = Vec::new(); for dl in deadlines { let Some(target) = parse_iso(&dl.date) else { - lines.push(format!(" {:<30} invalid date", dl.label)); + rows.push(DeadlineRow::Invalid(dl.label.clone())); continue; }; if target < today { continue; // past deadline } - let days_left = (target - today).num_days(); - let weeks = days_left / 7; - let date_str = match &dl.end_date { - Some(end) => format!("{} to {end}", dl.date), - None => dl.date.clone(), - }; - let suffix = if weeks > 0 { - format!(" ({weeks} weeks)") - } else { - String::new() - }; - let notes = if dl.notes.is_empty() { - String::new() - } else { - format!(" — {}", dl.notes) - }; - lines.push(format!( - " {:<30} {date_str:<25} {days_left} days{suffix}{notes}", - dl.label - )); + rows.push(DeadlineRow::Upcoming { + label: dl.label.clone(), + date: dl.date.clone(), + end_date: dl.end_date.clone(), + days_left: (target - today).num_days(), + notes: dl.notes.clone(), + }); + } + rows +} + +fn deadlines_data(rows: &[DeadlineRow]) -> serde_json::Value { + let mut deadlines = Vec::new(); + let mut invalid = Vec::new(); + for row in rows { + match row { + DeadlineRow::Upcoming { + label, + date, + end_date, + days_left, + notes, + } => deadlines.push(serde_json::json!({ + "label": label, "date": date, "end_date": end_date, + "days_left": days_left, "notes": notes, + })), + DeadlineRow::Invalid(label) => invalid.push(label.clone()), + } + } + serde_json::json!({ "deadlines": deadlines, "invalid": invalid }) +} + +fn render_deadlines(rows: &[DeadlineRow]) -> String { + let mut lines = Vec::new(); + for row in rows { + match row { + DeadlineRow::Invalid(label) => { + lines.push(format!(" {label:<30} invalid date")); + } + DeadlineRow::Upcoming { + label, + date, + end_date, + days_left, + notes, + } => { + let weeks = days_left / 7; + let date_str = match end_date { + Some(end) => format!("{date} to {end}"), + None => date.clone(), + }; + let suffix = if weeks > 0 { + format!(" ({weeks} weeks)") + } else { + String::new() + }; + let notes = if notes.is_empty() { + String::new() + } else { + format!(" — {notes}") + }; + lines.push(format!( + " {label:<30} {date_str:<25} {days_left} days{suffix}{notes}" + )); + } + } } if lines.is_empty() { "(no upcoming deadlines)".to_string() @@ -121,6 +219,38 @@ impl StepHandler for CountdownCalc { "countdown-calc" } + fn description(&self) -> &'static str { + "Elapsed work days per countdown (config + store entries merged)" + } + + fn data_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "required": ["countdowns", "invalid"], + "properties": { + "countdowns": { + "type": "array", + "items": { + "type": "object", + "required": ["label", "n", "total"], + "properties": { + "label": { "type": "string" }, + "n": { "type": "integer", "description": "work days elapsed" }, + "total": { "type": "integer" }, + "display_line": { "type": "string" }, + "role": { "type": "string" } + } + } + }, + "invalid": { + "type": "array", + "items": { "type": "string" }, + "description": "labels with unparsable dates" + } + } + }) + } + fn required_programs(&self, _spec: &StepSpec) -> Vec { vec![] } @@ -142,7 +272,10 @@ impl StepHandler for CountdownCalc { } } if countdowns.is_empty() { - return StepResult::ok(&spec.name, &spec.step_type, "No countdowns configured."); + let mut result = + StepResult::ok(&spec.name, &spec.step_type, "No countdowns configured."); + result.data = Some(serde_json::json!({ "countdowns": [], "invalid": [] })); + return result; } if cx.dry_run { let labels: Vec<&str> = countdowns.iter().map(|c| c.label.as_str()).collect(); @@ -156,31 +289,90 @@ impl StepHandler for CountdownCalc { ); } - let output = render_countdowns(&countdowns, today()); - StepResult::ok(&spec.name, &spec.step_type, output) + let computed = compute_countdowns(&countdowns, today()); + let mut result = StepResult::ok(&spec.name, &spec.step_type, render_countdowns(&computed)); + result.data = Some(countdowns_data(&computed)); + result } } -/// Pure renderer, factored so tests pin `today`. -fn render_countdowns(countdowns: &[crate::config::CountdownEntry], today: NaiveDate) -> String { - let mut lines = Vec::new(); +/// One computed active countdown (or an invalid entry). +enum CountdownRow { + Active { + label: String, + n: u32, + total: u32, + display_line: String, + role: String, + }, + Invalid(String), +} + +/// Pure computation, factored so tests pin `today`. Expired entries dropped. +fn compute_countdowns( + countdowns: &[crate::config::CountdownEntry], + today: NaiveDate, +) -> Vec { + let mut rows = Vec::new(); for cd in countdowns { let (Some(start), Some(end)) = (parse_iso(&cd.start_date), parse_iso(&cd.end_date)) else { - lines.push(format!("- {}: invalid dates", cd.label)); + rows.push(CountdownRow::Invalid(cd.label.clone())); continue; }; if today > end { continue; // expired } let n = work_days_between(start, today); - let display = cd + let display_line = cd .display .replace("{label}", &cd.label) .replace("{n}", &n.to_string()) .replace("{total}", &cd.total_work_days.to_string()); - lines.push(display); - if !cd.role.is_empty() { - lines.push(format!(" Role: {}", cd.role)); + rows.push(CountdownRow::Active { + label: cd.label.clone(), + n, + total: cd.total_work_days, + display_line, + role: cd.role.clone(), + }); + } + rows +} + +fn countdowns_data(rows: &[CountdownRow]) -> serde_json::Value { + let mut countdowns = Vec::new(); + let mut invalid = Vec::new(); + for row in rows { + match row { + CountdownRow::Active { + label, + n, + total, + display_line, + role, + } => countdowns.push(serde_json::json!({ + "label": label, "n": n, "total": total, + "display_line": display_line, "role": role, + })), + CountdownRow::Invalid(label) => invalid.push(label.clone()), + } + } + serde_json::json!({ "countdowns": countdowns, "invalid": invalid }) +} + +fn render_countdowns(rows: &[CountdownRow]) -> String { + let mut lines = Vec::new(); + for row in rows { + match row { + CountdownRow::Invalid(label) => lines.push(format!("- {label}: invalid dates")), + CountdownRow::Active { + display_line, role, .. + } => { + lines.push(display_line.clone()); + if !role.is_empty() { + lines.push(format!(" Role: {role}")); + } + } } } if lines.is_empty() { @@ -238,7 +430,7 @@ mod tests { notes: String::new(), }, ]; - let out = render_deadlines(&deadlines, date("2026-06-05")); + let out = render_deadlines(&compute_deadlines(&deadlines, date("2026-06-05"))); assert!(out.contains("soon")); assert!(out.contains("5 days")); assert!(out.contains("— submit")); @@ -258,7 +450,7 @@ mod tests { notes: String::new(), }]; assert_eq!( - render_deadlines(&deadlines, date("2026-06-05")), + render_deadlines(&compute_deadlines(&deadlines, date("2026-06-05"))), "(no upcoming deadlines)" ); } @@ -284,7 +476,7 @@ mod tests { }, ]; // Friday 2026-06-05: Mon..Thu elapsed = 4 work days before today. - let out = render_countdowns(&countdowns, date("2026-06-05")); + let out = render_countdowns(&compute_countdowns(&countdowns, date("2026-06-05"))); assert!(out.contains("Ramp: work day 4 of 30")); assert!(out.contains("Role: pilot")); assert!(!out.contains("Done")); diff --git a/crates/modulex-core/src/steps/git.rs b/crates/modulex-core/src/steps/git.rs index 7dc6824..1f13792 100644 --- a/crates/modulex-core/src/steps/git.rs +++ b/crates/modulex-core/src/steps/git.rs @@ -34,10 +34,13 @@ async fn run_git( /// Fan a per-repo closure across the step's repo list, sequentially when the /// step is not parallel (the engine already parallelizes across *steps*; /// in-step fan-out stays simple and ordered). +/// +/// `per_repo` returns the human-facing [`RepoResult`] plus the typed `state` +/// enum for the data contract. async fn fan_out(spec: &StepSpec, cx: &RunContext, per_repo: F) -> StepResult where F: Fn(String) -> Fut, - Fut: std::future::Future, + Fut: std::future::Future, { let repos = repos_for(spec, &cx.config); if repos.is_empty() { @@ -45,8 +48,15 @@ where } let mut repo_results = Vec::with_capacity(repos.len()); + let mut data_repos = Vec::with_capacity(repos.len()); for repo in repos { - repo_results.push(per_repo(repo).await); + let (rr, state) = per_repo(repo).await; + data_repos.push(serde_json::json!({ + "repo": rr.repo, + "state": state, + "detail": rr.error.clone().unwrap_or_else(|| rr.output.clone()), + })); + repo_results.push(rr); } let mut lines = Vec::new(); @@ -59,6 +69,7 @@ where } let body = lines.join("\n"); let mut result = StepResult::ok(&spec.name, &spec.step_type, body).with_repos(repo_results); + let mut data = serde_json::json!({ "repos": data_repos }); if spec.step_type == "git-tend" { // Prepend the tend summary line. let total = result.repo_results.len(); @@ -69,10 +80,36 @@ where summary.push_str(&format!(", {failed} failed")); } result.output = format!("{summary}\n{}", result.output); + data["summary"] = serde_json::json!({ "tended": tended, "failed": failed, "total": total }); + } + if !cx.dry_run { + result.data = Some(data); } result } +/// Shared schema fragment: the per-repo data array. +fn repos_schema(states: &[&str], extra_desc: &str) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "required": ["repos"], + "properties": { + "repos": { + "type": "array", + "items": { + "type": "object", + "required": ["repo", "state", "detail"], + "properties": { + "repo": { "type": "string" }, + "state": { "type": "string", "enum": states }, + "detail": { "type": "string", "description": extra_desc } + } + } + } + } + }) +} + /// `git-status`: `git status --short` per repo; empty output renders `(clean)`. pub struct GitStatus; @@ -82,6 +119,17 @@ impl StepHandler for GitStatus { "git-status" } + fn description(&self) -> &'static str { + "Working-tree status per repo (git status --short)" + } + + fn data_schema(&self) -> serde_json::Value { + repos_schema( + &["clean", "dirty", "error"], + "short-status lines when dirty; error text on error", + ) + } + fn required_programs(&self, _spec: &StepSpec) -> Vec { vec!["git".into()] } @@ -89,22 +137,25 @@ impl StepHandler for GitStatus { async fn run(&self, spec: &StepSpec, cx: &RunContext) -> StepResult { fan_out(spec, cx, |repo| async move { if cx.dry_run { - return RepoResult::ok(&repo, "[dry-run] would run: git status --short"); + return ( + RepoResult::ok(&repo, "[dry-run] would run: git status --short"), + "clean", + ); } match run_git(&cx.exec, &repo, &["status", "--short"], spec.timeout).await { Ok(out) if out.success() => { let trimmed = out.stdout.trim(); - RepoResult::ok( - &repo, - if trimmed.is_empty() { - "(clean)" - } else { - trimmed - }, - ) + if trimmed.is_empty() { + (RepoResult::ok(&repo, "(clean)"), "clean") + } else { + (RepoResult::ok(&repo, trimmed), "dirty") + } } - Ok(out) => RepoResult::err(&repo, nonempty(&out.stderr, "git error")), - Err(e) => RepoResult::err(&repo, e.to_string()), + Ok(out) => ( + RepoResult::err(&repo, nonempty(&out.stderr, "git error")), + "error", + ), + Err(e) => (RepoResult::err(&repo, e.to_string()), "error"), } }) .await @@ -121,6 +172,17 @@ impl StepHandler for GitUnpushed { "git-unpushed" } + fn description(&self) -> &'static str { + "Local commits not yet pushed, per repo" + } + + fn data_schema(&self) -> serde_json::Value { + repos_schema( + &["all-pushed", "unpushed", "no-upstream", "error"], + "oneline commit list when unpushed", + ) + } + fn required_programs(&self, _spec: &StepSpec) -> Vec { vec!["git".into()] } @@ -128,7 +190,10 @@ impl StepHandler for GitUnpushed { async fn run(&self, spec: &StepSpec, cx: &RunContext) -> StepResult { fan_out(spec, cx, |repo| async move { if cx.dry_run { - return RepoResult::ok(&repo, "[dry-run] would run: git log @{u}..HEAD --oneline"); + return ( + RepoResult::ok(&repo, "[dry-run] would run: git log @{u}..HEAD --oneline"), + "all-pushed", + ); } match run_git( &cx.exec, @@ -140,25 +205,25 @@ impl StepHandler for GitUnpushed { { Ok(out) if out.success() => { let trimmed = out.stdout.trim(); - RepoResult::ok( - &repo, - if trimmed.is_empty() { - "(all pushed)" - } else { - trimmed - }, - ) + if trimmed.is_empty() { + (RepoResult::ok(&repo, "(all pushed)"), "all-pushed") + } else { + (RepoResult::ok(&repo, trimmed), "unpushed") + } } Ok(out) => { let msg = out.stderr.trim().to_string(); let lower = msg.to_lowercase(); if lower.contains("no upstream") || lower.contains("fatal") { - RepoResult::ok(&repo, "(no upstream configured)") + ( + RepoResult::ok(&repo, "(no upstream configured)"), + "no-upstream", + ) } else { - RepoResult::err(&repo, nonempty(&msg, "git error")) + (RepoResult::err(&repo, nonempty(&msg, "git error")), "error") } } - Err(e) => RepoResult::err(&repo, e.to_string()), + Err(e) => (RepoResult::err(&repo, e.to_string()), "error"), } }) .await @@ -176,6 +241,35 @@ impl StepHandler for GitTend { "git-tend" } + fn description(&self) -> &'static str { + "Fetch + fast-forward pull per repo; conflicts reported, never resolved" + } + + fn data_schema(&self) -> serde_json::Value { + let mut schema = repos_schema( + &[ + "ok", + "no-tracking", + "diverged", + "fetch-failed", + "pull-failed", + "error", + ], + "last pull line on ok; failure reason otherwise", + ); + schema["required"] = serde_json::json!(["repos", "summary"]); + schema["properties"]["summary"] = serde_json::json!({ + "type": "object", + "required": ["tended", "failed", "total"], + "properties": { + "tended": { "type": "integer" }, + "failed": { "type": "integer" }, + "total": { "type": "integer" } + } + }); + schema + } + fn required_programs(&self, _spec: &StepSpec) -> Vec { vec!["git".into()] } @@ -183,9 +277,12 @@ impl StepHandler for GitTend { async fn run(&self, spec: &StepSpec, cx: &RunContext) -> StepResult { fan_out(spec, cx, |repo| async move { if cx.dry_run { - return RepoResult::ok( - &repo, - "[dry-run] would run: git fetch --all --prune && git pull --ff-only", + return ( + RepoResult::ok( + &repo, + "[dry-run] would run: git fetch --all --prune && git pull --ff-only", + ), + "ok", ); } let fetch = match run_git( @@ -197,20 +294,23 @@ impl StepHandler for GitTend { .await { Ok(out) => out, - Err(e) => return RepoResult::err(&repo, e.to_string()), + Err(e) => return (RepoResult::err(&repo, e.to_string()), "error"), }; if !fetch.success() { - return RepoResult::err( - &repo, - format!( - "fetch failed: {}", - nonempty(fetch.stderr.trim(), "git error") + return ( + RepoResult::err( + &repo, + format!( + "fetch failed: {}", + nonempty(fetch.stderr.trim(), "git error") + ), ), + "fetch-failed", ); } let pull = match run_git(&cx.exec, &repo, &["pull", "--ff-only"], spec.timeout).await { Ok(out) => out, - Err(e) => return RepoResult::err(&repo, e.to_string()), + Err(e) => return (RepoResult::err(&repo, e.to_string()), "error"), }; if pull.success() { let line = pull @@ -220,20 +320,29 @@ impl StepHandler for GitTend { .last() .unwrap_or("ok") .to_string(); - return RepoResult::ok(&repo, line); + return (RepoResult::ok(&repo, line), "ok"); } let msg = pull.stderr.trim().to_lowercase(); if msg.contains("not possible to fast-forward") || msg.contains("diverg") || msg.contains("would be overwritten") { - RepoResult::err(&repo, "diverged from upstream — manual resolution needed") + ( + RepoResult::err(&repo, "diverged from upstream — manual resolution needed"), + "diverged", + ) } else if msg.contains("no tracking") || msg.contains("no such ref") { - RepoResult::ok(&repo, "(no tracking branch — fetched only)") + ( + RepoResult::ok(&repo, "(no tracking branch — fetched only)"), + "no-tracking", + ) } else { - RepoResult::err( - &repo, - format!("pull failed: {}", nonempty(pull.stderr.trim(), "git error")), + ( + RepoResult::err( + &repo, + format!("pull failed: {}", nonempty(pull.stderr.trim(), "git error")), + ), + "pull-failed", ) } }) diff --git a/crates/modulex-core/src/steps/github.rs b/crates/modulex-core/src/steps/github.rs index 1bf1afc..79bc342 100644 --- a/crates/modulex-core/src/steps/github.rs +++ b/crates/modulex-core/src/steps/github.rs @@ -22,23 +22,39 @@ fn is_auth_error(stderr: &str) -> bool { || lower.contains("gh auth login") } -/// Render one `gh pr list --json number,title,author,updatedAt` payload. -fn render_prs(json_text: &str) -> Result { +/// Parse one `gh pr list --json number,title,author,updatedAt` payload into +/// typed records for the data contract. +fn parse_prs(json_text: &str) -> Result, String> { let prs: Vec = serde_json::from_str(json_text).map_err(|e| format!("unexpected gh output: {e}"))?; + Ok(prs + .iter() + .map(|pr| { + serde_json::json!({ + "number": pr["number"].as_u64().unwrap_or(0), + "title": pr["title"].as_str().unwrap_or("(untitled)"), + "author": pr["author"]["login"].as_str().unwrap_or("?"), + }) + }) + .collect()) +} + +/// Render typed PR records as compact report lines. +fn render_prs(prs: &[Value]) -> String { if prs.is_empty() { - return Ok("(no open PRs)".to_string()); + return "(no open PRs)".to_string(); } - let lines: Vec = prs - .iter() + prs.iter() .map(|pr| { - let number = pr["number"].as_u64().unwrap_or(0); - let title = pr["title"].as_str().unwrap_or("(untitled)"); - let author = pr["author"]["login"].as_str().unwrap_or("?"); - format!("#{number} {title} ({author})") + format!( + "#{} {} ({})", + pr["number"], + pr["title"].as_str().unwrap_or(""), + pr["author"].as_str().unwrap_or("") + ) }) - .collect(); - Ok(lines.join("\n")) + .collect::>() + .join("\n") } /// `github-pr-scan`: open PRs per configured `owner/repo` slug. @@ -50,6 +66,44 @@ impl StepHandler for GithubPrScan { "github-pr-scan" } + fn description(&self) -> &'static str { + "Open pull requests per configured repository" + } + + fn data_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "required": ["repos"], + "properties": { + "repos": { + "type": "array", + "items": { + "type": "object", + "required": ["repo", "state", "prs"], + "properties": { + "repo": { "type": "string" }, + "state": { "type": "string", + "enum": ["ok", "auth-failed", "error"] }, + "prs": { + "type": "array", + "items": { + "type": "object", + "required": ["number", "title", "author"], + "properties": { + "number": { "type": "integer" }, + "title": { "type": "string" }, + "author": { "type": "string" } + } + } + }, + "detail": { "type": "string" } + } + } + } + } + }) + } + fn required_programs(&self, _spec: &StepSpec) -> Vec { vec!["gh".into()] } @@ -84,6 +138,7 @@ impl StepHandler for GithubPrScan { }; let mut repo_results = Vec::with_capacity(repos.len()); + let mut data_repos = Vec::with_capacity(repos.len()); let mut auth_failures = 0usize; for repo in &repos { let args: Vec = [ @@ -111,6 +166,10 @@ impl StepHandler for GithubPrScan { { Ok(out) => out, Err(e) => { + data_repos.push(serde_json::json!({ + "repo": repo, "state": "error", "prs": [], + "detail": e.to_string(), + })); repo_results.push(RepoResult::err(repo, e.to_string())); continue; } @@ -119,6 +178,9 @@ impl StepHandler for GithubPrScan { let err = out.stderr.trim().to_string(); if is_auth_error(&err) { auth_failures += 1; + data_repos.push(serde_json::json!({ + "repo": repo, "state": "auth-failed", "prs": [], "detail": err, + })); repo_results.push(RepoResult { repo: repo.clone(), output: String::new(), @@ -126,20 +188,31 @@ impl StepHandler for GithubPrScan { error: Some(format!("Auth failed: {err}")), }); } else { - repo_results.push(RepoResult::err( - repo, - if err.is_empty() { - "gh error".into() - } else { - err - }, - )); + let err = if err.is_empty() { + "gh error".to_string() + } else { + err + }; + data_repos.push(serde_json::json!({ + "repo": repo, "state": "error", "prs": [], "detail": err, + })); + repo_results.push(RepoResult::err(repo, err)); } continue; } - match render_prs(out.stdout.trim()) { - Ok(body) => repo_results.push(RepoResult::ok(repo, body)), - Err(e) => repo_results.push(RepoResult::err(repo, e)), + match parse_prs(out.stdout.trim()) { + Ok(prs) => { + data_repos.push(serde_json::json!({ + "repo": repo, "state": "ok", "prs": prs, + })); + repo_results.push(RepoResult::ok(repo, render_prs(&prs))); + } + Err(e) => { + data_repos.push(serde_json::json!({ + "repo": repo, "state": "error", "prs": [], "detail": e, + })); + repo_results.push(RepoResult::err(repo, e)); + } } } @@ -159,7 +232,10 @@ impl StepHandler for GithubPrScan { None => lines.push(rr.output.clone()), } } - StepResult::ok(&spec.name, &spec.step_type, lines.join("\n")).with_repos(repo_results) + let mut result = + StepResult::ok(&spec.name, &spec.step_type, lines.join("\n")).with_repos(repo_results); + result.data = Some(serde_json::json!({ "repos": data_repos })); + result } } @@ -199,6 +275,8 @@ mod tests { toml::from_str("name=\"prs\"\ntype=\"github-pr-scan\"").unwrap() } + // FIXTURE-SYNC (#36): the JSON below mimics `gh pr list --json` output; + // verified against the real CLI by live_contract::live_gh_pr_list_json_shape. #[tokio::test] async fn renders_pr_lines_from_gh_json() { let (cx, spawner) = cx_with(vec![MockSpawner::ok( diff --git a/crates/modulex-core/src/steps/gitlab.rs b/crates/modulex-core/src/steps/gitlab.rs index 96463dd..bd6ba63 100644 --- a/crates/modulex-core/src/steps/gitlab.rs +++ b/crates/modulex-core/src/steps/gitlab.rs @@ -93,6 +93,31 @@ async fn run_glab( ) } +/// Shared schema for the glab-backed steps: per-target raw passthrough with +/// a state enum (glab output is human text; `raw` carries it verbatim). +fn targets_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "required": ["targets"], + "properties": { + "targets": { + "type": "array", + "items": { + "type": "object", + "required": ["target", "state", "raw"], + "properties": { + "target": { "type": "string" }, + "state": { "type": "string", + "enum": ["ok", "none", "auth-failed", "error"] }, + "raw": { "type": "string", + "description": "verbatim CLI output (ok) or error text" } + } + } + } + } + }) +} + /// Aggregate per-project results with the all-auth-failed soft skip. fn aggregate( spec: &StepSpec, @@ -137,7 +162,21 @@ fn aggregate( empty_message.to_string() }; - StepResult::ok(&spec.name, &spec.step_type, body).with_repos(repo_results) + let targets: Vec = repo_results + .iter() + .map(|rr| { + let (state, raw) = match &rr.error { + Some(e) if e.contains(AUTH_FAILED) => ("auth-failed", e.clone()), + Some(e) => ("error", e.clone()), + None if rr.output == "(none)" => ("none", String::new()), + None => ("ok", rr.output.clone()), + }; + serde_json::json!({ "target": rr.repo, "state": state, "raw": raw }) + }) + .collect(); + let mut result = StepResult::ok(&spec.name, &spec.step_type, body).with_repos(repo_results); + result.data = Some(serde_json::json!({ "targets": targets })); + result } /// Shared shape of the authored/review steps (only the role flag differs). @@ -203,6 +242,14 @@ impl StepHandler for GitlabMrAuthored { "gitlab-mr-authored" } + fn description(&self) -> &'static str { + "Open merge requests the user authored, per project" + } + + fn data_schema(&self) -> serde_json::Value { + targets_schema() + } + fn required_programs(&self, _spec: &StepSpec) -> Vec { vec!["glab".into()] } @@ -221,6 +268,14 @@ impl StepHandler for GitlabMrReview { "gitlab-mr-review" } + fn description(&self) -> &'static str { + "Open merge requests where the user is a reviewer, per project" + } + + fn data_schema(&self) -> serde_json::Value { + targets_schema() + } + fn required_programs(&self, _spec: &StepSpec) -> Vec { vec!["glab".into()] } @@ -240,6 +295,14 @@ impl StepHandler for GitlabGroupMrs { "gitlab-group-mrs" } + fn description(&self) -> &'static str { + "Recent merge-request activity across configured groups" + } + + fn data_schema(&self) -> serde_json::Value { + targets_schema() + } + fn required_programs(&self, _spec: &StepSpec) -> Vec { vec!["glab".into()] } @@ -303,6 +366,21 @@ impl StepHandler for MrSlaCheck { "mr-sla-check" } + fn description(&self) -> &'static str { + "Pending review-request count from this run, against an SLA threshold" + } + + fn data_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "required": ["threshold_hours", "pending_targets"], + "properties": { + "threshold_hours": { "type": "integer" }, + "pending_targets": { "type": "integer", "minimum": 0 } + } + }) + } + fn required_programs(&self, _spec: &StepSpec) -> Vec { vec![] // reads prior results; spawns nothing } @@ -336,7 +414,11 @@ impl StepHandler for MrSlaCheck { a response today." ) }; - StepResult::ok(&spec.name, &spec.step_type, body) + let mut result = StepResult::ok(&spec.name, &spec.step_type, body); + result.data = Some(serde_json::json!({ + "threshold_hours": hours, "pending_targets": pending, + })); + result } } diff --git a/crates/modulex-core/src/steps/python.rs b/crates/modulex-core/src/steps/python.rs index 5ae0b86..d6a6640 100644 --- a/crates/modulex-core/src/steps/python.rs +++ b/crates/modulex-core/src/steps/python.rs @@ -134,6 +134,18 @@ impl StepHandler for PythonPlugin { "python" } + fn description(&self) -> &'static str { + "Run a plugin script under the modulex-plugin/1 stdio JSON contract" + } + + fn data_schema(&self) -> serde_json::Value { + // Passthrough: the plugin owns its `data` payload (the protocol's + // `data` field). Empty schema = any JSON. + serde_json::json!({ + "description": "plugin-defined payload (modulex-plugin/1 `data` field)" + }) + } + fn required_programs(&self, spec: &StepSpec) -> Vec { vec![spec .param_str("interpreter") diff --git a/crates/modulex-core/src/steps/reminders.rs b/crates/modulex-core/src/steps/reminders.rs index c3e42fb..2648f6f 100644 --- a/crates/modulex-core/src/steps/reminders.rs +++ b/crates/modulex-core/src/steps/reminders.rs @@ -19,6 +19,35 @@ impl StepHandler for Reminders { "reminders" } + fn description(&self) -> &'static str { + "Open reminders from the agent state store, overdue first" + } + + fn data_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "required": ["reminders", "open"], + "properties": { + "reminders": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "text", "created_gen"], + "properties": { + "id": { "type": "integer" }, + "text": { "type": "string" }, + "due": { "type": ["string", "null"] }, + "recurrence": { "type": ["string", "null"] }, + "created_gen": { "type": "integer" }, + "done_gen": { "type": ["integer", "null"] } + } + } + }, + "open": { "type": "integer", "minimum": 0 } + } + }) + } + fn required_programs(&self, _spec: &StepSpec) -> Vec { vec![] } @@ -37,7 +66,13 @@ impl StepHandler for Reminders { match store.reminders_open() { Ok(reminders) => { let today = chrono::Local::now().date_naive(); - StepResult::ok(&spec.name, &spec.step_type, render(&reminders, today)) + let mut result = + StepResult::ok(&spec.name, &spec.step_type, render(&reminders, today)); + result.data = Some(serde_json::json!({ + "open": reminders.len(), + "reminders": reminders, + })); + result } Err(e) => StepResult::fail(&spec.name, &spec.step_type, e.to_string()), } diff --git a/crates/modulex-core/src/steps/script.rs b/crates/modulex-core/src/steps/script.rs index f7ce70e..2628715 100644 --- a/crates/modulex-core/src/steps/script.rs +++ b/crates/modulex-core/src/steps/script.rs @@ -77,6 +77,21 @@ impl StepHandler for Script { "script" } + fn description(&self) -> &'static str { + "Run an external command; trimmed stdout becomes the report section" + } + + fn data_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "required": ["exit_code"], + "properties": { + "exit_code": { "type": ["integer", "null"], + "description": "null when killed at timeout" } + } + }) + } + fn required_programs(&self, spec: &StepSpec) -> Vec { command_of(spec).into_iter().collect() } @@ -90,10 +105,14 @@ impl StepHandler for Script { Err(result) => return result, }; if out.timed_out { - return StepResult::fail(&spec.name, &spec.step_type, out.stderr.trim()); + let mut result = StepResult::fail(&spec.name, &spec.step_type, out.stderr.trim()); + result.data = Some(serde_json::json!({ "exit_code": null })); + return result; } if out.success() { - StepResult::ok(&spec.name, &spec.step_type, out.stdout.trim()) + let mut result = StepResult::ok(&spec.name, &spec.step_type, out.stdout.trim()); + result.data = Some(serde_json::json!({ "exit_code": 0 })); + result } else { let mut result = StepResult::fail( &spec.name, @@ -107,6 +126,7 @@ impl StepHandler for Script { // Keep whatever stdout the failing script produced — often the // useful half of a diagnostic. result.output = out.stdout.trim().to_string(); + result.data = Some(serde_json::json!({ "exit_code": out.status })); result } } @@ -121,6 +141,17 @@ impl StepHandler for Harness { "harness" } + fn description(&self) -> &'static str { + "Run an external tool whose contract is one JSON object on stdout" + } + + fn data_schema(&self) -> serde_json::Value { + // Passthrough: the tool owns its payload. Empty schema = any JSON. + serde_json::json!({ + "description": "tool-defined payload (the JSON object the harness emitted)" + }) + } + fn required_programs(&self, spec: &StepSpec) -> Vec { command_of(spec).into_iter().collect() } diff --git a/crates/modulex-core/src/steps/web.rs b/crates/modulex-core/src/steps/web.rs index ca5bb02..357101d 100644 --- a/crates/modulex-core/src/steps/web.rs +++ b/crates/modulex-core/src/steps/web.rs @@ -98,6 +98,37 @@ impl StepHandler for UrlWatch { "url-watch" } + fn description(&self) -> &'static str { + "Change tracking over registered URLs (leashed fetch, content hashing)" + } + + fn data_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "required": ["watches"], + "properties": { + "watches": { + "type": "array", + "items": { + "type": "object", + "required": ["url", "state"], + "properties": { + "url": { "type": "string" }, + "note": { "type": "string" }, + "state": { "type": "string", + "enum": ["first", "unchanged", "changed", "error"] }, + "title": { "type": "string" }, + "http_status": { "type": "integer" }, + "since_gen": { "type": "integer", + "description": "generation of the previous fetch" }, + "detail": { "type": "string", "description": "error text" } + } + } + } + } + }) + } + fn required_programs(&self, _spec: &StepSpec) -> Vec { vec![] // in-proc fetch; the leash here is the NET axis, not exec } @@ -122,6 +153,7 @@ impl StepHandler for UrlWatch { } let mut repo_results = Vec::with_capacity(watches.len()); + let mut data_watches = Vec::with_capacity(watches.len()); for watch in &watches { let label = if watch.note.is_empty() { watch.url.clone() @@ -133,16 +165,31 @@ impl StepHandler for UrlWatch { let hash = blake3::hash(fetched.markdown.as_bytes()) .to_hex() .to_string(); - let line = match (&watch.last_hash, watch.last_seen_gen) { - (Some(previous), Some(seen)) if *previous == hash => { - format!("unchanged since gen {seen} — {}", fetched.title) - } - (Some(_), Some(seen)) => format!( - "CHANGED since gen {seen} — {} (HTTP {})", - fetched.title, fetched.status + let (state, since_gen, line) = match (&watch.last_hash, watch.last_seen_gen) { + (Some(previous), Some(seen)) if *previous == hash => ( + "unchanged", + Some(seen), + format!("unchanged since gen {seen} — {}", fetched.title), + ), + (Some(_), Some(seen)) => ( + "changed", + Some(seen), + format!( + "CHANGED since gen {seen} — {} (HTTP {})", + fetched.title, fetched.status + ), + ), + _ => ( + "first", + None, + format!("first fetch — {} (HTTP {})", fetched.title, fetched.status), ), - _ => format!("first fetch — {} (HTTP {})", fetched.title, fetched.status), }; + data_watches.push(serde_json::json!({ + "url": watch.url, "note": watch.note, "state": state, + "title": fetched.title, "http_status": fetched.status, + "since_gen": since_gen, + })); if let Err(e) = store.watch_seen(watch.id, &hash, cx.generation) { repo_results.push(RepoResult::err(&label, e.to_string())); } else { @@ -151,7 +198,13 @@ impl StepHandler for UrlWatch { } // A denied or failed fetch is data, not a dead routine — the // denial reason (net leash, SSRF screen) lands in the report. - Err(e) => repo_results.push(RepoResult::err(&label, e)), + Err(e) => { + data_watches.push(serde_json::json!({ + "url": watch.url, "note": watch.note, "state": "error", + "detail": e, + })); + repo_results.push(RepoResult::err(&label, e)); + } } } @@ -163,7 +216,10 @@ impl StepHandler for UrlWatch { None => lines.push(rr.output.clone()), } } - StepResult::ok(&spec.name, &spec.step_type, lines.join("\n")).with_repos(repo_results) + let mut result = + StepResult::ok(&spec.name, &spec.step_type, lines.join("\n")).with_repos(repo_results); + result.data = Some(serde_json::json!({ "watches": data_watches })); + result } } diff --git a/crates/modulex-core/tests/data_contract.rs b/crates/modulex-core/tests/data_contract.rs new file mode 100644 index 0000000..6cc4ce9 --- /dev/null +++ b/crates/modulex-core/tests/data_contract.rs @@ -0,0 +1,241 @@ +//! The data-contract enforcement harness (FOUNDATION pillar A, #26). +//! +//! Two guarantees: +//! +//! 1. **Golden schemas** — every registered step type's `data_schema()` is +//! pinned to a checked-in golden file. Changing a shape makes this test +//! fail until the golden is updated — so every schema change is a visible, +//! reviewed diff (and a breaking release per the standing law). +//! 2. **Outputs conform** — each builtin, driven through a mock spawner / +//! in-memory store, produces `data` that VALIDATES against its schema. + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Arc; + +use modulex_core::exec::test_support::MockSpawner; +use modulex_core::{steps::builtin_registry, Caveats, Config, Engine, GrantedCaveats, Store}; + +fn golden_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/golden") +} + +/// Guarantee 1: schemas match their checked-in goldens. +/// +/// To update after an INTENTIONAL schema change: +/// UPDATE_GOLDEN_SCHEMAS=1 cargo test -p modulex-core --all-features golden +/// then review the diff like the breaking change it is. +#[test] +fn golden_schemas_are_pinned() { + let registry = builtin_registry(); + let update = std::env::var_os("UPDATE_GOLDEN_SCHEMAS").is_some(); + let mut failures = Vec::new(); + + for (name, _description, schema) in registry.specs() { + let path = golden_dir().join(format!("{name}.json")); + let rendered = serde_json::to_string_pretty(&schema).expect("schema serializes") + "\n"; + if update { + std::fs::write(&path, &rendered).expect("write golden"); + continue; + } + match std::fs::read_to_string(&path) { + Ok(golden) if golden == rendered => {} + Ok(_) => failures.push(format!( + "{name}: schema CHANGED vs tests/golden/{name}.json — a data \ + contract is a versioned contract; if intentional, rerun with \ + UPDATE_GOLDEN_SCHEMAS=1 and treat it as a breaking change" + )), + Err(_) => failures.push(format!( + "{name}: missing golden tests/golden/{name}.json — generate it \ + with UPDATE_GOLDEN_SCHEMAS=1" + )), + } + } + + // The other direction: no orphaned goldens for unregistered steps. + for entry in std::fs::read_dir(golden_dir()).expect("golden dir") { + let path = entry.expect("entry").path(); + if path.extension().is_some_and(|e| e == "json") { + let stem = path.file_stem().unwrap().to_string_lossy().into_owned(); + if !registry.type_names().contains(&stem) { + failures.push(format!("orphaned golden for unregistered step {stem:?}")); + } + } + } + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +const CONTRACT_CONFIG: &str = r#" +[identity] +username = "someone" +gitlab_host = "gitlab.example.com" + +[shared] +repos = ["/r/a"] +github_repos = ["owner/repo"] +gitlab_projects = ["group/a"] + +[[shared.gitlab_groups]] +name = "group" +scan = "all" + +[board] +path = "/nonexistent-board" +lanes = ["p0"] + +[chores] +path = "" + +[[deadlines]] +label = "CFP" +date = "2999-12-31" + +[[countdowns]] +label = "ramp" +start_date = "2026-06-01" +end_date = "2999-12-31" + +[routines.contract] + +[[routines.contract.steps]] +name = "tend" +type = "git-tend" + +[[routines.contract.steps]] +name = "status" +type = "git-status" + +[[routines.contract.steps]] +name = "unpushed" +type = "git-unpushed" + +[[routines.contract.steps]] +name = "deadlines" +type = "deadline-calc" + +[[routines.contract.steps]] +name = "countdowns" +type = "countdown-calc" + +[[routines.contract.steps]] +name = "sh" +type = "script" +command = "sh" + +[[routines.contract.steps]] +name = "tool" +type = "harness" +command = "sh" + +[[routines.contract.steps]] +name = "prs" +type = "github-pr-scan" + +[[routines.contract.steps]] +name = "authored" +type = "gitlab-mr-authored" + +[[routines.contract.steps]] +name = "review" +type = "gitlab-mr-review" + +[[routines.contract.steps]] +name = "groups" +type = "gitlab-group-mrs" + +[[routines.contract.steps]] +name = "sla" +type = "mr-sla-check" + +[[routines.contract.steps]] +name = "board" +type = "board-scan" + +[[routines.contract.steps]] +name = "chores" +type = "chores-check" + +[[routines.contract.steps]] +name = "agenda" +type = "reminders" +"#; + +/// Guarantee 2: executed builtins emit `data` that validates against their +/// published schema. (Steps whose tools are scripted via the mock spawner; +/// url-watch and the python plugin are covered by their module tests — the +/// former needs the web feature's fetcher seam, the latter a script file.) +#[tokio::test(flavor = "multi_thread")] +async fn executed_step_data_validates_against_schema() { + let config = Config::from_toml(CONTRACT_CONFIG).unwrap(); + let registry = builtin_registry(); + let declared = config.declared_programs(®istry); + let granted: Caveats = GrantedCaveats::resolve(None, None, declared) + .unwrap() + .caveats; + + // Scripted outputs, in step order (git fan-outs consume one per repo). + // FIXTURE-SYNC (#36): these strings mimic real tools; their shapes are + // verified by tests/live_contract.rs (git states, gh --json fields, + // harness stdout contract). Change a fixture → re-check its live test. + let outputs = vec![ + MockSpawner::ok("Already up to date.\n"), // tend: fetch + MockSpawner::ok("Already up to date.\n"), // tend: pull + MockSpawner::ok(" M src/lib.rs\n"), // status + MockSpawner::ok("abc123 wip\n"), // unpushed + MockSpawner::ok("script out\n"), // script + MockSpawner::ok(r#"{"summary":"ok","n":1}"#), // harness + MockSpawner::ok(r#"[{"number":7,"title":"t","author":{"login":"a"}}]"#), // gh + MockSpawner::ok("!1 mr\n"), // glab authored + MockSpawner::ok(""), // glab review (none) + MockSpawner::ok("!2 mr\n"), // glab groups + ]; + let spawner = Arc::new(MockSpawner::with_outputs(outputs)); + let store = Arc::new(Store::in_memory().unwrap()); + store + .reminder_add("validate me", Some("2999-01-01"), None, 0) + .unwrap(); + let engine = Engine::with_spawner(config, registry, granted, spawner).with_store(store); + + let report = engine + .run_routine("contract", modulex_core::RunOptions::default()) + .await + .unwrap(); + + let schemas: BTreeMap = builtin_registry() + .specs() + .into_iter() + .map(|(name, _, schema)| (name, schema)) + .collect(); + + let mut failures = Vec::new(); + for step in &report.step_results { + if step.skipped { + failures.push(format!( + "{} ({}): unexpectedly skipped: {}", + step.step_name, step.step_type, step.output + )); + continue; + } + let Some(data) = &step.data else { + failures.push(format!( + "{} ({}): executed step emitted NO data — the contract requires it", + step.step_name, step.step_type + )); + continue; + }; + let schema = &schemas[&step.step_type]; + let validator = jsonschema::validator_for(schema).expect("valid schema"); + for error in validator.iter_errors(data) { + failures.push(format!( + "{} ({}): data violates schema at {}: {}", + step.step_name, step.step_type, error.instance_path, error + )); + } + } + assert!( + failures.is_empty(), + "data contract violations:\n{}", + failures.join("\n") + ); +} diff --git a/crates/modulex-core/tests/golden/board-scan.json b/crates/modulex-core/tests/golden/board-scan.json new file mode 100644 index 0000000..1af8067 --- /dev/null +++ b/crates/modulex-core/tests/golden/board-scan.json @@ -0,0 +1,34 @@ +{ + "properties": { + "lanes": { + "items": { + "properties": { + "found": { + "description": "lane directory exists", + "type": "boolean" + }, + "lane": { + "type": "string" + }, + "tasks": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "lane", + "found", + "tasks" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "lanes" + ], + "type": "object" +} diff --git a/crates/modulex-core/tests/golden/chores-check.json b/crates/modulex-core/tests/golden/chores-check.json new file mode 100644 index 0000000..dd16d0a --- /dev/null +++ b/crates/modulex-core/tests/golden/chores-check.json @@ -0,0 +1,39 @@ +{ + "properties": { + "due_today": { + "items": { + "type": "string" + }, + "type": "array" + }, + "overdue": { + "items": { + "properties": { + "chore": { + "type": "string" + }, + "days_overdue": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "chore", + "days_overdue" + ], + "type": "object" + }, + "type": "array" + }, + "upcoming": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "overdue", + "due_today", + "upcoming" + ], + "type": "object" +} diff --git a/crates/modulex-core/tests/golden/countdown-calc.json b/crates/modulex-core/tests/golden/countdown-calc.json new file mode 100644 index 0000000..8655206 --- /dev/null +++ b/crates/modulex-core/tests/golden/countdown-calc.json @@ -0,0 +1,45 @@ +{ + "properties": { + "countdowns": { + "items": { + "properties": { + "display_line": { + "type": "string" + }, + "label": { + "type": "string" + }, + "n": { + "description": "work days elapsed", + "type": "integer" + }, + "role": { + "type": "string" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "label", + "n", + "total" + ], + "type": "object" + }, + "type": "array" + }, + "invalid": { + "description": "labels with unparsable dates", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "countdowns", + "invalid" + ], + "type": "object" +} diff --git a/crates/modulex-core/tests/golden/deadline-calc.json b/crates/modulex-core/tests/golden/deadline-calc.json new file mode 100644 index 0000000..00efdae --- /dev/null +++ b/crates/modulex-core/tests/golden/deadline-calc.json @@ -0,0 +1,49 @@ +{ + "properties": { + "deadlines": { + "items": { + "properties": { + "date": { + "description": "ISO YYYY-MM-DD", + "type": "string" + }, + "days_left": { + "minimum": 0, + "type": "integer" + }, + "end_date": { + "type": [ + "string", + "null" + ] + }, + "label": { + "type": "string" + }, + "notes": { + "type": "string" + } + }, + "required": [ + "label", + "date", + "days_left" + ], + "type": "object" + }, + "type": "array" + }, + "invalid": { + "description": "labels with unparsable dates", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "deadlines", + "invalid" + ], + "type": "object" +} diff --git a/crates/modulex-core/tests/golden/git-status.json b/crates/modulex-core/tests/golden/git-status.json new file mode 100644 index 0000000..ffde20e --- /dev/null +++ b/crates/modulex-core/tests/golden/git-status.json @@ -0,0 +1,36 @@ +{ + "properties": { + "repos": { + "items": { + "properties": { + "detail": { + "description": "short-status lines when dirty; error text on error", + "type": "string" + }, + "repo": { + "type": "string" + }, + "state": { + "enum": [ + "clean", + "dirty", + "error" + ], + "type": "string" + } + }, + "required": [ + "repo", + "state", + "detail" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "repos" + ], + "type": "object" +} diff --git a/crates/modulex-core/tests/golden/git-tend.json b/crates/modulex-core/tests/golden/git-tend.json new file mode 100644 index 0000000..168c119 --- /dev/null +++ b/crates/modulex-core/tests/golden/git-tend.json @@ -0,0 +1,59 @@ +{ + "properties": { + "repos": { + "items": { + "properties": { + "detail": { + "description": "last pull line on ok; failure reason otherwise", + "type": "string" + }, + "repo": { + "type": "string" + }, + "state": { + "enum": [ + "ok", + "no-tracking", + "diverged", + "fetch-failed", + "pull-failed", + "error" + ], + "type": "string" + } + }, + "required": [ + "repo", + "state", + "detail" + ], + "type": "object" + }, + "type": "array" + }, + "summary": { + "properties": { + "failed": { + "type": "integer" + }, + "tended": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "tended", + "failed", + "total" + ], + "type": "object" + } + }, + "required": [ + "repos", + "summary" + ], + "type": "object" +} diff --git a/crates/modulex-core/tests/golden/git-unpushed.json b/crates/modulex-core/tests/golden/git-unpushed.json new file mode 100644 index 0000000..37f3393 --- /dev/null +++ b/crates/modulex-core/tests/golden/git-unpushed.json @@ -0,0 +1,37 @@ +{ + "properties": { + "repos": { + "items": { + "properties": { + "detail": { + "description": "oneline commit list when unpushed", + "type": "string" + }, + "repo": { + "type": "string" + }, + "state": { + "enum": [ + "all-pushed", + "unpushed", + "no-upstream", + "error" + ], + "type": "string" + } + }, + "required": [ + "repo", + "state", + "detail" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "repos" + ], + "type": "object" +} diff --git a/crates/modulex-core/tests/golden/github-pr-scan.json b/crates/modulex-core/tests/golden/github-pr-scan.json new file mode 100644 index 0000000..03e4522 --- /dev/null +++ b/crates/modulex-core/tests/golden/github-pr-scan.json @@ -0,0 +1,57 @@ +{ + "properties": { + "repos": { + "items": { + "properties": { + "detail": { + "type": "string" + }, + "prs": { + "items": { + "properties": { + "author": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "title": { + "type": "string" + } + }, + "required": [ + "number", + "title", + "author" + ], + "type": "object" + }, + "type": "array" + }, + "repo": { + "type": "string" + }, + "state": { + "enum": [ + "ok", + "auth-failed", + "error" + ], + "type": "string" + } + }, + "required": [ + "repo", + "state", + "prs" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "repos" + ], + "type": "object" +} diff --git a/crates/modulex-core/tests/golden/gitlab-group-mrs.json b/crates/modulex-core/tests/golden/gitlab-group-mrs.json new file mode 100644 index 0000000..9f5dc07 --- /dev/null +++ b/crates/modulex-core/tests/golden/gitlab-group-mrs.json @@ -0,0 +1,37 @@ +{ + "properties": { + "targets": { + "items": { + "properties": { + "raw": { + "description": "verbatim CLI output (ok) or error text", + "type": "string" + }, + "state": { + "enum": [ + "ok", + "none", + "auth-failed", + "error" + ], + "type": "string" + }, + "target": { + "type": "string" + } + }, + "required": [ + "target", + "state", + "raw" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "targets" + ], + "type": "object" +} diff --git a/crates/modulex-core/tests/golden/gitlab-mr-authored.json b/crates/modulex-core/tests/golden/gitlab-mr-authored.json new file mode 100644 index 0000000..9f5dc07 --- /dev/null +++ b/crates/modulex-core/tests/golden/gitlab-mr-authored.json @@ -0,0 +1,37 @@ +{ + "properties": { + "targets": { + "items": { + "properties": { + "raw": { + "description": "verbatim CLI output (ok) or error text", + "type": "string" + }, + "state": { + "enum": [ + "ok", + "none", + "auth-failed", + "error" + ], + "type": "string" + }, + "target": { + "type": "string" + } + }, + "required": [ + "target", + "state", + "raw" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "targets" + ], + "type": "object" +} diff --git a/crates/modulex-core/tests/golden/gitlab-mr-review.json b/crates/modulex-core/tests/golden/gitlab-mr-review.json new file mode 100644 index 0000000..9f5dc07 --- /dev/null +++ b/crates/modulex-core/tests/golden/gitlab-mr-review.json @@ -0,0 +1,37 @@ +{ + "properties": { + "targets": { + "items": { + "properties": { + "raw": { + "description": "verbatim CLI output (ok) or error text", + "type": "string" + }, + "state": { + "enum": [ + "ok", + "none", + "auth-failed", + "error" + ], + "type": "string" + }, + "target": { + "type": "string" + } + }, + "required": [ + "target", + "state", + "raw" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "targets" + ], + "type": "object" +} diff --git a/crates/modulex-core/tests/golden/harness.json b/crates/modulex-core/tests/golden/harness.json new file mode 100644 index 0000000..86467a1 --- /dev/null +++ b/crates/modulex-core/tests/golden/harness.json @@ -0,0 +1,3 @@ +{ + "description": "tool-defined payload (the JSON object the harness emitted)" +} diff --git a/crates/modulex-core/tests/golden/mr-sla-check.json b/crates/modulex-core/tests/golden/mr-sla-check.json new file mode 100644 index 0000000..aaf4409 --- /dev/null +++ b/crates/modulex-core/tests/golden/mr-sla-check.json @@ -0,0 +1,16 @@ +{ + "properties": { + "pending_targets": { + "minimum": 0, + "type": "integer" + }, + "threshold_hours": { + "type": "integer" + } + }, + "required": [ + "threshold_hours", + "pending_targets" + ], + "type": "object" +} diff --git a/crates/modulex-core/tests/golden/python.json b/crates/modulex-core/tests/golden/python.json new file mode 100644 index 0000000..8a7db24 --- /dev/null +++ b/crates/modulex-core/tests/golden/python.json @@ -0,0 +1,3 @@ +{ + "description": "plugin-defined payload (modulex-plugin/1 `data` field)" +} diff --git a/crates/modulex-core/tests/golden/reminders.json b/crates/modulex-core/tests/golden/reminders.json new file mode 100644 index 0000000..59cf965 --- /dev/null +++ b/crates/modulex-core/tests/golden/reminders.json @@ -0,0 +1,53 @@ +{ + "properties": { + "open": { + "minimum": 0, + "type": "integer" + }, + "reminders": { + "items": { + "properties": { + "created_gen": { + "type": "integer" + }, + "done_gen": { + "type": [ + "integer", + "null" + ] + }, + "due": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "integer" + }, + "recurrence": { + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + } + }, + "required": [ + "id", + "text", + "created_gen" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "reminders", + "open" + ], + "type": "object" +} diff --git a/crates/modulex-core/tests/golden/script.json b/crates/modulex-core/tests/golden/script.json new file mode 100644 index 0000000..9396f9b --- /dev/null +++ b/crates/modulex-core/tests/golden/script.json @@ -0,0 +1,15 @@ +{ + "properties": { + "exit_code": { + "description": "null when killed at timeout", + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "exit_code" + ], + "type": "object" +} diff --git a/crates/modulex-core/tests/golden/url-watch.json b/crates/modulex-core/tests/golden/url-watch.json new file mode 100644 index 0000000..06736eb --- /dev/null +++ b/crates/modulex-core/tests/golden/url-watch.json @@ -0,0 +1,49 @@ +{ + "properties": { + "watches": { + "items": { + "properties": { + "detail": { + "description": "error text", + "type": "string" + }, + "http_status": { + "type": "integer" + }, + "note": { + "type": "string" + }, + "since_gen": { + "description": "generation of the previous fetch", + "type": "integer" + }, + "state": { + "enum": [ + "first", + "unchanged", + "changed", + "error" + ], + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "url", + "state" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "watches" + ], + "type": "object" +} diff --git a/crates/modulex-core/tests/live_contract.rs b/crates/modulex-core/tests/live_contract.rs new file mode 100644 index 0000000..ad99aa9 --- /dev/null +++ b/crates/modulex-core/tests/live_contract.rs @@ -0,0 +1,336 @@ +//! Tier 3: live-contract tests (#36) — verify the beliefs our mock fixtures +//! encode against REAL tools. +//! +//! Opt-in and deliberately host-dependent: +//! +//! ```bash +//! MODULEX_LIVE_TESTS=1 cargo test -p modulex-core --test live_contract -- --nocapture +//! # or: just live-test +//! ``` +//! +//! Without the env var every test exits immediately (default `just check` +//! and PR CI stay host-independent). With it, each test runs the real CLI +//! through the real `TokioSpawner` + leash using harmless, read-only (or +//! test-created-temp) invocations, asserting ONLY the output **shape** our +//! parsers and state matchers key on. A tool absent on the host skips that +//! test with a visible notice — fine HERE, this tier's job is to be +//! host-dependent. +//! +//! **Fixture-sync rule:** every `MockSpawner` fixture mimicking a real tool +//! cites its verifying test below; change one, re-check the other. + +use std::sync::Arc; + +use agent_bridle_core::{Caveats, Gate, Scope, Tool, ToolContext, ToolResult}; +use modulex_core::{ + Config, ExecGate, ExecRequest, RunContext, StepHandler, StepSpec, TokioSpawner, +}; + +fn live() -> bool { + if std::env::var_os("MODULEX_LIVE_TESTS").is_some() { + true + } else { + eprintln!("live-contract: skipped (set MODULEX_LIVE_TESTS=1 to run)"); + false + } +} + +struct LiveTool; + +#[async_trait::async_trait] +impl Tool for LiveTool { + fn name(&self) -> &str { + "live-contract" + } + fn schema(&self) -> serde_json::Value { + serde_json::json!({}) + } + async fn invoke( + &self, + _args: serde_json::Value, + _cx: &ToolContext, + ) -> ToolResult { + Ok(serde_json::Value::Null) + } +} + +/// A REAL exec gate (TokioSpawner) granted exactly `programs`. +fn live_gate(programs: &[&str]) -> ExecGate { + let granted = Caveats { + exec: Scope::only(programs.iter().map(ToString::to_string)), + ..Caveats::top() + }; + let cx = Gate::new(1) + .authorize(&LiveTool, &granted) + .expect("authorize"); + ExecGate::new(cx, Arc::new(TokioSpawner)) +} + +fn live_cx(programs: &[&str], repos: Vec) -> RunContext { + let mut config = Config::default(); + config.shared.repos = repos; + RunContext { + config: Arc::new(config), + dry_run: false, + generation: 1, + exec: live_gate(programs), + prior: Vec::new(), + store: None, + } +} + +fn spec(toml_text: &str) -> StepSpec { + toml::from_str(toml_text).expect("spec parses") +} + +/// Skip-with-notice when a tool is absent on this host. +fn tool_present(gate: &ExecGate, program: &str) -> bool { + if gate.program_available(program) { + true + } else { + eprintln!("live-contract: {program} not on this host — skipping"); + false + } +} + +fn unique_dir(tag: &str) -> std::path::PathBuf { + static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!( + "modulex-live-{tag}-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).expect("mkdir"); + dir +} + +/// Verifies the fixtures behind the git-status/git-unpushed mocks +/// (`steps/git.rs` tests + `tests/data_contract.rs`): real `git` against a +/// repo this test creates, driven through the REAL handlers — clean/dirty +/// state mapping and the no-upstream classification hold against reality. +#[tokio::test(flavor = "multi_thread")] +async fn live_git_status_and_unpushed_states() { + if !live() { + return; + } + let gate = live_gate(&["git"]); + if !tool_present(&gate, "git") { + return; + } + + let repo = unique_dir("git"); + async fn run(gate: &ExecGate, repo: &std::path::Path, args: &[&str]) { + let out = gate + .spawn( + ExecRequest::new("git") + .args(args.iter().map(ToString::to_string).collect()) + .cwd(repo.to_path_buf()), + ) + .await + .expect("git spawns"); + assert!(out.success(), "git failed: {}", out.stderr); + } + run(&gate, &repo, &["init", "-q"]).await; + run(&gate, &repo, &["config", "user.email", "live@test"]).await; + run(&gate, &repo, &["config", "user.name", "live test"]).await; + std::fs::write(repo.join("a.txt"), "one").expect("write"); + run(&gate, &repo, &["add", "a.txt"]).await; + run(&gate, &repo, &["commit", "-q", "-m", "init"]).await; + + let repo_str = repo.to_string_lossy().into_owned(); + let cx = live_cx(&["git"], vec![repo_str.clone()]); + let status = modulex_core::steps::git::GitStatus; + let unpushed = modulex_core::steps::git::GitUnpushed; + let status_spec = spec("name=\"s\"\ntype=\"git-status\""); + let unpushed_spec = spec("name=\"u\"\ntype=\"git-unpushed\""); + + // Clean tree → state "clean". + let result = status.run(&status_spec, &cx).await; + assert_eq!( + result.data.as_ref().unwrap()["repos"][0]["state"], + "clean", + "real git disagrees with the clean-state fixture: {result:?}" + ); + + // Dirty tree → state "dirty" with short-status detail. + std::fs::write(repo.join("a.txt"), "two").expect("write"); + let result = status.run(&status_spec, &cx).await; + let data = result.data.as_ref().unwrap(); + assert_eq!(data["repos"][0]["state"], "dirty"); + assert!( + data["repos"][0]["detail"].as_str().unwrap().contains("M"), + "expected a short-status 'M' marker: {data}" + ); + + // No upstream configured → classified "no-upstream", success stays true + // (the `fatal:`-marker belief in steps/git.rs). + let result = unpushed.run(&unpushed_spec, &cx).await; + assert!(result.success, "no-upstream must not fail: {result:?}"); + assert_eq!( + result.data.as_ref().unwrap()["repos"][0]["state"], + "no-upstream" + ); + + std::fs::remove_dir_all(&repo).ok(); +} + +/// Verifies the fixture behind `parse_prs` (`steps/github.rs` tests + +/// `tests/data_contract.rs`): real `gh pr list --json` emits an array of +/// objects carrying `number`/`title`/`author.login`. Read-only against a +/// stable public repo. Skips when gh is absent or unauthenticated. +#[tokio::test(flavor = "multi_thread")] +async fn live_gh_pr_list_json_shape() { + if !live() { + return; + } + let gate = live_gate(&["gh"]); + if !tool_present(&gate, "gh") { + return; + } + let auth = gate + .spawn(ExecRequest::new("gh").args(vec!["auth".into(), "status".into()])) + .await + .expect("gh spawns"); + if !auth.success() { + eprintln!("live-contract: gh present but unauthenticated — skipping"); + return; + } + + let out = gate + .spawn( + ExecRequest::new("gh").args( + [ + "pr", + "list", + "--repo", + "cli/cli", + "--state", + "open", + "--limit", + "2", + "--json", + "number,title,author,updatedAt", + ] + .iter() + .map(ToString::to_string) + .collect(), + ), + ) + .await + .expect("gh spawns"); + assert!(out.success(), "gh pr list failed: {}", out.stderr); + + let prs: Vec = + serde_json::from_str(out.stdout.trim()).expect("gh --json emits a JSON array"); + for pr in &prs { + assert!(pr["number"].is_u64(), "number field drifted: {pr}"); + assert!(pr["title"].is_string(), "title field drifted: {pr}"); + assert!( + pr["author"]["login"].is_string(), + "author.login field drifted: {pr}" + ); + } + eprintln!( + "live-contract: gh --json shape verified over {} PRs", + prs.len() + ); +} + +/// Presence + version probe for glab (the auth-marker belief — `401` / +/// `unauthorized` in stderr — can only be exercised against a configured +/// GitLab host; on such hosts the morning routine itself is the canary). +#[tokio::test(flavor = "multi_thread")] +async fn live_glab_presence() { + if !live() { + return; + } + let gate = live_gate(&["glab"]); + if !tool_present(&gate, "glab") { + return; + } + let out = gate + .spawn(ExecRequest::new("glab").args(vec!["--version".into()])) + .await + .expect("glab spawns"); + assert!(out.success(), "glab --version failed: {}", out.stderr); + eprintln!("live-contract: {}", out.stdout.trim()); +} + +/// Verifies the harness step's JSON-on-stdout contract against a REAL +/// process (the belief behind the harness fixtures in `steps/script.rs`): +/// a script this test writes emits JSON; the real handler runs it through +/// the real spawner and extracts the typed payload. +#[tokio::test(flavor = "multi_thread")] +async fn live_harness_contract_end_to_end() { + if !live() { + return; + } + let gate = live_gate(&["sh"]); + if !tool_present(&gate, "sh") { + return; + } + + let dir = unique_dir("harness"); + let script = dir.join("tool.sh"); + std::fs::write( + &script, + "#!/bin/sh\nprintf '{\"summary\":\"live ok\",\"n\":7}'\n", + ) + .expect("write script"); + + let mut cx = live_cx(&["sh"], vec![]); + cx.exec = gate; + let harness = modulex_core::steps::script::Harness; + let spec_toml = format!( + "name=\"t\"\ntype=\"harness\"\ncommand=\"sh\"\nargs=[\"{}\"]", + script.display() + ); + let result = harness.run(&spec(&spec_toml), &cx).await; + assert!(result.success, "harness failed: {result:?}"); + assert_eq!(result.output, "live ok"); + assert_eq!(result.data.as_ref().unwrap()["n"], 7); + + std::fs::remove_dir_all(&dir).ok(); +} + +/// Verifies the plugin protocol against a REAL python3 (the belief behind +/// `steps/python.rs` fixtures): request on stdin, response on stdout, typed +/// fields mapped. +#[tokio::test(flavor = "multi_thread")] +async fn live_plugin_protocol_with_real_python() { + if !live() { + return; + } + let gate = live_gate(&["python3"]); + if !tool_present(&gate, "python3") { + return; + } + + let dir = unique_dir("plugin"); + let script = dir.join("plugin.py"); + std::fs::write( + &script, + r#"import json, sys +req = json.load(sys.stdin) +assert req["protocol"] == "modulex-plugin/1" +json.dump({"protocol": "modulex-plugin/1", "success": True, + "output": f"gen {req['generation']}", + "data": {"echo": req["step"]["name"]}}, sys.stdout) +"#, + ) + .expect("write plugin"); + + let mut cx = live_cx(&["python3"], vec![]); + cx.exec = gate; + let plugin = modulex_core::steps::python::PythonPlugin; + let spec_toml = format!( + "name=\"live\"\ntype=\"python\"\nscript=\"{}\"", + script.display() + ); + let result = plugin.run(&spec(&spec_toml), &cx).await; + assert!(result.success, "plugin failed: {result:?}"); + assert_eq!(result.output, "gen 1"); + assert_eq!(result.data.as_ref().unwrap()["echo"], "live"); + + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/modulex-mcp/src/server.rs b/crates/modulex-mcp/src/server.rs index a7f53ca..e8702f9 100644 --- a/crates/modulex-mcp/src/server.rs +++ b/crates/modulex-mcp/src/server.rs @@ -396,6 +396,32 @@ type = "reminders" assert_eq!(report["step_results"][0]["error"], "boom"); } + #[tokio::test] + async fn format_data_returns_structured_payloads_only() { + // The agent-native view (data contract): typed payloads, no prose. + let s = server(vec![ok_out("hi\n")]); + let resp = s + .handle(&json!({ + "jsonrpc": "2.0", "id": 7, "method": "tools/call", + "params": { "name": "routine_run", + "arguments": { "routine": "morning", "format": "data" } } + })) + .await + .unwrap(); + let report: Value = + serde_json::from_str(resp["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(report["generation"], 1); + let steps = report["steps"].as_array().unwrap(); + // script step carries its typed exit_code, not markdown + let greeting = &steps[0]; + assert_eq!(greeting["type"], "script"); + assert_eq!(greeting["data"]["exit_code"], 0); + assert!(greeting.get("output").is_none(), "no prose in data view"); + // deadline step carries the typed empty contract + let deadlines = &steps[1]; + assert_eq!(deadlines["data"]["deadlines"], json!([])); + } + #[tokio::test] async fn unknown_routine_is_an_engine_fault_with_is_error() { let s = server(vec![]); @@ -482,9 +508,14 @@ type = "reminders" .unwrap(); let payload: Value = serde_json::from_str(resp["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); - let types = payload["step_types"].as_array().unwrap(); - assert!(types.iter().any(|t| t == "script")); - assert!(types.iter().any(|t| t == "harness")); + let steps = payload["steps"].as_array().unwrap(); + let script = steps + .iter() + .find(|s| s["type"] == "script") + .expect("script registered"); + assert!(script["description"].as_str().unwrap().contains("command")); + assert!(script["data_schema"]["properties"]["exit_code"].is_object()); + assert!(steps.iter().any(|s| s["type"] == "harness")); } #[tokio::test] diff --git a/crates/modulex-mcp/src/tools.rs b/crates/modulex-mcp/src/tools.rs index 2384a2d..4780b04 100644 --- a/crates/modulex-mcp/src/tools.rs +++ b/crates/modulex-mcp/src/tools.rs @@ -45,7 +45,7 @@ pub fn tool_specs() -> Value { "skip": { "type": "array", "items": { "type": "string" }, "description": "Skip these step names" }, "dry_run": { "type": "boolean", "default": false }, - "format": { "type": "string", "enum": ["text", "json"], "default": "text" } + "format": { "type": "string", "enum": ["text", "json", "data"], "default": "text" } }, "required": ["routine"] } @@ -65,7 +65,7 @@ pub fn tool_specs() -> Value { "routine": { "type": "string" }, "step": { "type": "string", "description": "Step name within the routine" }, "dry_run": { "type": "boolean", "default": false }, - "format": { "type": "string", "enum": ["text", "json"], "default": "text" } + "format": { "type": "string", "enum": ["text", "json", "data"], "default": "text" } }, "required": ["routine", "step"] } @@ -79,13 +79,15 @@ pub fn tool_specs() -> Value { "properties": { "generation": { "type": "integer", "minimum": 1, "description": "Exact report generation; omit for latest" }, - "format": { "type": "string", "enum": ["text", "json"], "default": "text" } + "format": { "type": "string", "enum": ["text", "json", "data"], "default": "text" } } } }, { "name": "steps_list", - "description": "List registered step types (builtin plus plugins).", + "description": "List registered step types with their data-contract \ + schemas: [{type, description, data_schema}]. Executed steps' \ + `data` payloads conform to these schemas (versioned contracts).", "inputSchema": { "type": "object", "properties": {} } }, { @@ -181,6 +183,8 @@ pub fn tool_specs() -> Value { fn render(report: &Report, args: &Value) -> String { match args.get("format").and_then(Value::as_str) { Some("json") => report.to_json(), + // The agent-native view: structured payloads only (data contract). + Some("data") => report.to_data_json(), _ => report.to_text(), } } @@ -414,7 +418,16 @@ pub async fn call(engine: &Engine, name: &str, args: &Value) -> ToolOutcome { }), } } - "steps_list" => ToolOutcome::ok(json!({ "step_types": engine.step_types() }).to_string()), + "steps_list" => { + let steps: Vec = engine + .step_specs() + .into_iter() + .map(|(name, description, schema)| { + json!({ "type": name, "description": description, "data_schema": schema }) + }) + .collect(); + ToolOutcome::ok(json!({ "steps": steps }).to_string()) + } other => ToolOutcome::err(format!("unknown tool: {other}")), } } diff --git a/crates/modulex-py/src/lib.rs b/crates/modulex-py/src/lib.rs index 61890cd..5ddb555 100644 --- a/crates/modulex-py/src/lib.rs +++ b/crates/modulex-py/src/lib.rs @@ -70,6 +70,17 @@ impl StepHandler for PyStep { self.type_name } + fn description(&self) -> &'static str { + "Python-registered in-process step handler" + } + + fn data_schema(&self) -> serde_json::Value { + // Passthrough: the Python handler owns its payload. + serde_json::json!({ + "description": "handler-defined payload (Python dict `data` field)" + }) + } + fn required_programs(&self, _spec: &modulex_core::StepSpec) -> Vec { vec![] // in-proc; spawns nothing through the engine by itself } diff --git a/justfile b/justfile index 6ebabe3..21c91b6 100644 --- a/justfile +++ b/justfile @@ -22,6 +22,12 @@ fmt: fmt-check: cargo fmt -- --check +# Tier-3 live-contract tests (#36): verify mock fixtures against REAL +# tools on THIS host. Opt-in and host-dependent by design — never part of +# `just check` or PR CI. Tools absent on the host skip with a notice. +live-test: + MODULEX_LIVE_TESTS=1 cargo test -p modulex-core --test live_contract -- --nocapture + # Run the CLI against the example config (dry run; no side effects). demo: MODULEX_CONFIG=modulex.toml.example cargo run -p modulex-cli -- run morning --dry-run