From f3f5f79760b609aa54de803749ba9c1fffbcea1d Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Fri, 1 May 2026 15:34:39 +0800 Subject: [PATCH] feat(frontend-loop): kei-db-contract primitive + frontend-validator agent + auto-dev-guard hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend continuous-quality loop landed. Three composable cubes: Wave 1 — kei-db-contract primitive (~870 LOC, 7 cubes per Constructor Pattern): - Diffs SQL CREATE TABLE migrations against TypeScript type/interface declarations - 4 drift modes: ORPHAN-SQL, ORPHAN-TS, TYPE-MISMATCH, NULL-MISMATCH - Reuses sqlparser-rs (Apache 2.0) + regex + walkdir + serde_json + clap - CLI: kei-db-contract [--output json|text] [--strict] - 5/5 integration tests pass (cargo check + cargo test green) - Smoke-tested on keisei-marketplace: drift_count=266 across 30 tables (expected — marketplace uses raw better-sqlite3 without explicit row types) Wave 2 — frontend-validator agent + dev-guard skill extension: - New _manifests/frontend-validator.toml (substrate_role: edit-local, tools: Bash+Read+Glob+Grep) - Agent runs: stack detect → tsc --noEmit → eslint → kei-db-contract → playwright (optional) - Severity rules: TYPE_CHECK FAIL = block, DB_CONTRACT drift > 0 = block, lint = advisory - skills/dev-guard/SKILL.md extended: 4th agent triggered on .tsx/.ts/.dart edits or DB-layer touches - adaptive-depth table extended with frontend + DB-layer rows Wave 3 — auto-dev-guard.sh hook (PostToolUse:Edit|Write): - Trivial-edit gate: skip if delta < 30 LOC (avoid spawn fatigue) - File-pattern match: *.tsx|*.ts|*.svelte|*.vue|*.dart OR migrations/*.sql OR src/db/** OR src/types/** OR prisma/schema.prisma OR drizzle.config.* - Auto-runs kei-db-contract for DB-layer edits if binary on PATH - Stderr advisory only (exit 0 always — never blocks) - Bypass: KEI_DISABLED_HOOKS or KEI_HOOK_PROFILE in {advisory-off, minimal, off} - Smoke-tested with synthetic Edit input (39 LOC delta on .tsx → emits advisory) - Registered in hooks/hooks.json under PostToolUse:Write|Edit chain Reusability map (Constructor Pattern compose): shared cubes: detect-stack, tsc, eslint, kei-db-contract, kei-visual-snapshot (deferred) orchestrators: /dev-start (pre), /dev-guard (during, NOW with frontend-validator), /dev-ship (final), /site-create (init) Verify-before-commit (RULE 0.13): - cargo check -p kei-db-contract: PASS - cargo test -p kei-db-contract: 5 passed - jq . hooks/hooks.json: valid - bash hooks/auto-dev-guard.sh < synthetic-input: works (frontend-relevant edit detected, exit 0) === STATUS-TRUTH MARKER === shipped: functional stubs: 0 cargo-check: PASS cargo-test: PASS (5 tests, 0 failures) behaviour-verified: yes follow-up-required: - kei-visual-snapshot primitive (Playwright wrap) — Wave 4, deferred - /dev-start frontend-contract-designer agent + /dev-ship frontend-final-gate — Wave 5, after Wave 1-3 obkatka - install.sh wiring for kei-db-contract binary - hermes-style emit-on-drift advisory mode Co-Authored-By: Claude Opus 4.7 (1M context) --- _manifests/frontend-validator.toml | 72 ++++++++ _primitives/_rust/Cargo.lock | 23 +++ _primitives/_rust/Cargo.toml | 2 + _primitives/_rust/kei-db-contract/Cargo.toml | 28 ++++ _primitives/_rust/kei-db-contract/src/diff.rs | 158 ++++++++++++++++++ _primitives/_rust/kei-db-contract/src/lib.rs | 16 ++ _primitives/_rust/kei-db-contract/src/main.rs | 68 ++++++++ .../_rust/kei-db-contract/src/matching.rs | 86 ++++++++++ .../_rust/kei-db-contract/src/output.rs | 56 +++++++ .../kei-db-contract/src/report_builders.rs | 70 ++++++++ .../_rust/kei-db-contract/src/sql_parse.rs | 103 ++++++++++++ .../_rust/kei-db-contract/src/ts_parse.rs | 126 ++++++++++++++ .../_rust/kei-db-contract/src/types_map.rs | 73 ++++++++ .../tests/contract_diff_basic.rs | 103 ++++++++++++ .../sample-project/migrations/0001_users.sql | 12 ++ .../fixtures/sample-project/src/types.ts | 15 ++ docs/DNA-INDEX.md | 20 ++- hooks/auto-dev-guard.sh | 100 +++++++++++ hooks/hooks.json | 5 + skills/dev-guard/SKILL.md | 43 ++++- 20 files changed, 1169 insertions(+), 10 deletions(-) create mode 100644 _manifests/frontend-validator.toml create mode 100644 _primitives/_rust/kei-db-contract/Cargo.toml create mode 100644 _primitives/_rust/kei-db-contract/src/diff.rs create mode 100644 _primitives/_rust/kei-db-contract/src/lib.rs create mode 100644 _primitives/_rust/kei-db-contract/src/main.rs create mode 100644 _primitives/_rust/kei-db-contract/src/matching.rs create mode 100644 _primitives/_rust/kei-db-contract/src/output.rs create mode 100644 _primitives/_rust/kei-db-contract/src/report_builders.rs create mode 100644 _primitives/_rust/kei-db-contract/src/sql_parse.rs create mode 100644 _primitives/_rust/kei-db-contract/src/ts_parse.rs create mode 100644 _primitives/_rust/kei-db-contract/src/types_map.rs create mode 100644 _primitives/_rust/kei-db-contract/tests/contract_diff_basic.rs create mode 100644 _primitives/_rust/kei-db-contract/tests/fixtures/sample-project/migrations/0001_users.sql create mode 100644 _primitives/_rust/kei-db-contract/tests/fixtures/sample-project/src/types.ts create mode 100755 hooks/auto-dev-guard.sh diff --git a/_manifests/frontend-validator.toml b/_manifests/frontend-validator.toml new file mode 100644 index 0000000..d96aaa3 --- /dev/null +++ b/_manifests/frontend-validator.toml @@ -0,0 +1,72 @@ +# Atomar agent — frontend continuous-quality validator. +# 1 cube = 1 responsibility. Edit this manifest, not the .md. + +name = "frontend-validator" +description = "Frontend continuous validator. Runs tsc --noEmit, eslint, kei-db-contract, optional visual snapshot. Surface drift between TS types and DB schema, type errors, lint regressions. Advisory by default." +tools = ["Glob", "Grep", "Read", "Bash"] +model = "opus" +substrate_role = "edit-local" + +role = """ +You are the frontend continuous-validator. Your job is to scan the current frontend project for drift and regressions, and to surface them before they reach the user. + +Your steps in order, each emitting a section of the final report: + +1. **Stack detect** — read package.json / pubspec.yaml / vite.config.* / next.config.* in the project root. State stack: Next.js / Vite / Flutter / SvelteKit / Astro / unknown. + +2. **Type-check** — run the appropriate type checker: + - TS / TSX → `npx tsc --noEmit` (or read existing `tsconfig.json`) + - Flutter → `dart analyze` + Capture errors. List file:line + message. Severity: BLOCK if any. + +3. **Lint** — run `npx eslint .` (or `dart analyze`, already covered). Capture errors and warnings separately. Severity: WARN. + +4. **DB-contract drift** — invoke `kei-db-contract --output json` if the binary exists in PATH. Parse JSON. List per-table drift: missing TS fields, orphan TS fields, type mismatches. Severity: ENFORCE if drift_count > 0 and project has DB; else N/A. + +5. **Visual regression (optional)** — if `playwright.config.*` exists AND a baseline snapshot dir is set, invoke `npx playwright test --reporter=json` for visual tests. Severity: WARN if any pixel diff exceeds threshold. + +6. **Verdict block** — summary table: each check, status (PASS / WARN / FAIL), brief evidence pointer. + +You do NOT autofix. You do NOT spawn other agents. You do NOT commit. You report. +""" + +blocks = [ + "baseline", + "evidence-grading", + "memory-protocol", +] + +domain_in = ["task scope (verbatim user prompt)", "project root path", "optional: changed file list from caller"] +forbidden_domain = [ + "hardcoded secrets (RULE 0.8)", + "git operations (orchestrator owns commits per RULE 0.13)", + "infrastructure deploys (delegate to infra-implementer)", +] +output_extra_fields = ["Stack detected", "Type errors count", "Lint warnings count", "DB drift count", "Visual diff count"] + +[[handoff]] +target = "code-implementer-typescript" +trigger = "TS type errors or lint failures need fixing" + +[[handoff]] +target = "validator" +trigger = "general fact-check fallback" + +[references] +extra = [ + "~/.claude/rules/code-style.md", + "~/.claude/rules/karpathy-behavioral.md", +] + +[taxonomy] +kingdom = "manifest" +mechanism = "compose" +domain = "agent" +layer = "agent-substrate" +stage = "design-time" +stability = "stable" +language = "toml" + +[lineage] +creator = "ag-orchestrator-human" +created = "2026-05-01" diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index 877fdc4..a47aed0 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -3409,6 +3409,20 @@ dependencies = [ "tempfile", ] +[[package]] +name = "kei-db-contract" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "regex", + "serde", + "serde_json", + "sqlparser", + "tempfile", + "walkdir", +] + [[package]] name = "kei-decision" version = "0.1.0" @@ -6680,6 +6694,15 @@ dependencies = [ "unicode_categories", ] +[[package]] +name = "sqlparser" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fe11944a61da0da3f592e19a45ebe5ab92dc14a779907ff1f08fbb797bfefc7" +dependencies = [ + "log", +] + [[package]] name = "sqlx" version = "0.8.0" diff --git a/_primitives/_rust/Cargo.toml b/_primitives/_rust/Cargo.toml index 6c9b520..8dc7c2c 100644 --- a/_primitives/_rust/Cargo.toml +++ b/_primitives/_rust/Cargo.toml @@ -175,6 +175,8 @@ members = [ "kei-cortex", "kei-tty", "kei-mcp", + # SQL ↔ TypeScript schema drift detector + "kei-db-contract", ] [workspace.package] diff --git a/_primitives/_rust/kei-db-contract/Cargo.toml b/_primitives/_rust/kei-db-contract/Cargo.toml new file mode 100644 index 0000000..b95cd02 --- /dev/null +++ b/_primitives/_rust/kei-db-contract/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "kei-db-contract" +version = "0.1.0" +edition.workspace = true +authors = ["Denis Parfionovich "] +rust-version.workspace = true +license = "Apache-2.0" +description = "Diff SQL migration schemas against TypeScript type declarations to catch frontend ↔ DB drift." + +[lib] +name = "kei_db_contract" +path = "src/lib.rs" + +[[bin]] +name = "kei-db-contract" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +regex = { workspace = true } +walkdir = { workspace = true } +anyhow = { workspace = true } +sqlparser = "0.51" + +[dev-dependencies] +tempfile = "3" diff --git a/_primitives/_rust/kei-db-contract/src/diff.rs b/_primitives/_rust/kei-db-contract/src/diff.rs new file mode 100644 index 0000000..13fbb32 --- /dev/null +++ b/_primitives/_rust/kei-db-contract/src/diff.rs @@ -0,0 +1,158 @@ +//! Diff cube: compare SqlTable vs TsType, produce per-field statuses. + +use crate::matching::{pair_tables_with_types, Pair}; +use crate::report_builders::{ + append_orphan_ts_fields, empty_report, orphan_table_report, orphan_type_report, +}; +use crate::sql_parse::{SqlColumn, SqlTable}; +use crate::ts_parse::{TsField, TsType}; +use crate::types_map::{null_compatible, sql_ts_compatible}; +use serde::Serialize; + +/// Status of a single field after pairing one SQL column with one TS field. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FieldStatus { + Ok, + OrphanSql, + OrphanTs, + TypeMismatch, + NullMismatch, +} + +/// Status of a paired (or orphan) table↔type unit. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TableStatus { + Ok, + Drift, + OrphanSql, + OrphanTs, +} + +/// One field-level row in the report. +#[derive(Debug, Clone, Serialize)] +pub struct FieldReport { + pub name: String, + pub sql_type: Option, + pub ts_type: Option, + pub status: FieldStatus, +} + +/// One table-level row in the report. +#[derive(Debug, Clone, Serialize)] +pub struct TableReport { + pub name: String, + pub status: TableStatus, + pub fields: Vec, +} + +/// Top-level report shape. +#[derive(Debug, Clone, Serialize)] +pub struct DriftReport { + pub drift_count: usize, + pub tables: Vec, +} + +/// Run the full diff over already-parsed inputs. +pub fn diff_project(tables: &[SqlTable], ts_types: &[TsType]) -> DriftReport { + let pairs = pair_tables_with_types(tables, ts_types); + let mut report_tables: Vec = Vec::new(); + let mut drift_count = 0usize; + for pair in pairs { + let tr = diff_pair(&pair); + if tr.status != TableStatus::Ok { + drift_count += count_drift_fields(&tr); + } + report_tables.push(tr); + } + DriftReport { + drift_count, + tables: report_tables, + } +} + +fn count_drift_fields(tr: &TableReport) -> usize { + tr.fields + .iter() + .filter(|f| f.status != FieldStatus::Ok) + .count() + .max(1) +} + +fn diff_pair(pair: &Pair<'_>) -> TableReport { + match (pair.table, pair.ts_type) { + (Some(t), Some(ty)) => diff_table_and_type(t, ty), + (Some(t), None) => orphan_table_report(t), + (None, Some(ty)) => orphan_type_report(ty), + (None, None) => empty_report(), + } +} + +fn diff_table_and_type(table: &SqlTable, ty: &TsType) -> TableReport { + let mut fields: Vec = Vec::new(); + let mut consumed = vec![false; ty.fields.len()]; + for col in &table.columns { + match find_ts_field(&ty.fields, &mut consumed, &col.name) { + Some(f) => fields.push(compare_one(col, f)), + None => fields.push(FieldReport { + name: col.name.clone(), + sql_type: Some(col.sql_type.clone()), + ts_type: None, + status: FieldStatus::OrphanSql, + }), + } + } + append_orphan_ts_fields(&mut fields, &consumed, &ty.fields); + let status = if fields.iter().all(|f| f.status == FieldStatus::Ok) { + TableStatus::Ok + } else { + TableStatus::Drift + }; + TableReport { + name: table.name.clone(), + status, + fields, + } +} + +fn find_ts_field<'a>( + ts_fields: &'a [TsField], + consumed: &mut [bool], + sql_name: &str, +) -> Option<&'a TsField> { + let target = canonicalize_field(sql_name); + for (i, f) in ts_fields.iter().enumerate() { + if consumed[i] { + continue; + } + if canonicalize_field(&f.name) == target { + consumed[i] = true; + return Some(f); + } + } + None +} + +fn compare_one(col: &SqlColumn, ts_field: &TsField) -> FieldReport { + let status = if !sql_ts_compatible(&col.sql_type, &ts_field.ts_type) { + FieldStatus::TypeMismatch + } else if !null_compatible(col.nullable, ts_field) { + FieldStatus::NullMismatch + } else { + FieldStatus::Ok + }; + FieldReport { + name: col.name.clone(), + sql_type: Some(col.sql_type.clone()), + ts_type: Some(ts_field.ts_type.clone()), + status, + } +} + +fn canonicalize_field(name: &str) -> String { + name.chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect::() + .to_ascii_lowercase() +} diff --git a/_primitives/_rust/kei-db-contract/src/lib.rs b/_primitives/_rust/kei-db-contract/src/lib.rs new file mode 100644 index 0000000..164d320 --- /dev/null +++ b/_primitives/_rust/kei-db-contract/src/lib.rs @@ -0,0 +1,16 @@ +//! kei-db-contract — diff SQL migration schemas against TypeScript types. +//! +//! Public API exists for integration tests. The binary lives in `main.rs`. + +pub mod diff; +pub mod matching; +pub mod output; +pub mod report_builders; +pub mod sql_parse; +pub mod ts_parse; +pub mod types_map; + +pub use diff::{diff_project, DriftReport, FieldStatus, TableStatus}; +pub use matching::pair_tables_with_types; +pub use sql_parse::{parse_migrations_dir, SqlColumn, SqlTable}; +pub use ts_parse::{parse_ts_glob, TsField, TsType}; diff --git a/_primitives/_rust/kei-db-contract/src/main.rs b/_primitives/_rust/kei-db-contract/src/main.rs new file mode 100644 index 0000000..495b373 --- /dev/null +++ b/_primitives/_rust/kei-db-contract/src/main.rs @@ -0,0 +1,68 @@ +//! kei-db-contract — CLI entrypoint. +//! +//! Diffs SQL migrations against TypeScript types in a project root. +//! Exit 0 when --strict not set OR no drift; exit 1 in --strict with drift; exit 2 on I/O error. + +use clap::{Parser, ValueEnum}; +use kei_db_contract::diff::diff_project; +use kei_db_contract::output::{render_json, render_text}; +use kei_db_contract::sql_parse::parse_migrations_dir; +use kei_db_contract::ts_parse::parse_ts_glob; +use std::path::PathBuf; +use std::process::ExitCode; + +#[derive(Parser, Debug)] +#[command( + name = "kei-db-contract", + about = "Diff SQL migrations against TypeScript types to catch drift." +)] +struct Cli { + /// Project root. + project_root: PathBuf, + /// Migrations directory (relative to project root). + #[arg(long, default_value = "migrations")] + migrations_dir: PathBuf, + /// TS source root (relative to project root). Walked recursively for `.ts`/`.tsx`. + #[arg(long, default_value = "src")] + types_dir: PathBuf, + /// Output format. + #[arg(long, value_enum, default_value_t = Format::Text)] + output: Format, + /// Exit 1 if drift_count > 0. + #[arg(long)] + strict: bool, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum Format { + Text, + Json, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + match run(cli) { + Ok(code) => code, + Err(err) => { + eprintln!("kei-db-contract: error: {:?}", err); + ExitCode::from(2) + } + } +} + +fn run(cli: Cli) -> anyhow::Result { + let mig_dir = cli.project_root.join(&cli.migrations_dir); + let ts_dir = cli.project_root.join(&cli.types_dir); + let tables = parse_migrations_dir(&mig_dir)?; + let ts_types = parse_ts_glob(&[ts_dir.as_path()])?; + let report = diff_project(&tables, &ts_types); + let text = match cli.output { + Format::Text => render_text(&report), + Format::Json => render_json(&report), + }; + println!("{}", text); + if cli.strict && report.drift_count > 0 { + return Ok(ExitCode::from(1)); + } + Ok(ExitCode::SUCCESS) +} diff --git a/_primitives/_rust/kei-db-contract/src/matching.rs b/_primitives/_rust/kei-db-contract/src/matching.rs new file mode 100644 index 0000000..56cedfd --- /dev/null +++ b/_primitives/_rust/kei-db-contract/src/matching.rs @@ -0,0 +1,86 @@ +//! Pair SQL tables with TS types using name heuristics: +//! `users` ≈ `User`, `magic_tokens` ≈ `MagicToken`, `auth_sessions` ≈ `AuthSession`. + +use crate::sql_parse::SqlTable; +use crate::ts_parse::TsType; + +/// One paired (or orphan) result from the matching step. +#[derive(Debug, Clone)] +pub struct Pair<'a> { + pub table: Option<&'a SqlTable>, + pub ts_type: Option<&'a TsType>, +} + +/// Pair every SQL table with at most one TS type and vice versa. +pub fn pair_tables_with_types<'a>( + tables: &'a [SqlTable], + ts_types: &'a [TsType], +) -> Vec> { + let mut consumed_ts: Vec = vec![false; ts_types.len()]; + let mut pairs: Vec> = Vec::new(); + for tbl in tables { + pairs.push(claim_one_table(tbl, ts_types, &mut consumed_ts)); + } + for (i, ts) in ts_types.iter().enumerate() { + if !consumed_ts[i] { + pairs.push(Pair { + table: None, + ts_type: Some(ts), + }); + } + } + pairs +} + +fn claim_one_table<'a>( + tbl: &'a SqlTable, + ts_types: &'a [TsType], + consumed_ts: &mut [bool], +) -> Pair<'a> { + let canonical = canonicalize_table(&tbl.name); + for (i, ts) in ts_types.iter().enumerate() { + if consumed_ts[i] { + continue; + } + if canonicalize_type(&ts.name) == canonical { + consumed_ts[i] = true; + return Pair { + table: Some(tbl), + ts_type: Some(&ts_types[i]), + }; + } + } + Pair { + table: Some(tbl), + ts_type: None, + } +} + +/// Strip plural -s, normalize separators, lowercase. `magic_tokens` → `magictoken`. +pub fn canonicalize_table(name: &str) -> String { + let cleaned: String = name + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-') + .collect(); + let no_seps = cleaned.replace(['_', '-'], ""); + let lower = no_seps.to_ascii_lowercase(); + strip_trailing_s(&lower) +} + +/// Lowercase + strip trailing -s. `MagicToken` → `magictoken`. +pub fn canonicalize_type(name: &str) -> String { + let lower = name.to_ascii_lowercase(); + strip_trailing_s(&lower) +} + +fn strip_trailing_s(s: &str) -> String { + if s.ends_with("ies") && s.len() > 3 { + let mut out = s[..s.len() - 3].to_string(); + out.push('y'); + return out; + } + if s.ends_with('s') && s.len() > 1 { + return s[..s.len() - 1].to_string(); + } + s.to_string() +} diff --git a/_primitives/_rust/kei-db-contract/src/output.rs b/_primitives/_rust/kei-db-contract/src/output.rs new file mode 100644 index 0000000..65b9ebf --- /dev/null +++ b/_primitives/_rust/kei-db-contract/src/output.rs @@ -0,0 +1,56 @@ +//! Render a `DriftReport` as JSON or human-readable text. + +use crate::diff::{DriftReport, FieldStatus, TableStatus}; + +/// Pretty-printed JSON via serde. +pub fn render_json(report: &DriftReport) -> String { + serde_json::to_string_pretty(report).unwrap_or_else(|_| "{}".to_string()) +} + +/// Human-readable summary; one block per table, severity-tagged. +pub fn render_text(report: &DriftReport) -> String { + let mut out = String::new(); + out.push_str(&format!( + "kei-db-contract: drift_count={} tables={}\n", + report.drift_count, + report.tables.len() + )); + for table in &report.tables { + out.push_str(&format_table(table)); + } + out +} + +fn format_table(table: &crate::diff::TableReport) -> String { + let tag = table_status_tag(&table.status); + let mut block = format!("\n[{}] {}\n", tag, table.name); + for field in &table.fields { + let ftag = field_status_tag(&field.status); + let sql = field.sql_type.as_deref().unwrap_or("-"); + let ts = field.ts_type.as_deref().unwrap_or("-"); + block.push_str(&format!( + " [{ftag}] {} :: sql={} ts={}\n", + field.name, sql, ts + )); + } + block +} + +fn table_status_tag(status: &TableStatus) -> &'static str { + match status { + TableStatus::Ok => "OK", + TableStatus::Drift => "DRIFT", + TableStatus::OrphanSql => "ORPHAN-SQL", + TableStatus::OrphanTs => "ORPHAN-TS", + } +} + +fn field_status_tag(status: &FieldStatus) -> &'static str { + match status { + FieldStatus::Ok => "OK", + FieldStatus::OrphanSql => "ORPHAN-SQL", + FieldStatus::OrphanTs => "ORPHAN-TS", + FieldStatus::TypeMismatch => "TYPE-MISMATCH", + FieldStatus::NullMismatch => "NULL-MISMATCH", + } +} diff --git a/_primitives/_rust/kei-db-contract/src/report_builders.rs b/_primitives/_rust/kei-db-contract/src/report_builders.rs new file mode 100644 index 0000000..d89259a --- /dev/null +++ b/_primitives/_rust/kei-db-contract/src/report_builders.rs @@ -0,0 +1,70 @@ +//! Small builders that translate raw SQL/TS pieces into report rows. + +use crate::diff::{FieldReport, FieldStatus, TableReport, TableStatus}; +use crate::sql_parse::SqlTable; +use crate::ts_parse::{TsField, TsType}; + +/// Whole-table report for an SQL table with no matching TS type. +pub fn orphan_table_report(t: &SqlTable) -> TableReport { + let fields = t + .columns + .iter() + .map(|c| FieldReport { + name: c.name.clone(), + sql_type: Some(c.sql_type.clone()), + ts_type: None, + status: FieldStatus::OrphanSql, + }) + .collect(); + TableReport { + name: t.name.clone(), + status: TableStatus::OrphanSql, + fields, + } +} + +/// Whole-table report for a TS type with no matching SQL table. +pub fn orphan_type_report(ty: &TsType) -> TableReport { + let fields = ty + .fields + .iter() + .map(|f| FieldReport { + name: f.name.clone(), + sql_type: None, + ts_type: Some(f.ts_type.clone()), + status: FieldStatus::OrphanTs, + }) + .collect(); + TableReport { + name: ty.name.clone(), + status: TableStatus::OrphanTs, + fields, + } +} + +/// Vacuous report for the (None, None) pair (only triggered by an empty workspace). +pub fn empty_report() -> TableReport { + TableReport { + name: String::new(), + status: TableStatus::Ok, + fields: Vec::new(), + } +} + +/// Append every TS field that no SQL column claimed as orphan-TS rows. +pub fn append_orphan_ts_fields( + fields: &mut Vec, + consumed: &[bool], + ts_fields: &[TsField], +) { + for (i, f) in ts_fields.iter().enumerate() { + if !consumed[i] { + fields.push(FieldReport { + name: f.name.clone(), + sql_type: None, + ts_type: Some(f.ts_type.clone()), + status: FieldStatus::OrphanTs, + }); + } + } +} diff --git a/_primitives/_rust/kei-db-contract/src/sql_parse.rs b/_primitives/_rust/kei-db-contract/src/sql_parse.rs new file mode 100644 index 0000000..b838ce7 --- /dev/null +++ b/_primitives/_rust/kei-db-contract/src/sql_parse.rs @@ -0,0 +1,103 @@ +//! SQL parser cube: walks `migrations/*.sql`, extracts `CREATE TABLE` shapes. + +use anyhow::{Context, Result}; +use serde::Serialize; +use sqlparser::ast::{ColumnOption, DataType, Statement}; +use sqlparser::dialect::GenericDialect; +use sqlparser::parser::Parser; +use std::path::Path; +use walkdir::WalkDir; + +/// One column extracted from a migration `CREATE TABLE` statement. +#[derive(Debug, Clone, Serialize)] +pub struct SqlColumn { + pub name: String, + pub sql_type: String, + pub nullable: bool, +} + +/// One table extracted from a migration. Later tables with the same name override earlier ones. +#[derive(Debug, Clone, Serialize)] +pub struct SqlTable { + pub name: String, + pub columns: Vec, + pub source_file: String, +} + +/// Walk `dir` recursively for `.sql` files, return parsed tables. +pub fn parse_migrations_dir(dir: &Path) -> Result> { + let mut tables: Vec = Vec::new(); + if !dir.exists() { + return Ok(tables); + } + for entry in WalkDir::new(dir).sort_by_file_name() { + let entry = entry?; + if !entry.file_type().is_file() { + continue; + } + let path = entry.path(); + let ext = path.extension().and_then(|x| x.to_str()).unwrap_or(""); + if !ext.eq_ignore_ascii_case("sql") { + continue; + } + let text = std::fs::read_to_string(path) + .with_context(|| format!("read {}", path.display()))?; + let file_label = path.display().to_string(); + for table in parse_sql_text(&text, &file_label) { + upsert_table(&mut tables, table); + } + } + Ok(tables) +} + +fn upsert_table(tables: &mut Vec, new_table: SqlTable) { + if let Some(pos) = tables.iter().position(|t| t.name == new_table.name) { + tables[pos] = new_table; + } else { + tables.push(new_table); + } +} + +/// Parse one SQL document into zero or more table definitions. +pub fn parse_sql_text(text: &str, source: &str) -> Vec { + let dialect = GenericDialect {}; + let stmts = match Parser::parse_sql(&dialect, text) { + Ok(s) => s, + Err(_) => return Vec::new(), + }; + let mut out = Vec::new(); + for stmt in stmts { + if let Statement::CreateTable(create) = stmt { + let name = create + .name + .0 + .last() + .map(|p| p.value.clone()) + .unwrap_or_default(); + let columns = create.columns.iter().map(column_def_to_sql_column).collect(); + out.push(SqlTable { + name, + columns, + source_file: source.to_string(), + }); + } + } + out +} + +fn column_def_to_sql_column(col: &sqlparser::ast::ColumnDef) -> SqlColumn { + let nullable = !col + .options + .iter() + .any(|opt| matches!(opt.option, ColumnOption::NotNull)); + SqlColumn { + name: col.name.value.clone(), + sql_type: data_type_to_string(&col.data_type), + nullable, + } +} + +fn data_type_to_string(dt: &DataType) -> String { + let raw = format!("{}", dt); + raw.split('(').next().unwrap_or(&raw).trim().to_string() +} diff --git a/_primitives/_rust/kei-db-contract/src/ts_parse.rs b/_primitives/_rust/kei-db-contract/src/ts_parse.rs new file mode 100644 index 0000000..60c83c0 --- /dev/null +++ b/_primitives/_rust/kei-db-contract/src/ts_parse.rs @@ -0,0 +1,126 @@ +//! TS parser cube: shallow regex extraction of `type X = { ... }` and `interface X { ... }`. + +use anyhow::{Context, Result}; +use regex::Regex; +use serde::Serialize; +use std::path::Path; +use walkdir::WalkDir; + +/// One declared field on a TS type / interface. +#[derive(Debug, Clone, Serialize)] +pub struct TsField { + pub name: String, + pub ts_type: String, + pub optional: bool, +} + +/// One declared `type` alias or `interface` block. +#[derive(Debug, Clone, Serialize)] +pub struct TsType { + pub name: String, + pub fields: Vec, + pub source_file: String, +} + +/// Walk a directory recursively, parse every `.ts` and `.tsx` file. +pub fn parse_ts_glob(roots: &[&Path]) -> Result> { + let mut out = Vec::new(); + for root in roots { + if !root.exists() { + continue; + } + for entry in WalkDir::new(root).sort_by_file_name() { + let entry = entry?; + if !entry.file_type().is_file() { + continue; + } + let path = entry.path(); + let ext = path.extension().and_then(|x| x.to_str()).unwrap_or(""); + if !matches!(ext, "ts" | "tsx") { + continue; + } + let text = std::fs::read_to_string(path) + .with_context(|| format!("read {}", path.display()))?; + let label = path.display().to_string(); + out.extend(extract_ts_types(&text, &label)); + } + } + Ok(out) +} + +/// Extract every type/interface block from a single TS source string. +pub fn extract_ts_types(text: &str, source: &str) -> Vec { + let stripped = strip_line_comments(text); + let mut out = Vec::new(); + let header_re = Regex::new( + r"(?m)^(?:export\s+)?(?:type|interface)\s+([A-Z][A-Za-z0-9_]*)\s*(?:=\s*)?\{", + ) + .expect("static regex"); + for m in header_re.captures_iter(&stripped) { + let name = m.get(1).map(|x| x.as_str().to_string()).unwrap_or_default(); + let header_end = m.get(0).map(|x| x.end()).unwrap_or(0); + let Some(body) = capture_balanced_braces(&stripped, header_end) else { + continue; + }; + let fields = parse_ts_fields(&body); + out.push(TsType { + name, + fields, + source_file: source.to_string(), + }); + } + out +} + +fn strip_line_comments(text: &str) -> String { + text.lines() + .map(|line| match line.find("//") { + Some(idx) => line[..idx].to_string(), + None => line.to_string(), + }) + .collect::>() + .join("\n") +} + +fn capture_balanced_braces(text: &str, start: usize) -> Option { + let bytes = text.as_bytes(); + let mut depth = 1usize; + let mut idx = start; + while idx < bytes.len() { + match bytes[idx] { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + return Some(text[start..idx].to_string()); + } + } + _ => {} + } + idx += 1; + } + None +} + +fn parse_ts_fields(body: &str) -> Vec { + let field_re = Regex::new( + r"(?:readonly\s+)?([A-Za-z_][A-Za-z0-9_]*)(\?)?\s*:\s*([^;,\r\n]+?)\s*[;,\r\n]", + ) + .expect("static regex"); + let padded = format!("{};", body); + let mut out = Vec::new(); + for c in field_re.captures_iter(&padded) { + let name = c[1].to_string(); + let optional = c.get(2).is_some(); + let ts_type = c[3].trim().to_string(); + if ts_type.is_empty() || ts_type == "{" { + continue; + } + out.push(TsField { + name, + ts_type, + optional, + }); + } + out +} diff --git a/_primitives/_rust/kei-db-contract/src/types_map.rs b/_primitives/_rust/kei-db-contract/src/types_map.rs new file mode 100644 index 0000000..d9d9fcd --- /dev/null +++ b/_primitives/_rust/kei-db-contract/src/types_map.rs @@ -0,0 +1,73 @@ +//! SQL → TypeScript type compatibility table. +//! Conservative allow-list: anything not listed surfaces as drift. + +use crate::ts_parse::TsField; + +/// Returns true when the SQL column type is compatible with the TS field type. +pub fn sql_ts_compatible(sql_type: &str, ts_type: &str) -> bool { + let s = sql_type.to_ascii_uppercase(); + let t = ts_type.trim().to_ascii_lowercase(); + let core = strip_null_union(&t); + if is_text_family(&s) { + return core == "string" || core.contains("string"); + } + if is_int_family(&s) { + return core == "number" || core == "bigint" || core.contains("number"); + } + if is_float_family(&s) { + return core == "number" || core.contains("number"); + } + if s == "BLOB" { + return core.contains("buffer") || core.contains("uint8array"); + } + if matches!(s.as_str(), "BOOLEAN" | "BOOL") { + return core == "boolean"; + } + if is_temporal_family(&s) { + return core.contains("string") || core.contains("date") || core.contains("number"); + } + false +} + +fn is_text_family(s: &str) -> bool { + matches!( + s, + "TEXT" | "VARCHAR" | "CHAR" | "STRING" | "CLOB" | "NVARCHAR" | "JSON" | "JSONB" + ) +} + +fn is_int_family(s: &str) -> bool { + matches!( + s, + "INTEGER" | "INT" | "BIGINT" | "NUMERIC" | "DECIMAL" | "SMALLINT" + ) +} + +fn is_float_family(s: &str) -> bool { + matches!(s, "REAL" | "FLOAT" | "DOUBLE" | "DOUBLE PRECISION") +} + +fn is_temporal_family(s: &str) -> bool { + matches!(s, "DATETIME" | "TIMESTAMP" | "DATE" | "TIME") +} + +/// Filter out `null` / `undefined` from a TS union to get the core type set. +pub fn strip_null_union(t: &str) -> String { + t.split('|') + .map(|s| s.trim()) + .filter(|s| *s != "null" && *s != "undefined") + .collect::>() + .join(" | ") +} + +/// SQL column nullable + TS field shape ⇒ compatible? NOT NULL columns always pass. +pub fn null_compatible(sql_nullable: bool, ts: &TsField) -> bool { + let permits_null = ts.optional + || ts.ts_type.contains("null") + || ts.ts_type.contains("undefined"); + if sql_nullable { + permits_null + } else { + true + } +} diff --git a/_primitives/_rust/kei-db-contract/tests/contract_diff_basic.rs b/_primitives/_rust/kei-db-contract/tests/contract_diff_basic.rs new file mode 100644 index 0000000..03f8517 --- /dev/null +++ b/_primitives/_rust/kei-db-contract/tests/contract_diff_basic.rs @@ -0,0 +1,103 @@ +//! Integration tests for kei-db-contract. +//! Each test isolates its own input files in a tmpdir to avoid coupling. + +use kei_db_contract::diff::{diff_project, FieldStatus, TableStatus}; +use kei_db_contract::sql_parse::parse_migrations_dir; +use kei_db_contract::ts_parse::parse_ts_glob; +use std::fs; +use std::path::Path; + +fn write_project(root: &Path, sql: &str, ts: &str) { + let migrations = root.join("migrations"); + let src = root.join("src"); + fs::create_dir_all(&migrations).expect("mkdir migrations"); + fs::create_dir_all(&src).expect("mkdir src"); + fs::write(migrations.join("0001.sql"), sql).expect("write sql"); + fs::write(src.join("types.ts"), ts).expect("write ts"); +} + +fn run_diff(root: &Path) -> kei_db_contract::diff::DriftReport { + let tables = parse_migrations_dir(&root.join("migrations")).expect("parse sql"); + let ts_types = parse_ts_glob(&[root.join("src").as_path()]).expect("parse ts"); + diff_project(&tables, &ts_types) +} + +#[test] +fn no_drift_when_shapes_match() { + let tmp = tempfile::tempdir().unwrap(); + write_project( + tmp.path(), + "CREATE TABLE users (id INTEGER NOT NULL, email TEXT NOT NULL);", + "export type User = { id: number; email: string; };", + ); + let report = run_diff(tmp.path()); + assert_eq!(report.drift_count, 0, "expected no drift, got {report:?}"); + let users = &report.tables[0]; + assert_eq!(users.status, TableStatus::Ok); + assert!(users.fields.iter().all(|f| f.status == FieldStatus::Ok)); +} + +#[test] +fn orphan_sql_when_ts_missing_field() { + let tmp = tempfile::tempdir().unwrap(); + write_project( + tmp.path(), + "CREATE TABLE users (id INTEGER NOT NULL, email TEXT NOT NULL);", + "export type User = { id: number; };", + ); + let report = run_diff(tmp.path()); + assert!(report.drift_count >= 1); + let users = report.tables.iter().find(|t| t.name == "users").unwrap(); + assert_eq!(users.status, TableStatus::Drift); + let email = users.fields.iter().find(|f| f.name == "email").unwrap(); + assert_eq!(email.status, FieldStatus::OrphanSql); +} + +#[test] +fn orphan_ts_when_sql_missing_field() { + let tmp = tempfile::tempdir().unwrap(); + write_project( + tmp.path(), + "CREATE TABLE users (id INTEGER NOT NULL);", + "export type User = { id: number; phone: string; };", + ); + let report = run_diff(tmp.path()); + assert!(report.drift_count >= 1); + let users = report.tables.iter().find(|t| t.name == "users").unwrap(); + let phone = users.fields.iter().find(|f| f.name == "phone").unwrap(); + assert_eq!(phone.status, FieldStatus::OrphanTs); +} + +#[test] +fn type_mismatch_when_age_integer_vs_string() { + let tmp = tempfile::tempdir().unwrap(); + write_project( + tmp.path(), + "CREATE TABLE users (age INTEGER NOT NULL);", + "export type User = { age: string; };", + ); + let report = run_diff(tmp.path()); + assert!(report.drift_count >= 1); + let users = report.tables.iter().find(|t| t.name == "users").unwrap(); + let age = users.fields.iter().find(|f| f.name == "age").unwrap(); + assert_eq!(age.status, FieldStatus::TypeMismatch); +} + +#[test] +fn fixture_project_matches_expected_drift() { + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("sample-project"); + let tables = + parse_migrations_dir(&fixture.join("migrations")).expect("fixture parse sql"); + let ts_types = parse_ts_glob(&[fixture.join("src").as_path()]).expect("fixture parse ts"); + let report = diff_project(&tables, &ts_types); + let users = report.tables.iter().find(|t| t.name == "users").unwrap(); + let age = users.fields.iter().find(|f| f.name == "age").unwrap(); + assert_eq!(age.status, FieldStatus::TypeMismatch); + let phone = users.fields.iter().find(|f| f.name == "phone").unwrap(); + assert_eq!(phone.status, FieldStatus::OrphanTs); + let magic = report.tables.iter().find(|t| t.name == "magic_tokens").unwrap(); + assert_eq!(magic.status, TableStatus::Ok); +} diff --git a/_primitives/_rust/kei-db-contract/tests/fixtures/sample-project/migrations/0001_users.sql b/_primitives/_rust/kei-db-contract/tests/fixtures/sample-project/migrations/0001_users.sql new file mode 100644 index 0000000..ada3fea --- /dev/null +++ b/_primitives/_rust/kei-db-contract/tests/fixtures/sample-project/migrations/0001_users.sql @@ -0,0 +1,12 @@ +CREATE TABLE users ( + id INTEGER NOT NULL, + email TEXT NOT NULL, + age INTEGER NOT NULL, + bio TEXT +); + +CREATE TABLE magic_tokens ( + token TEXT NOT NULL, + user_id INTEGER NOT NULL, + expires_at TIMESTAMP NOT NULL +); diff --git a/_primitives/_rust/kei-db-contract/tests/fixtures/sample-project/src/types.ts b/_primitives/_rust/kei-db-contract/tests/fixtures/sample-project/src/types.ts new file mode 100644 index 0000000..a632185 --- /dev/null +++ b/_primitives/_rust/kei-db-contract/tests/fixtures/sample-project/src/types.ts @@ -0,0 +1,15 @@ +// Match: type↔table for users +export type User = { + id: number; + email: string; + age: string; // INTENTIONAL drift: SQL is INTEGER + phone: string; // INTENTIONAL drift: orphan TS field + bio: string | null; +}; + +// Match: type↔table for magic_tokens +export interface MagicToken { + token: string; + user_id: number; + expires_at: string; +} diff --git a/docs/DNA-INDEX.md b/docs/DNA-INDEX.md index b8d2a1f..f3bb25a 100644 --- a/docs/DNA-INDEX.md +++ b/docs/DNA-INDEX.md @@ -1,19 +1,19 @@ # KeiSeiKit DNA Encyclopedia -> Auto-generated from kei-registry. Last regenerated: 2026-05-01T06:08:41Z. -> Total blocks: 498. Per-type breakdown: +> Auto-generated from kei-registry. Last regenerated: 2026-05-01T07:31:58Z. +> Total blocks: 500. Per-type breakdown: | Type | Count | |---|---:| | atom | 117 | -| hook | 35 | -| primitive | 105 | +| hook | 36 | +| primitive | 106 | | rule | 174 | | skill | 67 | --- -## Primitive (105) +## Primitive (106) Sorted alphabetically by name. @@ -45,6 +45,7 @@ Sorted alphabetically by name. | kei-cron-scheduler | primitive::md,networ… | _primitives/_rust/kei-cron-scheduler/Cargo.toml | da2674f5 | | kei-crossdomain | primitive::cli,md,sq… | _primitives/_rust/kei-crossdomain/Cargo.toml | 7a263b47 | | kei-curator | primitive::cli,md,sq… | _primitives/_rust/kei-curator/Cargo.toml | dad1e6e3 | +| kei-db-contract::kei-db-contract | primitive::_::ef3f4c… | _primitives/_rust/kei-db-contract/Cargo.toml | e4c729d2 | | kei-decision | primitive::cli,fs,md… | _primitives/_rust/kei-decision/Cargo.toml | 29049ab5 | | kei-decompose | primitive::cli,fs,md… | _primitives/_rust/kei-decompose/Cargo.toml | 7495424e | | kei-diff | primitive::md::2f52c… | _primitives/_rust/kei-diff/Cargo.toml | 0b1d7d44 | @@ -78,7 +79,7 @@ Sorted alphabetically by name. | kei-memory-redis | primitive::md,networ… | _primitives/_rust/kei-memory-redis/Cargo.toml | fd7a49a9 | | kei-memory-sled | primitive::md,networ… | _primitives/_rust/kei-memory-sled/Cargo.toml | 6bd5485f | | kei-memory-sqlite | primitive::md,networ… | _primitives/_rust/kei-memory-sqlite/Cargo.toml | f64bbb1d | -| kei-memory::kei-memory | primitive::_::e47cd8… | _primitives/_rust/kei-memory/Cargo.toml | 2f7698b2 | +| kei-memory::kei-memory | primitive::_::e47cd8… | _primitives/_rust/kei-memory/Cargo.toml | 0dd1dfc8 | | kei-migrate | primitive::cli,hash,… | _primitives/_rust/kei-migrate/Cargo.toml | db2e7bd0 | | kei-model | primitive::cli,md,re… | _primitives/_rust/kei-model/Cargo.toml | 0a6ce8bc | | kei-model-router | primitive::md,sqlite… | _primitives/_rust/kei-model-router/Cargo.toml | 1280a1dd | @@ -833,7 +834,7 @@ Sorted alphabetically by name. | sleep-layer::the-rule | rule::_::576bbb7f::d… | d0e03a0d | -## Hook (35) +## Hook (36) Sorted alphabetically by name. @@ -848,6 +849,7 @@ Sorted alphabetically by name. | alignment-check | shell | hook::shell::01f8f21… | hooks/alignment-check.sh | | assemble-agents | shell | hook::shell::9cd98a7… | hooks/assemble-agents.sh | | assemble-validate | shell | hook::shell::eace6b3… | hooks/assemble-validate.sh | +| auto-dev-guard | shell | hook::shell::96e1fb2… | hooks/auto-dev-guard.sh | | block-dangerous | shell | hook::shell::e26e2af… | hooks/block-dangerous.sh | | check-error-patterns | shell | hook::shell::3bdab81… | hooks/check-error-patterns.sh | | citation-verify | shell | hook::shell::180a844… | hooks/citation-verify.sh | @@ -1003,10 +1005,12 @@ Sorted alphabetically by name. ## Supersede chains +- `/dev-guard — Continuous Development Guard` — 4 versions: 66daa27e → 59e77fbc → a1f93eb9 → 7ed68721 - `3D Scene Skill` — 2 versions: e31a87ca → ca06fcac - `foo` — 10 versions: 309b88fa → 309b88fa → 309b88fa → 309b88fa → 309b88fa → 309b88fa → 309b88fa → 309b88fa → 309b88fa → 309b88fa - `kei-cortex::kei-cortex` — 50 versions: 2305a894 → b046411d → 31e30021 → 0e1fdd58 → ee42ea3c → ea55151c → 5a91990e → 48b55962 → 9d197f44 → 44dcf2b8 → f82717c3 → 6beb14d1 → 7c783b8b → 6f4566d6 → ae6673fb → cb55caac → 0544a125 → 906fe71e → dda08557 → a9d9835c → c6bb1a76 → ff69e910 → 8c2a2cd0 → a4f10ba1 → 3e1d80b9 → a42dc172 → 9d1faba6 → 8c098c2a → ed51e643 → 8e611e78 → b0e5fc42 → d5acba40 → ea37b0a2 → ef485e8b → 4ee863b3 → 7b9b0b84 → b75a06c5 → 154d5906 → ccf3586b → bfa4e51e → 2d4d2abe → 5f7a5fac → ae4e5a1a → 81387a8b → 98f37df7 → 1f8a6a5e → a7910ea4 → bcbb7ede → 44165ca9 → 213f02fc -- `kei-memory::kei-memory` — 32 versions: adcd4146 → 4645a074 → a8883527 → 898880d6 → 63248191 → 13461cd3 → 43470a70 → a2665f92 → fc8f7afb → 347c6675 → 2405f427 → a64eaf5c → 6fd5449b → d8509f53 → bba89ea5 → 4c12d77d → 5940f848 → e3b6aa5d → 7de01ed1 → fd2b0d2d → 2054601f → 04b9f270 → 0e6a981d → 802f8487 → 0da8e0c7 → c136273f → 1035f140 → a02e197e → 739a6c0f → 5a1ebf4f → 0bf3b6f7 → 2f7698b2 +- `kei-db-contract::kei-db-contract` — 17 versions: 2e9d962a → 07651211 → e4200114 → facc4312 → 20bb0441 → dcd5de23 → bbd7a9df → 2662f63e → e067292d → e39caba6 → 42411821 → ec449d79 → 48d6d10f → c06e17c1 → 82de90e6 → e4c729d2 → 2ef926dc +- `kei-memory::kei-memory` — 33 versions: adcd4146 → 4645a074 → a8883527 → 898880d6 → 63248191 → 13461cd3 → 43470a70 → a2665f92 → fc8f7afb → 347c6675 → 2405f427 → a64eaf5c → 6fd5449b → d8509f53 → bba89ea5 → 4c12d77d → 5940f848 → e3b6aa5d → 7de01ed1 → fd2b0d2d → 2054601f → 04b9f270 → 0e6a981d → 802f8487 → 0da8e0c7 → c136273f → 1035f140 → a02e197e → 739a6c0f → 5a1ebf4f → 0bf3b6f7 → 2f7698b2 → 0dd1dfc8 - `kei-registry::kei-registry` — 3 versions: a9d4104f → 4110ba86 → 6e2dc3fd - `kei-router::kei-router` — 15 versions: 186634e6 → d91e8a11 → 80d4f8c6 → f8677f1d → a2e47f61 → 299a5afe → 675effa4 → 1fa6b4bb → 89c81c79 → 29340bbb → 51682c29 → ec0a1bfb → f4fce214 → 184e4f53 → 98ab93cd - `kei-token-tracker::kei-token-tracker` — 10 versions: 2e9d962a → 425b08f0 → 9a5196eb → 200eba01 → 2caec2d6 → 4538adbc → 0acb6793 → 1fa333e0 → dffb827c → 28bdb3b1 diff --git a/hooks/auto-dev-guard.sh b/hooks/auto-dev-guard.sh new file mode 100755 index 0000000..6f12565 --- /dev/null +++ b/hooks/auto-dev-guard.sh @@ -0,0 +1,100 @@ +#!/bin/sh +# auto-dev-guard.sh — PostToolUse:Edit|Write advisory hook. +# +# Triggers a frontend-validator pass after meaningful Edit/Write on +# frontend files (.tsx, .ts, .svelte, .vue, .dart) OR DB-layer files +# (migrations/*.sql, src/db/**, src/types/**, prisma/schema.prisma, +# drizzle.config.*). +# +# DOES NOT block. Emits stderr advisory pointing the user at /dev-guard +# OR auto-spawns a single-shot validator pass (advisory mode) if +# `kei-db-contract` binary is on PATH. +# +# Skip-on-trivial: if Edit's diff is < 30 LOC, skip. Avoid spawn-fatigue. +# +# Bypass: KEI_DISABLED_HOOKS contains "auto-dev-guard" OR +# KEI_HOOK_PROFILE in {advisory-off, minimal, off}. + +command -v jq >/dev/null 2>&1 || exit 0 + +_KEI_LIB="$(dirname "$0")/_lib/gate.sh" +if [ -r "$_KEI_LIB" ]; then + . "$_KEI_LIB" + kei_hook_gate "auto-dev-guard" || exit 0 +fi + +set -eu + +input="$(cat)" + +# --- Detect tool + file_path --- +tool=$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null || true) +file=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true) +[ -n "$file" ] || exit 0 + +# --- Match frontend / DB-layer patterns --- +match=0 +case "$file" in + *.tsx|*.ts|*.svelte|*.vue|*.dart) match=1 ;; + */migrations/*.sql) match=1 ;; + */src/db/*|*/src/types/*) match=1 ;; + */prisma/schema.prisma|*/drizzle.config.*) match=1 ;; +esac +[ "$match" = "1" ] || exit 0 + +# --- Trivial-edit gate: skip if change is small --- +# For Edit tool, count line delta. For Write, count total lines. +delta=0 +if [ "$tool" = "Edit" ]; then + old_str=$(printf '%s' "$input" | jq -r '.tool_input.old_string // empty' 2>/dev/null || true) + new_str=$(printf '%s' "$input" | jq -r '.tool_input.new_string // empty' 2>/dev/null || true) + old_lines=$(printf '%s' "$old_str" | wc -l 2>/dev/null | tr -d ' ') + new_lines=$(printf '%s' "$new_str" | wc -l 2>/dev/null | tr -d ' ') + delta=$((new_lines > old_lines ? new_lines - old_lines : old_lines - new_lines)) +elif [ "$tool" = "Write" ]; then + content=$(printf '%s' "$input" | jq -r '.tool_input.content // empty' 2>/dev/null || true) + delta=$(printf '%s' "$content" | wc -l 2>/dev/null | tr -d ' ') +fi +[ "$delta" -ge 30 ] || exit 0 + +# --- Resolve project root from file path --- +# Walk up from $file until we find package.json / pubspec.yaml / Cargo.toml. +project_root="" +dir=$(dirname "$file") +for _ in 1 2 3 4 5 6 7 8; do + if [ -f "$dir/package.json" ] || [ -f "$dir/pubspec.yaml" ]; then + project_root="$dir" + break + fi + parent=$(dirname "$dir") + [ "$parent" = "$dir" ] && break + dir="$parent" +done + +# --- Emit advisory + (optionally) run kei-db-contract for DB drift --- +echo "[auto-dev-guard] Frontend-relevant edit: $(basename "$file") ($delta LOC delta)" >&2 + +if [ -n "$project_root" ] && command -v kei-db-contract >/dev/null 2>&1; then + # Only run DB contract check when DB-layer files were edited + case "$file" in + */migrations/*.sql|*/src/db/*|*/src/types/*|*/prisma/schema.prisma|*/drizzle.config.*) + drift_json=$(kei-db-contract "$project_root" --output json 2>/dev/null || true) + if [ -n "$drift_json" ]; then + drift_count=$(printf '%s' "$drift_json" | jq -r '.drift_count // 0' 2>/dev/null || echo 0) + if [ "$drift_count" -gt 0 ]; then + echo "[auto-dev-guard] DB-contract drift: $drift_count table(s)." >&2 + echo "[auto-dev-guard] Run /dev-guard or 'kei-db-contract $project_root' for details." >&2 + fi + fi + ;; + esac +fi + +# Suggest /dev-guard for manual full pass +case "$file" in + *.tsx|*.ts|*.svelte|*.vue|*.dart) + echo "[auto-dev-guard] Tip: /dev-guard for full TS / lint / DB / visual pass." >&2 + ;; +esac + +exit 0 diff --git a/hooks/hooks.json b/hooks/hooks.json index 73c9e88..49020bd 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -12,6 +12,11 @@ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/site-wysiwyd-check.sh", "statusMessage": "site-wysiwyd drift check..." + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/auto-dev-guard.sh", + "statusMessage": "frontend dev-guard advisory..." } ] }, diff --git a/skills/dev-guard/SKILL.md b/skills/dev-guard/SKILL.md index d37dd4e..7c45d2d 100644 --- a/skills/dev-guard/SKILL.md +++ b/skills/dev-guard/SKILL.md @@ -1,12 +1,12 @@ --- name: dev-guard -description: Continuous parallel quality gate during development — 3 agents check every significant code change for security holes, performance traps, and pattern violations in real-time. Run after writing code, before committing. +description: Continuous parallel quality gate during development — 3 backend agents (security/perf/structure) plus optional 4th frontend-validator agent (TS types ↔ DB schema drift, lint, type-check, visual diff) check every significant code change in real-time. Run after writing code, before committing. --- # /dev-guard — Continuous Development Guard > wave-audit находит проблемы ПОСЛЕ. dev-guard ловит их ПОКА пишешь код. -> 3 агента параллельно проверяют каждое значимое изменение. +> 3 backend агента + опциональный frontend-validator проверяют каждое значимое изменение. ## Команды @@ -113,6 +113,43 @@ Constructor Pattern Checks: Если всё чисто → `STRUCTURE: CLEAN (N checks passed, all files <200 LOC)`. ``` +### Agent: `frontend-validator` (опциональный 4-й) + +> Запускается если в diff есть `*.tsx | *.ts | *.svelte | *.vue | *.dart` ИЛИ затронут DB-layer +> (`migrations/*.sql`, `src/db/**`, `src/types/**`, `prisma/schema.prisma`, `drizzle.config.*`). + +``` +Спавнить с subagent_type: "frontend-validator". Prompt: + +Frontend continuous-validation pass. Project root: [path], stack: [Next.js / Vite / Flutter / ...]. +Changed files: [список из git diff --name-only]. + +Run in order, emit per-section status: + +1. Stack detect (package.json / pubspec.yaml / next.config.*). +2. Type-check (npx tsc --noEmit OR dart analyze). List file:line errors. +3. Lint (npx eslint . --ext .ts,.tsx OR dart analyze). List warnings. +4. DB-contract drift — invoke `kei-db-contract --output json` if binary in PATH AND project + has migrations/ OR src/db/. Parse drift_count + per-table fields. +5. Visual regression — only if playwright.config.* exists. Skip otherwise. +6. Verdict block: each check status (PASS / WARN / FAIL) + brief evidence. + +Output format: +- TYPE_CHECK: [ errors] +- LINT: [ warnings] +- DB_CONTRACT: [ tables drifted] +- VISUAL_DIFF: +- VERDICT: COMMIT_OK / FIX_FIRST / REVIEW_NEEDED + +Read-only on the codebase; do NOT autofix. Hand off TS errors to code-implementer-typescript. +``` + +Severity rules: +- **TYPE_CHECK FAIL** → block commit (hard). +- **DB_CONTRACT drift_count > 0** → block commit, suggest schema-or-types update. +- **LINT warnings** → advisory. +- **VISUAL_DIFF mismatch** → review needed (user approves new baseline OR fixes regression). + --- ## Phase 2 — Quick Synthesis (lead, НЕ агент) @@ -154,6 +191,8 @@ Verdict: COMMIT / FIX FIRST / REVIEW NEEDED | services/utils/config | performance + structure | ~1 min | | UI/styles/docs | structure only | ~30 sec | | 1 файл, <20 строк | lead проверяет сам (без агентов) | ~10 sec | +| frontend (.tsx/.ts/.dart) | + frontend-validator | +30-60 sec | +| DB layer (migrations/, src/db/, schema.prisma) | + frontend-validator (DB contract) | +20 sec | ---