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>
103 lines
4 KiB
Rust
103 lines
4 KiB
Rust
//! 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);
|
|
}
|