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