feat: ADR-0035 Amendment 1 follow-up — enrich replay errors + close message gaps

- F2-broad: replay failures now render with real schema context instead of
  a contextless friendly_message(). Extract App::build_translate_context into
  the shared App::translate_context_for(command, facts, verbosity); run_replay
  enriches via enrich_dsl_failure + that builder. ctx_* fallbacks degrade to
  neutral prose so the rare non-replay contextless callsites can't leak raw
  {name} either. (SQL INSERT/UPDATE values aren't retained — ADR-0033 verbatim
  — so those show real table/column + neutral "that value".)
- Gap C: SQL ALTER … ADD FOREIGN KEY on a missing child column refuses with an
  SQL-appropriate "add it first", not the DSL-only --create-fk flag.
- Gap B: dropping a single-column-UNIQUE column refuses with a pointer to
  `drop constraint unique from T.col` (was an opaque generic refusal).
- Gap D: 4e drop/rename CHECK-guard + 4f change-type FK-guard refusals reworded
  to explain why; static_refusal reasons left as-is.

Tests: +4, 3 strengthened. 1926 pass / 0 fail / 0 skip; clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-26 18:30:31 +00:00
parent cb8ff8a7c2
commit f8a91f41c9
8 changed files with 328 additions and 41 deletions
+36
View File
@@ -317,6 +317,42 @@ fn replay_only_comments_completes_with_zero_commands() {
assert_completed(&events, 0);
}
#[test]
fn replay_constraint_failure_shows_real_names_not_placeholders() {
// F2 follow-up (ADR-0035 Amendment 1): a replayed command that hits a
// UNIQUE violation renders with the REAL table/column/value (enriched
// like the interactive path) — never a literal `{table}` / `{column}`
// / `{value}` placeholder. Before the fix, replay rendered via a
// contextless `friendly_message()` and leaked the markers.
let data = tempdir();
let (project, db) = open_project_db(data.path());
write_script(
project.path(),
"dup.commands",
"create table T with pk id(int)\n\
add column T: email (text)\n\
add constraint unique to T.email\n\
insert into T (id, email) values (1, 'a@b.com')\n\
insert into T (id, email) values (2, 'a@b.com')\n",
);
let events = rt().block_on(async { run_replay(&db, project.path(), "dup.commands").await });
let failed = assert_failed_at(&events, 5);
let AppEvent::ReplayFailed { error, .. } = failed else {
unreachable!()
};
// No unsubstituted placeholders (the safety net + enrichment).
assert!(
!error.contains("{table}") && !error.contains("{column}") && !error.contains("{value}"),
"no unsubstituted placeholders; got: {error}"
);
// The real table + column are shown (resolved from the engine
// message). The offending value is NOT shown: replay parses in
// advanced mode → `SqlInsert`, whose values are raw SQL text (ADR-0033
// verbatim execution), not retained typed values — so it degrades to
// the neutral "that value" rather than leaking `{value}`.
assert!(error.contains("T.email"), "names the real table.column; got: {error}");
}
#[test]
fn replay_missing_file_fails_with_line_number_zero() {
let data = tempdir();