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
+34 -13
View File
@@ -707,36 +707,38 @@ fn verbose_hint(ctx: &TranslateContext, hint: String) -> Option<String> {
}
}
// Fallback markers when context can't supply a value. We use
// the catalog's `{name}` form so unfilled positions read as
// "this placeholder was not supplied" — same shape the
// translator's source uses, easier to grep, and visually
// consistent with the catalog templates. With runtime-side
// enrichment (ADR-0019 §6) populating `FailureContext`,
// these fallbacks rarely render in practice.
// Neutral-prose fallbacks when context can't supply a value
// (ADR-0035 Amendment 1, F2 follow-up — the safety net). Runtime-side
// enrichment (ADR-0019 §6) fills `FailureContext` on the interactive and
// replay paths, so these rarely render; but the few contextless
// `friendly_message()` callsites (undo / rebuild / export) must NOT
// surface a raw `{name}` placeholder, which reads like a bug. The earlier
// `{name}`-marker form was a developer-facing tell that predated those
// callsites rendering in practice; neutral prose degrades gracefully
// instead.
fn ctx_table(ctx: &TranslateContext) -> String {
ctx.table.clone().unwrap_or_else(|| "{table}".to_string())
ctx.table.clone().unwrap_or_else(|| "the table".to_string())
}
fn ctx_column(ctx: &TranslateContext) -> String {
ctx.column.clone().unwrap_or_else(|| "{column}".to_string())
ctx.column.clone().unwrap_or_else(|| "the column".to_string())
}
fn ctx_value(ctx: &TranslateContext) -> String {
ctx.value.clone().unwrap_or_else(|| "{value}".to_string())
ctx.value.clone().unwrap_or_else(|| "that value".to_string())
}
fn ctx_parent_table(ctx: &TranslateContext) -> String {
ctx.parent_table.clone().unwrap_or_else(|| "{parent_table}".to_string())
ctx.parent_table.clone().unwrap_or_else(|| "the referenced table".to_string())
}
fn ctx_parent_column(ctx: &TranslateContext) -> String {
ctx.parent_column.clone().unwrap_or_else(|| "{parent_column}".to_string())
ctx.parent_column.clone().unwrap_or_else(|| "the referenced column".to_string())
}
fn ctx_child_table(ctx: &TranslateContext) -> String {
ctx.child_table.clone().unwrap_or_else(|| "{child_table}".to_string())
ctx.child_table.clone().unwrap_or_else(|| "the referencing table".to_string())
}
/// Extract `T.col` from a message like
@@ -1063,6 +1065,25 @@ mod tests {
);
}
#[test]
fn constraint_templates_degrade_to_prose_without_context() {
// F2 follow-up safety net: a constraint error rendered via a
// contextless `friendly_message()` (no facts) degrades to neutral
// prose, never a raw `{name}` marker.
for kind in [
SqliteErrorKind::UniqueViolation,
SqliteErrorKind::Other,
SqliteErrorKind::NoSuchColumn,
] {
let err = sqlite("constraint failed", kind);
let rendered = translate(&err, &TranslateContext::default()).render();
assert!(
!rendered.contains('{'),
"placeholder marker leaked for {kind:?}:\n{rendered}"
);
}
}
// ---- passthrough variants ----
#[test]