feat(frontend-loop): kei-db-contract primitive + frontend-validator agent + auto-dev-guard hook
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 <project-root> [--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) <noreply@anthropic.com>
This commit is contained in:
parent
eedffd1cd2
commit
54c298036e
20 changed files with 1169 additions and 10 deletions
72
_manifests/frontend-validator.toml
Normal file
72
_manifests/frontend-validator.toml
Normal file
|
|
@ -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 <project-root> --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"
|
||||
23
_primitives/_rust/Cargo.lock
generated
23
_primitives/_rust/Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -175,6 +175,8 @@ members = [
|
|||
"kei-cortex",
|
||||
"kei-tty",
|
||||
"kei-mcp",
|
||||
# SQL ↔ TypeScript schema drift detector
|
||||
"kei-db-contract",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
|
|
|||
28
_primitives/_rust/kei-db-contract/Cargo.toml
Normal file
28
_primitives/_rust/kei-db-contract/Cargo.toml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
[package]
|
||||
name = "kei-db-contract"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
authors = ["Denis Parfionovich <info@greendragon.info>"]
|
||||
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"
|
||||
158
_primitives/_rust/kei-db-contract/src/diff.rs
Normal file
158
_primitives/_rust/kei-db-contract/src/diff.rs
Normal file
|
|
@ -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<String>,
|
||||
pub ts_type: Option<String>,
|
||||
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<FieldReport>,
|
||||
}
|
||||
|
||||
/// Top-level report shape.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DriftReport {
|
||||
pub drift_count: usize,
|
||||
pub tables: Vec<TableReport>,
|
||||
}
|
||||
|
||||
/// 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<TableReport> = 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<FieldReport> = 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::<String>()
|
||||
.to_ascii_lowercase()
|
||||
}
|
||||
16
_primitives/_rust/kei-db-contract/src/lib.rs
Normal file
16
_primitives/_rust/kei-db-contract/src/lib.rs
Normal file
|
|
@ -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};
|
||||
68
_primitives/_rust/kei-db-contract/src/main.rs
Normal file
68
_primitives/_rust/kei-db-contract/src/main.rs
Normal file
|
|
@ -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<ExitCode> {
|
||||
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)
|
||||
}
|
||||
86
_primitives/_rust/kei-db-contract/src/matching.rs
Normal file
86
_primitives/_rust/kei-db-contract/src/matching.rs
Normal file
|
|
@ -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<Pair<'a>> {
|
||||
let mut consumed_ts: Vec<bool> = vec![false; ts_types.len()];
|
||||
let mut pairs: Vec<Pair<'a>> = 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()
|
||||
}
|
||||
56
_primitives/_rust/kei-db-contract/src/output.rs
Normal file
56
_primitives/_rust/kei-db-contract/src/output.rs
Normal file
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
70
_primitives/_rust/kei-db-contract/src/report_builders.rs
Normal file
70
_primitives/_rust/kei-db-contract/src/report_builders.rs
Normal file
|
|
@ -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<FieldReport>,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
103
_primitives/_rust/kei-db-contract/src/sql_parse.rs
Normal file
103
_primitives/_rust/kei-db-contract/src/sql_parse.rs
Normal file
|
|
@ -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<SqlColumn>,
|
||||
pub source_file: String,
|
||||
}
|
||||
|
||||
/// Walk `dir` recursively for `.sql` files, return parsed tables.
|
||||
pub fn parse_migrations_dir(dir: &Path) -> Result<Vec<SqlTable>> {
|
||||
let mut tables: Vec<SqlTable> = 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<SqlTable>, 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<SqlTable> {
|
||||
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()
|
||||
}
|
||||
126
_primitives/_rust/kei-db-contract/src/ts_parse.rs
Normal file
126
_primitives/_rust/kei-db-contract/src/ts_parse.rs
Normal file
|
|
@ -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<TsField>,
|
||||
pub source_file: String,
|
||||
}
|
||||
|
||||
/// Walk a directory recursively, parse every `.ts` and `.tsx` file.
|
||||
pub fn parse_ts_glob(roots: &[&Path]) -> Result<Vec<TsType>> {
|
||||
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<TsType> {
|
||||
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::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn capture_balanced_braces(text: &str, start: usize) -> Option<String> {
|
||||
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<TsField> {
|
||||
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
|
||||
}
|
||||
73
_primitives/_rust/kei-db-contract/src/types_map.rs
Normal file
73
_primitives/_rust/kei-db-contract/src/types_map.rs
Normal file
|
|
@ -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::<Vec<&str>>()
|
||||
.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
|
||||
}
|
||||
}
|
||||
103
_primitives/_rust/kei-db-contract/tests/contract_diff_basic.rs
Normal file
103
_primitives/_rust/kei-db-contract/tests/contract_diff_basic.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
12
_primitives/_rust/kei-db-contract/tests/fixtures/sample-project/migrations/0001_users.sql
vendored
Normal file
12
_primitives/_rust/kei-db-contract/tests/fixtures/sample-project/migrations/0001_users.sql
vendored
Normal file
|
|
@ -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
|
||||
);
|
||||
15
_primitives/_rust/kei-db-contract/tests/fixtures/sample-project/src/types.ts
vendored
Normal file
15
_primitives/_rust/kei-db-contract/tests/fixtures/sample-project/src/types.ts
vendored
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
100
hooks/auto-dev-guard.sh
Executable file
100
hooks/auto-dev-guard.sh
Executable file
|
|
@ -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
|
||||
|
|
@ -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..."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 <root> --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: <status> [<count> errors]
|
||||
- LINT: <status> [<count> warnings]
|
||||
- DB_CONTRACT: <status> [<drift_count> tables drifted]
|
||||
- VISUAL_DIFF: <status>
|
||||
- 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 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue