feat(stream-a): kei-forge MVP — local web wizard scaffolding atoms

New crate _primitives/_rust/kei-forge/ exposing POST /forge over axum
on 127.0.0.1:8747. Shell-outs to scripts/new-atom.sh for generation.
5-input inline HTML form, no JS required. 9 unit + 3 integration tests
green via `cargo test --features mock-generate`.

Registered kei-forge in workspace members.

Stream A of substrate v1 parallel build — see docs/SUBSTRATE-SCHEMA.md.
Spec pre-locked; schema immutable until 2026-06-03 or revocation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-04-23 00:09:53 +08:00
parent 9f6ba0cbfc
commit fd25c3af60
9 changed files with 721 additions and 0 deletions

View file

@ -556,6 +556,61 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"bytes",
"futures-util",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"hyper 1.9.0",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "base64"
version = "0.21.7"
@ -1449,6 +1504,7 @@ dependencies = [
"http 1.4.0",
"http-body 1.0.1",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
@ -1815,6 +1871,19 @@ dependencies = [
"tempfile",
]
[[package]]
name = "kei-forge"
version = "0.1.0"
dependencies = [
"axum",
"serde",
"serde_json",
"tokio",
"tower",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "kei-graph-check"
version = "0.1.0"
@ -2084,6 +2153,12 @@ dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "md-5"
version = "0.10.6"
@ -2100,6 +2175,12 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@ -2157,6 +2238,15 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
@ -2789,6 +2879,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
@ -2856,12 +2957,31 @@ dependencies = [
"digest 0.11.2",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
@ -3195,6 +3315,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "synstructure"
version = "0.13.2"
@ -3259,6 +3385,15 @@ dependencies = [
"syn",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.47"
@ -3332,7 +3467,9 @@ dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.6.3",
"tokio-macros",
"windows-sys 0.61.2",
@ -3440,8 +3577,14 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@ -3486,6 +3629,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"nu-ansi-term",
"sharded-slab",
"smallvec",
"thread_local",
"tracing-core",
"tracing-log",
]
[[package]]
@ -3592,6 +3761,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"

View file

@ -29,6 +29,8 @@ members = [
"kei-artifact",
# v0.18 exobrain CLI
"keisei",
# v1 substrate Stream A — local web wizard for scaffolding atoms
"kei-forge",
]
[workspace.package]

View file

@ -0,0 +1,36 @@
[package]
name = "kei-forge"
version = "0.1.0"
edition = "2021"
rust-version = "1.75"
description = "Local web wizard for scaffolding new atoms"
[package.metadata.keisei]
backend = "none"
description = "Local web wizard for scaffolding new atoms"
[[bin]]
name = "kei-forge"
path = "src/main.rs"
[lib]
name = "kei_forge"
path = "src/lib.rs"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower = "0.5"
tracing = "0.1"
tracing-subscriber = "0.3"
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
[features]
default = []
# When enabled, POST /forge skips the shell-out to scripts/new-atom.sh and
# returns a synthesized success payload. Used exclusively by tests.
mock-generate = []

View file

@ -0,0 +1,139 @@
//! Form request deserialization + validation.
//!
//! Accepts either `application/x-www-form-urlencoded` (HTML `<form>`) or
//! `application/json` (future API clients). Validation enforces the
//! locked substrate schema — verb naming (kebab-case) and atom kind
//! enumeration (command | query | stream | transform).
use serde::{Deserialize, Serialize};
/// Incoming POST /forge body.
///
/// `crate` is renamed because it's a Rust reserved word.
#[derive(Debug, Deserialize, Serialize)]
pub struct ForgeRequest {
#[serde(rename = "crate")]
pub crate_name: String,
pub verb: String,
pub kind: String,
pub description: String,
}
/// Validation outcome. `Ok(())` if the request matches schema constraints.
pub fn validate(req: &ForgeRequest) -> Result<(), String> {
validate_crate_name(&req.crate_name)?;
validate_verb(&req.verb)?;
validate_kind(&req.kind)?;
Ok(())
}
fn validate_crate_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("crate must not be empty".to_string());
}
if !is_kebab_lower(name) {
return Err(format!(
"crate must be lowercase kebab-case (got '{name}')"
));
}
Ok(())
}
fn validate_verb(verb: &str) -> Result<(), String> {
if verb.is_empty() {
return Err("verb must not be empty".to_string());
}
if !is_kebab_lower(verb) {
return Err(format!(
"verb must be lowercase kebab-case (got '{verb}')"
));
}
Ok(())
}
fn validate_kind(kind: &str) -> Result<(), String> {
match kind {
"command" | "query" | "stream" | "transform" => Ok(()),
other => Err(format!(
"kind must be command|query|stream|transform (got '{other}')"
)),
}
}
/// Matches regex `^[a-z][a-z0-9]*(-[a-z0-9]+)*$` without pulling in `regex`.
/// Hand-rolled because it's ~10 LOC and saves a workspace-wide dep.
fn is_kebab_lower(s: &str) -> bool {
let mut chars = s.chars();
let Some(first) = chars.next() else {
return false;
};
if !first.is_ascii_lowercase() {
return false;
}
let mut prev_dash = false;
for c in chars {
match c {
'a'..='z' | '0'..='9' => prev_dash = false,
'-' if !prev_dash => prev_dash = true,
_ => return false,
}
}
!prev_dash
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_empty() {
assert!(validate_verb("").is_err());
assert!(validate_crate_name("").is_err());
}
#[test]
fn rejects_upper() {
assert!(validate_verb("addDependency").is_err());
}
#[test]
fn rejects_trailing_dash() {
assert!(validate_verb("add-").is_err());
}
#[test]
fn rejects_double_dash() {
assert!(validate_verb("add--dep").is_err());
}
#[test]
fn accepts_kebab() {
assert!(validate_verb("add-dependency").is_ok());
assert!(validate_verb("search").is_ok());
assert!(validate_verb("v2-rename").is_ok());
}
#[test]
fn accepts_known_kinds() {
for k in ["command", "query", "stream", "transform"] {
let req = ForgeRequest {
crate_name: "kei-task".into(),
verb: "noop".into(),
kind: k.into(),
description: "test".into(),
};
assert!(validate(&req).is_ok(), "kind {k} should validate");
}
}
#[test]
fn rejects_unknown_kind() {
let req = ForgeRequest {
crate_name: "kei-task".into(),
verb: "noop".into(),
kind: "saga".into(),
description: "test".into(),
};
assert!(validate(&req).is_err());
}
}

View file

@ -0,0 +1,146 @@
//! Atom-scaffolding generator.
//!
//! MVP implementation: shells out to `scripts/new-atom.sh` with form values
//! as argv and `ATOM_DESCRIPTION` in the environment. Parses the "Files
//! created:" block from stdout into a structured file-list.
//!
//! Follow-up (post-MVP): reimplement in pure Rust by reading
//! `_templates/atom/` directly, eliminating the shell dependency.
use crate::form::ForgeRequest;
use serde::Serialize;
use std::path::PathBuf;
use std::process::Command;
/// Result of a scaffolding attempt.
#[derive(Debug, Serialize)]
pub struct ForgeResult {
pub success: bool,
pub files: Vec<String>,
pub errors: Vec<String>,
}
impl ForgeResult {
pub fn ok(files: Vec<String>) -> Self {
Self {
success: true,
files,
errors: Vec::new(),
}
}
pub fn fail(err: impl Into<String>) -> Self {
Self {
success: false,
files: Vec::new(),
errors: vec![err.into()],
}
}
}
/// Locate the repo root by walking up from CARGO_MANIFEST_DIR until we
/// see `scripts/new-atom.sh`. Falls back to CWD if the env var isn't
/// set (e.g. when the binary is run detached from cargo).
fn repo_root() -> PathBuf {
let start = std::env::var("CARGO_MANIFEST_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| std::env::current_dir().unwrap_or_default());
let mut cur: &std::path::Path = &start;
loop {
if cur.join("scripts/new-atom.sh").exists() {
return cur.to_path_buf();
}
match cur.parent() {
Some(p) => cur = p,
None => return start,
}
}
}
/// Execute new-atom.sh. Honours the `mock-generate` cargo feature so
/// integration tests can exercise the HTTP surface without touching the
/// real filesystem.
pub fn forge(req: &ForgeRequest) -> ForgeResult {
if cfg!(feature = "mock-generate") {
return ForgeResult::ok(vec![format!(
"_primitives/_rust/{}/atoms/{}.md",
req.crate_name, req.verb
)]);
}
let root = repo_root();
let script = root.join("scripts/new-atom.sh");
if !script.exists() {
return ForgeResult::fail(format!(
"scripts/new-atom.sh not found under {}",
root.display()
));
}
let output = Command::new(&script)
.arg(&req.crate_name)
.arg(&req.verb)
.arg(&req.kind)
.env("ATOM_DESCRIPTION", &req.description)
.current_dir(&root)
.output();
match output {
Ok(out) if out.status.success() => {
let stdout = String::from_utf8_lossy(&out.stdout);
ForgeResult::ok(parse_file_list(&stdout))
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
ForgeResult::fail(if stderr.is_empty() {
format!("new-atom.sh exited with {:?}", out.status.code())
} else {
stderr
})
}
Err(e) => ForgeResult::fail(format!("failed to spawn new-atom.sh: {e}")),
}
}
/// Extract the file-list block from new-atom.sh stdout.
///
/// The script emits a `Files created:` heading followed by indented
/// paths, then a blank line, then `Next steps:`. We slice between the
/// two headings and trim each path.
fn parse_file_list(stdout: &str) -> Vec<String> {
let mut in_block = false;
let mut files = Vec::new();
for line in stdout.lines() {
let trimmed = line.trim();
if trimmed == "Files created:" {
in_block = true;
continue;
}
if in_block {
if trimmed.is_empty() || trimmed == "Next steps:" {
break;
}
files.push(trimmed.to_string());
}
}
files
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_file_list() {
let stdout = "\n✓ Scaffolded atom kei-task::search (query)\n\n\
Files created:\n a.md\n b.json\n c.rs\n\n\
Next steps:\n 1. edit\n";
let files = parse_file_list(stdout);
assert_eq!(files, vec!["a.md", "b.json", "c.rs"]);
}
#[test]
fn empty_when_no_block() {
assert!(parse_file_list("nothing here").is_empty());
}
}

View file

@ -0,0 +1,15 @@
//! kei-forge — local web wizard for scaffolding new atoms per the locked
//! SUBSTRATE-SCHEMA.md contract.
//!
//! Architecture (Constructor Pattern, one responsibility per file):
//! - [`server`] — axum router + HTML form handler
//! - [`form`] — request deserialization + validation
//! - [`generate`] — invoke scripts/new-atom.sh, parse output
//!
//! Public entry point is [`server::app`], which returns the fully-wired
//! `axum::Router` ready to be served by any bind target (production =
//! 127.0.0.1:8747; tests = random ephemeral port).
pub mod form;
pub mod generate;
pub mod server;

View file

@ -0,0 +1,25 @@
//! kei-forge binary entry point.
//!
//! Binds axum to `127.0.0.1:8747` and serves the atom-scaffolding wizard.
//! Port 8747 chosen for mnemonic `"TK4S"` (KT + 4 streams) and low conflict
//! probability — not registered with IANA, outside common dev-tool ranges.
use kei_forge::server;
use std::net::SocketAddr;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.init();
let addr: SocketAddr = "127.0.0.1:8747".parse()?;
let app = server::app();
let listener = tokio::net::TcpListener::bind(addr).await?;
println!("keisei forge ready — open http://localhost:8747/");
tracing::info!(%addr, "kei-forge listening");
axum::serve(listener, app).await?;
Ok(())
}

View file

@ -0,0 +1,84 @@
//! Axum router — GET / (HTML form) and POST /forge (scaffold handler).
//!
//! Intentionally stateless: no `AppState`, no handles, no async init.
//! Every request is self-contained. This lets tests spin up `app()` in
//! an ephemeral Tokio runtime without setup teardown overhead.
use axum::{
extract::Form,
http::StatusCode,
response::{Html, IntoResponse},
routing::{get, post},
Json, Router,
};
use crate::form::{validate, ForgeRequest};
use crate::generate::{forge, ForgeResult};
/// Build the router. Called by `main.rs` and by tests.
pub fn app() -> Router {
Router::new()
.route("/", get(render_form))
.route("/forge", post(handle_forge))
}
async fn render_form() -> Html<&'static str> {
Html(FORM_HTML)
}
async fn handle_forge(Form(req): Form<ForgeRequest>) -> impl IntoResponse {
if let Err(e) = validate(&req) {
return (StatusCode::BAD_REQUEST, Json(ForgeResult::fail(e)));
}
let result = forge(&req);
let status = if result.success {
StatusCode::OK
} else {
StatusCode::UNPROCESSABLE_ENTITY
};
(status, Json(result))
}
/// Minimal HTML — 5 inputs, no JS, no CSS framework. The locked schema
/// allows only 4 atom kinds, hard-coded as a `<select>`.
const FORM_HTML: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>kei-forge</title>
</head>
<body>
<h1>kei-forge scaffold an atom</h1>
<p>Per <a href="/static/SCHEMA-LOCKED.md">locked substrate schema</a>.</p>
<form method="POST" action="/forge">
<p>
<label>crate:
<input name="crate" required pattern="[a-z][a-z0-9-]*" placeholder="kei-task">
</label>
</p>
<p>
<label>verb:
<input name="verb" required pattern="[a-z][a-z0-9-]*" placeholder="add-dependency">
</label>
</p>
<p>
<label>kind:
<select name="kind" required>
<option value="command">command</option>
<option value="query">query</option>
<option value="stream">stream</option>
<option value="transform">transform</option>
</select>
</label>
</p>
<p>
<label>description:<br>
<textarea name="description" rows="3" cols="60"
placeholder="One-line purpose. Used in atoms/&lt;verb&gt;.md"></textarea>
</label>
</p>
<p><button type="submit">forge atom</button></p>
</form>
</body>
</html>
"#;

View file

@ -0,0 +1,99 @@
//! Integration smoke test for kei-forge.
//!
//! Exercises GET / and POST /forge via `tower::ServiceExt::oneshot` on
//! the Router — no real socket, no real shell-out. The `mock-generate`
//! feature makes `generate::forge` return a synthesized success payload
//! so the test never touches the filesystem.
//!
//! Run with: `cargo test -p kei-forge --features mock-generate`
use axum::{
body::{to_bytes, Body},
http::{Request, StatusCode},
};
use kei_forge::server;
use serde_json::Value;
use tower::ServiceExt;
#[tokio::test]
async fn get_root_serves_form() {
let app = server::app();
let resp = app
.oneshot(
Request::builder()
.uri("/")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = to_bytes(resp.into_body(), 64 * 1024).await.unwrap();
let html = std::str::from_utf8(&body).unwrap();
assert!(html.contains("kei-forge"));
assert!(html.contains("<form"));
assert!(html.contains("name=\"verb\""));
assert!(html.contains("name=\"kind\""));
}
#[tokio::test]
async fn post_forge_returns_json_shape() {
let app = server::app();
let body = "crate=kei-task&verb=add-dependency&kind=command&description=test+desc";
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/forge")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap();
let json: Value = serde_json::from_slice(&bytes).expect("response is JSON");
assert!(json.get("success").is_some(), "missing success field");
assert!(json.get("files").is_some(), "missing files field");
assert!(json.get("errors").is_some(), "missing errors field");
// With --features mock-generate we return success; without, the shell-out
// may succeed or fail depending on whether scripts/new-atom.sh lives in
// the worktree. Either way the schema must be correct.
assert!(
status == StatusCode::OK
|| status == StatusCode::UNPROCESSABLE_ENTITY
|| status == StatusCode::BAD_REQUEST,
"unexpected status {status}"
);
}
#[tokio::test]
async fn post_forge_rejects_bad_kind() {
let app = server::app();
let body = "crate=kei-task&verb=x&kind=saga&description=y";
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/forge")
.header("content-type", "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap();
let json: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["success"], Value::Bool(false));
let errs = json["errors"].as_array().unwrap();
assert!(!errs.is_empty());
}