From be20f5ba4637d6f19647fed0f3d13dcd407aa381 Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Tue, 21 Apr 2026 20:57:15 +0800 Subject: [PATCH] feat(primitives): kei-docs-scaffold shell + kei-changelog Rust --- _primitives/_rust/kei-changelog/Cargo.toml | 24 ++ _primitives/_rust/kei-changelog/src/commit.rs | 77 +++++ _primitives/_rust/kei-changelog/src/group.rs | 39 +++ _primitives/_rust/kei-changelog/src/lib.rs | 16 + _primitives/_rust/kei-changelog/src/main.rs | 88 ++++++ _primitives/_rust/kei-changelog/src/parse.rs | 58 ++++ _primitives/_rust/kei-changelog/src/render.rs | 74 +++++ _primitives/_rust/kei-changelog/src/walk.rs | 57 ++++ .../_rust/kei-changelog/tests/parse.rs | 51 ++++ .../_rust/kei-changelog/tests/render.rs | 60 ++++ _primitives/kei-docs-scaffold.sh | 285 ++++++++++++++++++ 11 files changed, 829 insertions(+) create mode 100644 _primitives/_rust/kei-changelog/Cargo.toml create mode 100644 _primitives/_rust/kei-changelog/src/commit.rs create mode 100644 _primitives/_rust/kei-changelog/src/group.rs create mode 100644 _primitives/_rust/kei-changelog/src/lib.rs create mode 100644 _primitives/_rust/kei-changelog/src/main.rs create mode 100644 _primitives/_rust/kei-changelog/src/parse.rs create mode 100644 _primitives/_rust/kei-changelog/src/render.rs create mode 100644 _primitives/_rust/kei-changelog/src/walk.rs create mode 100644 _primitives/_rust/kei-changelog/tests/parse.rs create mode 100644 _primitives/_rust/kei-changelog/tests/render.rs create mode 100755 _primitives/kei-docs-scaffold.sh diff --git a/_primitives/_rust/kei-changelog/Cargo.toml b/_primitives/_rust/kei-changelog/Cargo.toml new file mode 100644 index 0000000..0f1cda1 --- /dev/null +++ b/_primitives/_rust/kei-changelog/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "kei-changelog" +version = "0.1.0" +edition = "2021" +description = "Git-cliff-style CHANGELOG.md generator from Conventional Commits." +license = "MIT OR Apache-2.0" + +[[bin]] +name = "kei-changelog" +path = "src/main.rs" + +[lib] +path = "src/lib.rs" + +[dependencies] +anyhow = "1" +chrono = { version = "0.4", default-features = false, features = ["clock"] } +clap = { version = "4", features = ["derive"] } +git2 = { version = "0.19", default-features = false } +regex = "1" + +[profile.release] +opt-level = 3 +lto = "thin" diff --git a/_primitives/_rust/kei-changelog/src/commit.rs b/_primitives/_rust/kei-changelog/src/commit.rs new file mode 100644 index 0000000..0206b5f --- /dev/null +++ b/_primitives/_rust/kei-changelog/src/commit.rs @@ -0,0 +1,77 @@ +//! Commit model — parsed conventional-commit record. + +use std::fmt; + +/// Conventional-commit kind. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum CommitKind { + Feat, + Fix, + Refactor, + Docs, + Test, + Chore, + Perf, + Ci, + Build, + Checkpoint, + Audit, + /// Anything we do not recognise as conventional. + Other(String), +} + +impl CommitKind { + /// Stable ordering for grouping in CHANGELOG.md (lower = earlier). + #[must_use] + pub fn sort_key(&self) -> u8 { + match self { + Self::Feat => 0, + Self::Fix => 1, + Self::Perf => 2, + Self::Refactor => 3, + Self::Docs => 4, + Self::Test => 5, + Self::Build => 6, + Self::Ci => 7, + Self::Chore => 8, + Self::Audit => 9, + Self::Checkpoint => 10, + Self::Other(_) => 11, + } + } + + /// Human-facing section heading used in `render::render_markdown`. + #[must_use] + pub fn heading(&self) -> &str { + match self { + Self::Feat => "Features", + Self::Fix => "Fixes", + Self::Perf => "Performance", + Self::Refactor => "Refactor", + Self::Docs => "Documentation", + Self::Test => "Tests", + Self::Build => "Build", + Self::Ci => "CI", + Self::Chore => "Chore", + Self::Audit => "Audit", + Self::Checkpoint => "Checkpoints", + Self::Other(_) => "Other", + } + } +} + +impl fmt::Display for CommitKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.heading()) + } +} + +/// Parsed commit record used by the walker and renderer. +#[derive(Debug, Clone)] +pub struct Commit { + pub sha: String, + pub kind: CommitKind, + pub scope: Option, + pub subject: String, + pub breaking: bool, +} diff --git a/_primitives/_rust/kei-changelog/src/group.rs b/_primitives/_rust/kei-changelog/src/group.rs new file mode 100644 index 0000000..c93018d --- /dev/null +++ b/_primitives/_rust/kei-changelog/src/group.rs @@ -0,0 +1,39 @@ +//! Group commits by kind, preserving insertion order within each bucket. + +use crate::commit::{Commit, CommitKind}; +use std::collections::BTreeMap; + +/// Commits grouped by `CommitKind`, sorted by `CommitKind::sort_key`. +#[derive(Debug, Default)] +pub struct Grouped { + pub by_kind: BTreeMap)>, + pub breaking: Vec, +} + +impl Grouped { + /// Build a `Grouped` from an ordered slice of commits. + /// + /// Breaking commits are additionally copied into `breaking` so renderers + /// can surface them in a "BREAKING CHANGES" section. + #[must_use] + pub fn from_commits(commits: &[Commit]) -> Self { + let mut g = Self::default(); + for c in commits { + if c.breaking { + g.breaking.push(c.clone()); + } + let key = c.kind.sort_key(); + g.by_kind + .entry(key) + .or_insert_with(|| (c.kind.clone(), Vec::new())) + .1 + .push(c.clone()); + } + g + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.by_kind.is_empty() && self.breaking.is_empty() + } +} diff --git a/_primitives/_rust/kei-changelog/src/lib.rs b/_primitives/_rust/kei-changelog/src/lib.rs new file mode 100644 index 0000000..85e49f5 --- /dev/null +++ b/_primitives/_rust/kei-changelog/src/lib.rs @@ -0,0 +1,16 @@ +//! kei-changelog — library surface. +//! +//! Public modules, re-exported for the binary and integration tests. +//! Constructor Pattern: one file = one concern; keep this root < 30 LOC. + +pub mod commit; +pub mod group; +pub mod parse; +pub mod render; +pub mod walk; + +pub use commit::{Commit, CommitKind}; +pub use group::Grouped; +pub use parse::parse_subject; +pub use render::{render_markdown, RenderOpts}; +pub use walk::{walk_range, WalkRange}; diff --git a/_primitives/_rust/kei-changelog/src/main.rs b/_primitives/_rust/kei-changelog/src/main.rs new file mode 100644 index 0000000..7b49b5b --- /dev/null +++ b/_primitives/_rust/kei-changelog/src/main.rs @@ -0,0 +1,88 @@ +//! kei-changelog — CLI entry point. +//! +//! Thin wrapper over the library modules. Keeps flag parsing + IO here; all +//! commit / render logic lives in `lib.rs`. + +use anyhow::{Context, Result}; +use clap::Parser; +use kei_changelog::{ + group::Grouped, render::render_markdown, render::RenderOpts, walk::walk_range, walk::WalkRange, +}; +use std::fs; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command(name = "kei-changelog", version, about = "Generate CHANGELOG.md from conventional commits")] +struct Cli { + /// Starting ref (exclusive). Defaults to the full history root. + #[arg(long)] + from: Option, + + /// Ending ref (inclusive). Defaults to `HEAD`. + #[arg(long, default_value = "HEAD")] + to: String, + + /// Treat the range as an Unreleased section (overrides --version heading). + #[arg(long)] + unreleased: bool, + + /// Version label for the rendered block (e.g. "v0.7.0"). Ignored with --unreleased. + #[arg(long, default_value = "v0.1.0")] + version: String, + + /// Repository path. Defaults to current directory. + #[arg(long, default_value = ".")] + repo: PathBuf, + + /// Prepend output to this file (creates if missing). Without it, prints to stdout. + #[arg(long)] + update: Option, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let range = WalkRange { + from: cli.from.clone(), + to: cli.to.clone(), + }; + let commits = walk_range(&cli.repo, &range)?; + let grouped = Grouped::from_commits(&commits); + + let version = if cli.unreleased { + "Unreleased".to_string() + } else { + cli.version.clone() + }; + let opts = RenderOpts::new(version); + let rendered = render_markdown(&grouped, &opts); + + if let Some(path) = cli.update.as_ref() { + let existing = fs::read_to_string(path).unwrap_or_default(); + let body = if existing.is_empty() { + format!("# CHANGELOG\n\n{rendered}") + } else { + prepend_section(&existing, &rendered) + }; + fs::write(path, body).with_context(|| format!("write {}", path.display()))?; + eprintln!("[kei-changelog] updated {}", path.display()); + } else { + print!("{rendered}"); + } + Ok(()) +} + +/// Insert `section` after the top-level `# CHANGELOG` heading if present, +/// otherwise prepend. Never duplicates an existing identical section verbatim. +fn prepend_section(existing: &str, section: &str) -> String { + if section.trim().is_empty() { + return existing.to_string(); + } + if existing.contains(section.trim()) { + return existing.to_string(); + } + if let Some(rest) = existing.strip_prefix("# CHANGELOG\n\n") { + format!("# CHANGELOG\n\n{section}{rest}") + } else { + format!("{section}\n{existing}") + } +} diff --git a/_primitives/_rust/kei-changelog/src/parse.rs b/_primitives/_rust/kei-changelog/src/parse.rs new file mode 100644 index 0000000..0b290fe --- /dev/null +++ b/_primitives/_rust/kei-changelog/src/parse.rs @@ -0,0 +1,58 @@ +//! Conventional-commit subject parser. +//! +//! Shape: `type(scope)!: subject` — scope and `!` optional. +//! Returns `(kind, scope, subject, breaking)`. Malformed → `Other` kind with +//! the full subject as `subject`. + +use crate::commit::CommitKind; +use regex::Regex; +use std::sync::OnceLock; + +fn re() -> &'static Regex { + static R: OnceLock = OnceLock::new(); + R.get_or_init(|| { + Regex::new(r"^(?P[a-zA-Z]+)(?:\((?P[^)]+)\))?(?P!)?:\s+(?P.+)$") + .expect("valid regex") + }) +} + +fn kind_from(raw: &str) -> CommitKind { + match raw.to_ascii_lowercase().as_str() { + "feat" => CommitKind::Feat, + "fix" => CommitKind::Fix, + "refactor" => CommitKind::Refactor, + "docs" => CommitKind::Docs, + "test" => CommitKind::Test, + "chore" => CommitKind::Chore, + "perf" => CommitKind::Perf, + "ci" => CommitKind::Ci, + "build" => CommitKind::Build, + "checkpoint" => CommitKind::Checkpoint, + "audit" => CommitKind::Audit, + other => CommitKind::Other(other.to_string()), + } +} + +/// Parse a commit subject line. +/// +/// Returns `(kind, scope, subject, breaking)`. On a non-conventional subject, +/// returns `(Other("_"), None, full_line, false)`. +#[must_use] +pub fn parse_subject(first_line: &str) -> (CommitKind, Option, String, bool) { + let trimmed = first_line.trim(); + match re().captures(trimmed) { + Some(c) => { + let kind = kind_from(&c["kind"]); + let scope = c.name("scope").map(|m| m.as_str().to_string()); + let subject = c["subject"].to_string(); + let breaking = c.name("bang").is_some(); + (kind, scope, subject, breaking) + } + None => ( + CommitKind::Other("_".into()), + None, + trimmed.to_string(), + false, + ), + } +} diff --git a/_primitives/_rust/kei-changelog/src/render.rs b/_primitives/_rust/kei-changelog/src/render.rs new file mode 100644 index 0000000..1fdc224 --- /dev/null +++ b/_primitives/_rust/kei-changelog/src/render.rs @@ -0,0 +1,74 @@ +//! Render a `Grouped` set of commits as a CHANGELOG.md section. + +use crate::group::Grouped; +use chrono::{DateTime, Utc}; + +/// Options governing the rendered section. +#[derive(Debug, Clone)] +pub struct RenderOpts { + /// Heading for the version block, e.g. "v0.7.0" or "Unreleased". + pub version: String, + /// Optional release date. If `None`, uses today (UTC). + pub date: Option>, + /// If true, include short (7-char) SHA suffix on each line. + pub include_sha: bool, +} + +impl RenderOpts { + #[must_use] + pub fn new(version: impl Into) -> Self { + Self { + version: version.into(), + date: None, + include_sha: true, + } + } +} + +fn fmt_line(subj: &str, scope: Option<&str>, sha: Option<&str>) -> String { + let mut s = String::new(); + s.push_str("- "); + if let Some(sc) = scope { + s.push_str("**"); + s.push_str(sc); + s.push_str(":** "); + } + s.push_str(subj); + if let Some(h) = sha { + s.push_str(&format!(" (`{}`)", &h[..h.len().min(7)])); + } + s +} + +/// Render the grouped commits into markdown. Returns an empty string if the +/// grouping has no entries (caller can detect via `Grouped::is_empty`). +#[must_use] +pub fn render_markdown(grouped: &Grouped, opts: &RenderOpts) -> String { + if grouped.is_empty() { + return String::new(); + } + let date = opts.date.unwrap_or_else(Utc::now).format("%Y-%m-%d"); + let mut out = String::new(); + out.push_str(&format!("## {} — {date}\n\n", opts.version)); + + if !grouped.breaking.is_empty() { + out.push_str("### BREAKING CHANGES\n\n"); + for c in &grouped.breaking { + let sha = if opts.include_sha { Some(c.sha.as_str()) } else { None }; + out.push_str(&fmt_line(&c.subject, c.scope.as_deref(), sha)); + out.push('\n'); + } + out.push('\n'); + } + + for (_, (kind, commits)) in &grouped.by_kind { + out.push_str(&format!("### {}\n\n", kind.heading())); + for c in commits { + let sha = if opts.include_sha { Some(c.sha.as_str()) } else { None }; + out.push_str(&fmt_line(&c.subject, c.scope.as_deref(), sha)); + out.push('\n'); + } + out.push('\n'); + } + out +} diff --git a/_primitives/_rust/kei-changelog/src/walk.rs b/_primitives/_rust/kei-changelog/src/walk.rs new file mode 100644 index 0000000..994ab8e --- /dev/null +++ b/_primitives/_rust/kei-changelog/src/walk.rs @@ -0,0 +1,57 @@ +//! git2 walker — collect commits between two refs. + +use crate::commit::Commit; +use crate::parse::parse_subject; +use anyhow::{Context, Result}; +use git2::{Oid, Repository, Sort}; + +/// Range specification passed in from CLI. +#[derive(Debug, Clone)] +pub struct WalkRange { + pub from: Option, + pub to: String, +} + +fn resolve(repo: &Repository, name: &str) -> Result { + let obj = repo + .revparse_single(name) + .with_context(|| format!("cannot resolve ref: {name}"))?; + Ok(obj.id()) +} + +/// Walk commits in topological order (newest first) from `to` back to `from`. +/// If `from` is `None`, walks the full history reachable from `to`. +pub fn walk_range(repo_path: &std::path::Path, range: &WalkRange) -> Result> { + let repo = Repository::discover(repo_path) + .with_context(|| format!("not a git repo: {}", repo_path.display()))?; + let to_oid = resolve(&repo, &range.to)?; + let from_oid = match &range.from { + Some(name) => Some(resolve(&repo, name)?), + None => None, + }; + + let mut revwalk = repo.revwalk()?; + revwalk.set_sorting(Sort::TOPOLOGICAL)?; + revwalk.push(to_oid)?; + if let Some(f) = from_oid { + revwalk.hide(f)?; + } + + let mut out: Vec = Vec::new(); + for oid in revwalk { + let oid = oid?; + let commit = repo.find_commit(oid)?; + let first = commit.summary().unwrap_or("").to_string(); + let body = commit.body().unwrap_or(""); + let (kind, scope, subject, breaking_bang) = parse_subject(&first); + let breaking = breaking_bang || body.contains("BREAKING CHANGE"); + out.push(Commit { + sha: oid.to_string(), + kind, + scope, + subject, + breaking, + }); + } + Ok(out) +} diff --git a/_primitives/_rust/kei-changelog/tests/parse.rs b/_primitives/_rust/kei-changelog/tests/parse.rs new file mode 100644 index 0000000..3eb802c --- /dev/null +++ b/_primitives/_rust/kei-changelog/tests/parse.rs @@ -0,0 +1,51 @@ +use kei_changelog::{parse_subject, CommitKind}; + +#[test] +fn feat_with_scope() { + let (kind, scope, subj, breaking) = parse_subject("feat(blocks): 5 documentation blocks"); + assert_eq!(kind, CommitKind::Feat); + assert_eq!(scope.as_deref(), Some("blocks")); + assert_eq!(subj, "5 documentation blocks"); + assert!(!breaking); +} + +#[test] +fn fix_no_scope() { + let (kind, scope, _, breaking) = parse_subject("fix: off-by-one in walker"); + assert_eq!(kind, CommitKind::Fix); + assert!(scope.is_none()); + assert!(!breaking); +} + +#[test] +fn breaking_bang() { + let (kind, _, _, breaking) = parse_subject("feat(api)!: rename endpoint"); + assert_eq!(kind, CommitKind::Feat); + assert!(breaking); +} + +#[test] +fn unknown_kind_falls_to_other() { + let (kind, _, _, _) = parse_subject("nonsense: whatever"); + match kind { + CommitKind::Other(raw) => assert_eq!(raw, "nonsense"), + other => panic!("expected Other, got {other:?}"), + } +} + +#[test] +fn non_conventional_subject() { + let (kind, scope, subj, _) = parse_subject("just a plain message"); + match kind { + CommitKind::Other(raw) => assert_eq!(raw, "_"), + other => panic!("expected Other('_'), got {other:?}"), + } + assert!(scope.is_none()); + assert_eq!(subj, "just a plain message"); +} + +#[test] +fn checkpoint_kind() { + let (kind, _, _, _) = parse_subject("checkpoint: before big refactor"); + assert_eq!(kind, CommitKind::Checkpoint); +} diff --git a/_primitives/_rust/kei-changelog/tests/render.rs b/_primitives/_rust/kei-changelog/tests/render.rs new file mode 100644 index 0000000..4dddd18 --- /dev/null +++ b/_primitives/_rust/kei-changelog/tests/render.rs @@ -0,0 +1,60 @@ +use chrono::{TimeZone, Utc}; +use kei_changelog::{render_markdown, Commit, CommitKind, Grouped, RenderOpts}; + +fn mk(kind: CommitKind, scope: Option<&str>, subject: &str, breaking: bool, sha: &str) -> Commit { + Commit { + sha: sha.to_string(), + kind, + scope: scope.map(str::to_string), + subject: subject.to_string(), + breaking, + } +} + +#[test] +fn renders_feat_and_fix_sections() { + let commits = vec![ + mk(CommitKind::Feat, Some("blocks"), "5 blocks", false, "abcdef1234"), + mk(CommitKind::Fix, None, "regex bug", false, "1234567890"), + ]; + let grouped = Grouped::from_commits(&commits); + let mut opts = RenderOpts::new("v0.1.0"); + opts.date = Some(Utc.with_ymd_and_hms(2026, 4, 21, 0, 0, 0).unwrap()); + let out = render_markdown(&grouped, &opts); + assert!(out.starts_with("## v0.1.0 — 2026-04-21\n")); + assert!(out.contains("### Features")); + assert!(out.contains("### Fixes")); + assert!(out.contains("**blocks:** 5 blocks (`abcdef1`)")); + assert!(out.contains("- regex bug (`1234567`)")); +} + +#[test] +fn breaking_section_comes_first() { + let commits = vec![ + mk(CommitKind::Feat, None, "non-breaking", false, "aaaaaaa"), + mk(CommitKind::Feat, Some("api"), "rename", true, "bbbbbbb"), + ]; + let grouped = Grouped::from_commits(&commits); + let out = render_markdown(&grouped, &RenderOpts::new("v1.0.0")); + let bi = out.find("BREAKING CHANGES").expect("section present"); + let fi = out.find("### Features").expect("features present"); + assert!(bi < fi, "BREAKING must come before Features"); +} + +#[test] +fn empty_grouped_renders_empty() { + let grouped = Grouped::from_commits(&[]); + let out = render_markdown(&grouped, &RenderOpts::new("v0.0.0")); + assert!(out.is_empty()); +} + +#[test] +fn include_sha_false_hides_hash() { + let commits = vec![mk(CommitKind::Feat, None, "x", false, "deadbeef0")]; + let grouped = Grouped::from_commits(&commits); + let mut opts = RenderOpts::new("v0.1.0"); + opts.include_sha = false; + let out = render_markdown(&grouped, &opts); + assert!(!out.contains("deadbee")); + assert!(out.contains("- x")); +} diff --git a/_primitives/kei-docs-scaffold.sh b/_primitives/kei-docs-scaffold.sh new file mode 100755 index 0000000..7226854 --- /dev/null +++ b/_primitives/kei-docs-scaffold.sh @@ -0,0 +1,285 @@ +#!/bin/sh +# kei-docs-scaffold — detect project type, generate missing docs from templates. +# First-class primitive, POSIX sh (no bash-isms), ports to KeiSeiKit convention. +# Install path: $HOME/.claude/agents/_primitives/kei-docs-scaffold.sh +# +# Usage: +# kei-docs-scaffold.sh [--type=all|claude|decisions|runbook|readme] [--force] [--dry-run] [DIR] +# +# Flags: +# --type=TYPE Which doc to scaffold. Default: all. +# Values: all | claude | decisions | runbook | readme +# --force Overwrite existing files. Default: skip if present. +# --dry-run Print actions, do not write. +# DIR Project directory. Default: $PWD. +# +# Detection: examines DIR for Cargo.toml / package.json / pyproject.toml / +# pubspec.yaml / go.mod / Package.swift / docker-compose.yml. Writes +# scaffolds pre-filled with the detected stack name. +# Safe to re-run: idempotent without --force. + +set -eu + +# ---- defaults ------------------------------------------------------------- +TYPE="all" +FORCE=0 +DRY_RUN=0 +DIR="" + +# ---- flag parsing (POSIX, no getopt_long) --------------------------------- +while [ $# -gt 0 ]; do + case "$1" in + --type=*) TYPE="${1#--type=}" ;; + --type) shift; TYPE="$1" ;; + --force) FORCE=1 ;; + --dry-run) DRY_RUN=1 ;; + -h|--help) + sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + -*) + printf '[scaffold] unknown flag: %s\n' "$1" >&2 + exit 2 + ;; + *) + [ -z "$DIR" ] || { printf '[scaffold] multiple DIR args\n' >&2; exit 2; } + DIR="$1" + ;; + esac + shift +done + +DIR="${DIR:-$PWD}" +[ -d "$DIR" ] || { printf '[scaffold] not a directory: %s\n' "$DIR" >&2; exit 2; } + +case "$TYPE" in + all|claude|decisions|runbook|readme) : ;; + *) printf '[scaffold] invalid --type: %s\n' "$TYPE" >&2; exit 2 ;; +esac + +# ---- stack detection ------------------------------------------------------ +detect_stack() { + if [ -f "$DIR/Cargo.toml" ]; then echo "Rust (Cargo)" + elif [ -f "$DIR/pubspec.yaml" ]; then echo "Flutter / Dart" + elif [ -f "$DIR/package.json" ]; then echo "Node.js / TypeScript" + elif [ -f "$DIR/pyproject.toml" ]; then echo "Python (pyproject)" + elif [ -f "$DIR/requirements.txt" ]; then echo "Python (pip)" + elif [ -f "$DIR/go.mod" ]; then echo "Go" + elif [ -f "$DIR/Package.swift" ]; then echo "Swift (SPM)" + elif [ -f "$DIR/docker-compose.yml" ]; then echo "Docker (compose)" + else echo "Unknown" + fi +} + +detect_test_cmd() { + case "$1" in + "Rust (Cargo)") echo "cargo test --release && cargo clippy -- -D warnings" ;; + "Flutter / Dart") echo "flutter test && flutter analyze" ;; + "Node.js / TypeScript") echo "npm test" ;; + "Python"*) echo "pytest -q" ;; + "Go") echo "go test ./..." ;; + "Swift (SPM)") echo "swift test" ;; + *) echo "# TODO: set test command" ;; + esac +} + +STACK=$(detect_stack) +TEST_CMD=$(detect_test_cmd "$STACK") +PROJECT_NAME=$(basename "$DIR") + +printf '[scaffold] project: %s stack: %s\n' "$PROJECT_NAME" "$STACK" >&2 + +# ---- write helpers -------------------------------------------------------- +write_file() { + target="$1" + if [ -e "$target" ] && [ "$FORCE" -eq 0 ]; then + printf '[scaffold] skip (exists): %s\n' "$target" >&2 + return 0 + fi + if [ "$DRY_RUN" -eq 1 ]; then + printf '[scaffold] would write: %s\n' "$target" >&2 + cat > /dev/null + return 0 + fi + cat > "$target" + printf '[scaffold] wrote: %s\n' "$target" >&2 +} + +# ---- doc generators (one function per file, ≤ 30 LOC) --------------------- +gen_claude() { + write_file "$DIR/CLAUDE.md" < Agent-facing project guide. Read FIRST at session start. + +## Architecture + +- [ ] Layer 1 — +- [ ] Layer 2 — +- [ ] Data flow — + +## Stack + +- **Language / framework:** $STACK +- **Package manager:** +- **Test runner:** \`$TEST_CMD\` + +## Constraints + +- Constructor Pattern: file < 200 LOC, function < 30 LOC +- No new dependency without Plan Mode +- No \`.unwrap()\` / \`.expect()\` in prod paths (Rust) + +## Known issues + +- [ ] + +## Test invariants + +\`\`\` +$TEST_CMD +\`\`\` + +## Hot paths (double-audit on touch) + +- [ ] + +## References + +- \`DECISIONS.md\` — architectural decisions +- \`docs/runbook.md\` — ops playbook (if deployed) +EOF +} + +gen_decisions() { + write_file "$DIR/DECISIONS.md" <<'EOF' +# DECISIONS.md — Architectural Decision Records (MADR 4.0) + +> Append-only. One decision = one ADR entry. Never delete; supersede instead. + +## ADR-001 — Adopt Constructor Pattern +- **Status:** accepted +- **Date:** TODO +- **Deciders:** TODO +- **Evidence grade:** E4 + +### Context and problem statement +Projects tend to grow monolithic files / DI containers / abstract factories. +Debugging and refactoring slow down. + +### Decision drivers +- Every file readable in one screen (< 200 LOC) +- Every function fits in one mental buffer (< 30 LOC) +- Reproducibility: any cube swappable + +### Considered options +1. **Constructor Pattern** — 1 file = 1 class = 1 responsibility +2. Classical layered OOP — DI containers, mixins +3. Free-form — no rules + +### Decision outcome +Chosen: **Option 1** — Constructor Pattern. + +### Consequences +- Good: easy audit, easy swap, cubes compose +- Bad: more files, more module boundaries +- Neutral: matches KeiSeiKit kit defaults + +### Verification +File-size lint on every PR. See repo CI. +EOF +} + +gen_runbook() { + mkdir -p "$DIR/docs" + write_file "$DIR/docs/runbook.md" <<'EOF' +# Runbook — Ops Playbook + +> One symptom = one entry. Check → Fix → Escalation. Read-only before write. + +## Service does not start + +### Check (read-only, < 5 min) +- `` — expected: running +- Log path: `` — grep for `panic|ERROR|fatal` +- Port open? `` + +### Fix (by likelihood, safest first) +1. **Config drift** — diff env vs template, reapply +2. **Port conflict** — kill conflicting process (NOT the service itself) +3. **Upstream outage** — check status page, fall back +4. **Data corruption** — restore from last checkpoint + +### Escalation +- After 15 min failed fixes → escalate to on-call +- After 3 repeats in 24h → open an incident review + +--- + +## Latency spike + +### Check +- `` +- Grep slow queries in `` + +### Fix +1. Restart worker (BENIGN first) +2. Check DB connection pool saturation +3. Scale up if sustained + +### Escalation +- After 30 min sustained p99 > SLO → page on-call +EOF +} + +gen_readme() { + write_file "$DIR/README.md" < One-line pitch: . + +## Why + + + +## Install + +\`\`\` +# TODO: paste copy-pasteable install command +\`\`\` + +## Quickstart + +\`\`\` +# TODO: minimal runnable example, ≤ 15 lines +\`\`\` + +## Features + +- [ ] Feature A — link to docs +- [ ] Feature B — link to docs + +## Architecture + +See \`CLAUDE.md\` for agent-facing details. Stack: **$STACK**. + +## Status + +Alpha. Versioning: SemVer 0.x. + +## License + +See \`LICENSE\`. +EOF +} + +# ---- dispatch ------------------------------------------------------------- +case "$TYPE" in + all) gen_claude; gen_decisions; gen_runbook; gen_readme ;; + claude) gen_claude ;; + decisions) gen_decisions ;; + runbook) gen_runbook ;; + readme) gen_readme ;; +esac + +printf '[scaffold] done\n' >&2