feat: bring simple-mode insert arity diagnostics to parity with advanced

A wrong-count simple-mode insert now shows the friendly per-column arity
message at typing time (instead of a bare "expected `,`/`)`") and is
blocked from dispatch at submit — unifying simple and advanced mode onto
the one ADR-0027 model (structural parse + ERROR diagnostic), where they
had diverged.

Grammar: a simple-mode-only arity gate (dsl_insert_value_list) routes a
wrong-count DSL insert tuple to the type-blind fallback so it matches
structurally and the per-tuple arity diagnostic fires. The gate is gated
to simple mode, so advanced behaviour is unchanged. count_tuple_values
and the target-column selection (insert_target_columns) are now shared
by both grammars.

Diagnostic: dml_insert_arity_diagnostics is mode-aware — advanced Form B
expects all columns; simple Form B/C expects the user-fillable columns
(serial/shortid auto-fill). It counts the DSL Form A role and scans the
keyword-less Form C tuple. New catalog keys name the fillable/auto split
and the all-auto-table case.

Submit: a wrong-count DSL insert now parses Ok + carries the ERROR
diagnostic, so a unified Ok-arm pre-flight (dsl_insert_count_mismatch_notes)
blocks dispatch and teaches; the previous Err-arm note retires.
advanced_alternative_note's gate now reads the validity verdict so it
still fires for the parse-Ok-with-error shape.

Docs: ADR-0036 Amendment 2 (+ README index) and requirements.md H1a.
This commit is contained in:
claude@clouddev1
2026-05-29 20:45:21 +00:00
parent 7cccf4eabb
commit 10e5197c19
16 changed files with 812 additions and 240 deletions
+90 -4
View File
@@ -323,10 +323,20 @@ pub fn advanced_alternative_note(
input: &str,
cache: &crate::completion::SchemaCache,
) -> Option<String> {
let definite_dsl_error = matches!(
classify_input_with_schema_in_mode(input, cache, Mode::Simple),
InputState::DefiniteErrorAt(_)
);
// The line must be *definitely* invalid in simple mode — a definite
// parse error, or (issue #17) a parse that succeeds structurally but
// carries a blocking ERROR diagnostic such as a value-count
// mismatch. Incomplete input (still being typed) and empty input are
// excluded so the pointer doesn't flicker mid-keystroke.
let definite_dsl_error = match classify_input_with_schema_in_mode(input, cache, Mode::Simple)
{
InputState::DefiniteErrorAt(_) => true,
InputState::Valid => {
crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Simple)
== Some(crate::dsl::walker::outcome::Severity::Error)
}
InputState::Empty | InputState::IncompleteAtEof => false,
};
if !definite_dsl_error {
return None;
}
@@ -363,6 +373,82 @@ pub fn advanced_alternative_note(
/// without adding pedagogy. The cross-mode pointer wins because it
/// only fires when switching modes actually works (issue #1 sub-task
/// 1's gate); when it doesn't fire, this note steps in.
/// Submit-time pre-flight for a simple-mode (DSL) `Command::Insert`
/// whose positional value count doesn't match the expected count
/// (issue #17). Returns the advice line(s) to display when there is a
/// mismatch — the caller (`dispatch_dsl`) blocks dispatch whenever this
/// is `Some`, so a wrong-count insert never reaches the worker. `None`
/// when the command isn't an insert, the table is unknown, or the count
/// already matches.
///
/// This is the simple-mode counterpart of the advanced Ok-arm pre-flight
/// (`form_b_positional_count_mismatch_note`). Both modes now parse a
/// wrong-count insert as `Ok` (so the typing-time arity diagnostic can
/// fire — issue #17), so dispatch is gated here, uniformly, rather than
/// by a parse error.
///
/// Expected count: Form A (explicit `(col, …)`) → the listed count;
/// Form B/C (no list) → the user-fillable (non-auto-generated) count,
/// since the dispatch auto-fills serial/shortid (ADR-0018 §3).
///
/// Advice selection mirrors the previous Err-arm logic: the cross-mode
/// pointer wins when the same text is valid in advanced mode; otherwise
/// Form B/C shows the rich teaching note (names the fillable + auto
/// columns and the Form-A override) and Form A shows the column-list
/// arity message.
#[must_use]
pub fn dsl_insert_count_mismatch_notes(
input: &str,
cmd: &crate::dsl::command::Command,
cache: &crate::completion::SchemaCache,
) -> Option<Vec<String>> {
use crate::dsl::command::Command;
use crate::dsl::types::Type;
let Command::Insert {
table,
columns,
values,
} = cmd
else {
return None;
};
let table_cols = cache.table_columns.get(table)?;
let is_auto = |t: Type| matches!(t, Type::Serial | Type::ShortId);
let expected = columns.as_ref().map_or_else(
|| table_cols.iter().filter(|c| !is_auto(c.user_type)).count(),
Vec::len,
);
if values.len() == expected {
return None; // counts match — nothing to flag, dispatch proceeds
}
// Count mismatch → the caller blocks dispatch. Build the advice.
// The cross-mode pointer is the single most useful line when the
// same text is valid in advanced mode, so it suppresses the rest.
if let Some(pointer) = advanced_alternative_note(input, cache) {
return Some(vec![pointer]);
}
let note = if columns.is_some() {
// Form A: the column-list arity message.
crate::t!(
"diagnostic.insert_arity_mismatch",
expected = expected,
actual = values.len()
)
} else {
// Form B/C: the rich teaching note. Falls back to the all-auto
// explanation for a table whose columns are all auto-generated
// (the override note doesn't apply there).
form_b_extra_values_note(input, cache).unwrap_or_else(|| {
crate::t!(
"diagnostic.insert_arity_mismatch_all_auto",
table = table,
actual = values.len()
)
})
};
Some(vec![note])
}
#[must_use]
pub fn form_b_extra_values_note(
input: &str,