KeiSeiKit-1.0/_primitives/_rust/kei-db-contract/src/sql_parse.rs
Parfii-bot f3f5f79760 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>
2026-05-01 15:34:39 +08:00

103 lines
3.1 KiB
Rust

//! 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()
}