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:
Parfii-bot 2026-05-01 15:34:39 +08:00
parent 902fb3e81a
commit f3f5f79760
20 changed files with 1169 additions and 10 deletions

View 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"

View file

@ -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"

View file

@ -175,6 +175,8 @@ members = [
"kei-cortex",
"kei-tty",
"kei-mcp",
# SQL ↔ TypeScript schema drift detector
"kei-db-contract",
]
[workspace.package]

View 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"

View 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()
}

View 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};

View 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)
}

View 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()
}

View 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",
}
}

View 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,
});
}
}
}

View 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()
}

View 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
}

View 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
}
}

View 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);
}

View 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
);

View 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;
}

View file

@ -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
View 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

View file

@ -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..."
}
]
},

View file

@ -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 |
---