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>
158 lines
4.3 KiB
Rust
158 lines
4.3 KiB
Rust
//! 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()
|
|
}
|