From b8102dc063430ab8a22ec37acc82c294fb58bf28 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 8 May 2026 14:57:12 +0000 Subject: [PATCH] tests: ADR-0002 engine-vocabulary audit (A2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies the user-facing posture in ADR-0002 §"User-facing posture": no occurrence of SQLite, sqlite, rusqlite, STRICT, or PRAGMA may appear in any user-reachable string. The audit's mechanical sweep of `src/` confirmed the codebase already conforms — every appearance of those tokens is in either: - code comments / module-level docstrings (allowed by ADR-0002 explicitly), - DDL strings sent to the engine (not displayed to the user), - internal field/function names like `sqlite_type` / `sqlite_strict_type` (code identifiers, not user-visible). The previous session removed the last known leak in `do_add_column`. To stop a future change from quietly re-introducing one, this commit adds a regression test file covering a representative set of user surfaces: - `cli::HELP_TEXT` (`--help` banner). - The in-app `help` command output. - DSL parse errors for a battery of failing inputs (column-name-first typo, unknown type token, mutually exclusive flags, missing clause, garbage). - `DbError::friendly_message()` for realistic Sqlite, Unsupported, and InvalidValue payloads — the surface the runtime forwards via `AppEvent::DslFailed`. The forbidden-token list lives in one place (`engine_vocabulary_audit.rs::FORBIDDEN`) so future audits can extend it. Failure messages name the leaking token and its byte offset so a regressing edit pinpoints itself. Out of scope (and called out in the handoff for separate work): the H1 friendly-error layer that translates the remaining engine error wording into pedagogical English. That needs its own ADR. The local `friendly_change_column_engine_error` wrapper (db.rs §2354) is the prototype. 537 -> 541 passing (4 new), clippy clean. --- tests/engine_vocabulary_audit.rs | 182 +++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 tests/engine_vocabulary_audit.rs diff --git a/tests/engine_vocabulary_audit.rs b/tests/engine_vocabulary_audit.rs new file mode 100644 index 0000000..6758ceb --- /dev/null +++ b/tests/engine_vocabulary_audit.rs @@ -0,0 +1,182 @@ +//! ADR-0002 user-facing posture: regression audit. +//! +//! ADR-0002's "User-facing posture" section commits to never +//! exposing the underlying engine's name in user-visible +//! strings. The chosen product (and its idioms — STRICT, +//! PRAGMA, the rusqlite crate) is an implementation detail; +//! students should leave with knowledge of relational concepts, +//! not of one specific RDBMS. +//! +//! This test file exists so that a future change can't silently +//! regress that posture. The strings asserted here are a +//! representative cross-section of user-reachable surfaces: +//! +//! - CLI usage banner (`HELP_TEXT`). +//! - In-app `help` output (`note_help`). +//! - DSL parse-error wording. +//! - Realistic `DbError` payloads carried via +//! `friendly_message()` (the surface the runtime forwards to +//! `AppEvent::DslFailed`). +//! +//! See ADR-0002 §"User-facing posture" for the contract. +//! Code comments and ADR prose are explicitly allowed to name +//! the engine — only user-facing strings are policed. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + +use rdbms_playground::app::App; +use rdbms_playground::cli::HELP_TEXT; +use rdbms_playground::db::{DbError, SqliteErrorKind}; +use rdbms_playground::dsl::parse_command; +use rdbms_playground::event::AppEvent; + +const FORBIDDEN: &[&str] = &[ + // Product names. + "SQLite", "sqlite", + // Crate name. + "rusqlite", + // Engine-specific keywords / idioms. + "STRICT", "PRAGMA", +]; + +/// Report the first forbidden token found in `s`, with byte +/// offset, so failure output points at exactly what leaked. +fn engine_vocab_leak(s: &str) -> Option<(&'static str, usize)> { + for needle in FORBIDDEN { + if let Some(pos) = s.find(needle) { + return Some((needle, pos)); + } + } + None +} + +fn assert_clean(label: &str, s: &str) { + if let Some((needle, pos)) = engine_vocab_leak(s) { + panic!( + "ADR-0002 leak in {label}: found `{needle}` at byte {pos} in:\n{s}" + ); + } +} + +const fn key(code: KeyCode) -> AppEvent { + AppEvent::Key(KeyEvent { + code, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: crossterm::event::KeyEventState::NONE, + }) +} + +fn type_str(app: &mut App, s: &str) { + for c in s.chars() { + app.update(key(KeyCode::Char(c))); + } +} + +fn submit(app: &mut App) { + app.update(key(KeyCode::Enter)); +} + +fn collect_output(app: &App) -> String { + app.output + .iter() + .map(|l| l.text.as_str()) + .collect::>() + .join("\n") +} + +#[test] +fn cli_help_text_uses_no_engine_vocabulary() { + assert_clean("CLI HELP_TEXT", HELP_TEXT); +} + +#[test] +fn in_app_help_uses_no_engine_vocabulary() { + let mut app = App::new(); + type_str(&mut app, "help"); + submit(&mut app); + assert_clean("in-app help", &collect_output(&app)); +} + +#[test] +fn parse_errors_use_no_engine_vocabulary() { + // A representative set of failing inputs: structural + // (missing colon, wrong keyword), unknown type, and the + // change-column flag conflict. All must produce + // engine-free messages. + let inputs: &[&str] = &[ + // structural: column-name-first typo (the parser + // tiny-win recipe from handoff-5). + "change column Tag in Customers: Tag (text)", + // unknown type token. + "create table T with pk id:varchar", + // mutually exclusive flags on change column. + "change column T: c (int) --force-conversion --dont-convert", + // missing required clause. + "create table T", + // garbage. + "this is not a command", + ]; + for input in inputs { + let err = parse_command(input) + .expect_err(&format!("expected parse failure for `{input}`")); + let rendered = format!("{err:?}"); + assert_clean(&format!("parse error for `{input}`"), &rendered); + } +} + +#[test] +fn db_error_friendly_message_uses_no_engine_vocabulary() { + // A representative set of `DbError` payloads, mirroring the + // shapes the runtime actually surfaces via + // `AppEvent::DslFailed { error: DbError::friendly_message }`. + // These cover the three code-constructed variants: Sqlite + // (engine-classified, message comes from rusqlite or our own + // hand-rolled "no such ..."), Unsupported (refusals), and + // InvalidValue (input validation). + let cases: Vec<(&str, DbError)> = vec![ + ( + "no-such-table", + DbError::Sqlite { + message: "no such table: Customers".to_string(), + kind: SqliteErrorKind::NoSuchTable, + }, + ), + ( + "no-such-column", + DbError::Sqlite { + message: "no such column: Customers.zip".to_string(), + kind: SqliteErrorKind::NoSuchColumn, + }, + ), + ( + "unique-violation", + DbError::Sqlite { + message: "UNIQUE constraint failed: T.id".to_string(), + kind: SqliteErrorKind::UniqueViolation, + }, + ), + ( + "fk-violation", + DbError::Sqlite { + message: "FOREIGN KEY constraint failed".to_string(), + kind: SqliteErrorKind::Other, + }, + ), + ( + "unsupported-refusal", + DbError::Unsupported( + "cannot drop primary-key column `T.id`. \ + Drop the table or change the primary key first." + .to_string(), + ), + ), + ( + "invalid-value", + DbError::InvalidValue("expected 3 value(s), got 2".to_string()), + ), + ]; + for (label, err) in cases { + assert_clean(label, &err.friendly_message()); + } +}