Skip to main content

razeri_plugin_api/
conformance.rs

1// Copyright (C) 2026 Gabriel Shea
2// Licensed under either of the Apache License, Version 2.0 or the MIT
3// license, at your option.
4
5//! **Every obligation a dialect owes, reported at once.**
6//!
7//! Two people built dialects against Razeri's authoring guide without help.
8//! Between them they met twenty-seven obligations one `cargo test` at a time —
9//! a roster fails, you add a row, the next roster fails. Not one of those
10//! failures was discoverable before it fired. This module is the report that
11//! answers all of them in one pass.
12//!
13//! # Running it
14//!
15//! From your own crate's tests, where your dialect is linked and Razeri's
16//! registry can see it:
17//!
18//! ```no_run
19//! # use razeri_plugin_api::conformance;
20//! # fn example(my_dialect: &dyn razeri_plugin_api::PlatformDialect) {
21//! let report = conformance::check(my_dialect);
22//! assert!(report.is_clean(), "{report}");
23//! # }
24//! ```
25//!
26//! Inside Razeri's own repository, `razeri plugin conform <name>` prints the
27//! same report for any registered platform, followed by the repository-side
28//! obligations a command cannot run for you.
29//!
30//! # What it can and cannot see
31//!
32//! Everything here is read off the dialect object, so it works wherever your
33//! plugin compiles. What it cannot do is run Razeri's frozen matrices or
34//! regenerate its language bindings — those are commands, and
35//! [`repository_obligations`] lists them in the order they are owed.
36
37use crate::PlatformDialect;
38use razeri_plugin_contract::{
39    ConstraintEnforcement, DataType, DdlRenderer, DialectCapabilities, DialectFamily, MergeShape,
40    ProcedureForm, SqlRenderer, UNKNOWN_PLATFORM,
41};
42use std::fmt;
43
44/// How much a finding matters.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
46pub enum Severity {
47    /// **This dialect will produce SQL its engine rejects.** Fix it before
48    /// anything else.
49    Blocking,
50    /// **A question this dialect has not answered.** Razeri has a default and
51    /// will use it; the default was written for a different engine.
52    Unanswered,
53    /// Worth a look. Nothing is broken.
54    Note,
55}
56
57impl fmt::Display for Severity {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        f.write_str(match self {
60            Self::Blocking => "must fix",
61            Self::Unanswered => "unanswered",
62            Self::Note => "worth a look",
63        })
64    }
65}
66
67/// One thing to fix, in the order a reader wants it: what is wrong, then what
68/// to do about it.
69#[derive(Debug, Clone)]
70pub struct Finding {
71    /// How much it matters.
72    pub severity: Severity,
73    /// One line naming what is wrong.
74    pub what: String,
75    /// One line naming what to do.
76    pub fix: String,
77}
78
79/// A command a plugin author runs inside Razeri's repository, and why.
80#[derive(Debug, Clone, Copy)]
81pub struct Obligation {
82    /// What this step is for.
83    pub what: &'static str,
84    /// The command, exactly as typed.
85    pub command: &'static str,
86}
87
88/// The whole report for one dialect.
89#[derive(Debug, Clone)]
90pub struct Report {
91    /// The dialect's registry name.
92    pub dialect: String,
93    /// Everything found, worst first.
94    pub findings: Vec<Finding>,
95}
96
97impl Report {
98    /// Whether nothing blocking or unanswered was found.
99    ///
100    /// Notes do not fail a report: they are observations, not obligations.
101    #[must_use]
102    pub fn is_clean(&self) -> bool {
103        !self.findings.iter().any(|f| f.severity != Severity::Note)
104    }
105
106    /// How many findings of one severity.
107    #[must_use]
108    pub fn count(&self, severity: Severity) -> usize {
109        self.findings.iter().filter(|f| f.severity == severity).count()
110    }
111}
112
113impl fmt::Display for Report {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        let name = &self.dialect;
116        if self.findings.is_empty() {
117            return writeln!(
118                f,
119                "{name}: nothing to fix. Every question Razeri asks a dialect, this one answers."
120            );
121        }
122        let blocking = self.count(Severity::Blocking);
123        let unanswered = self.count(Severity::Unanswered);
124        let notes = self.count(Severity::Note);
125        writeln!(f, "{name}: {blocking} to fix, {unanswered} unanswered, {notes} worth a look.")?;
126        for finding in &self.findings {
127            writeln!(f)?;
128            writeln!(f, "  [{}] {}", finding.severity, finding.what)?;
129            writeln!(f, "      → {}", finding.fix)?;
130        }
131        Ok(())
132    }
133}
134
135/// **The report.** Everything about this dialect that Razeri can check by
136/// asking it.
137#[must_use]
138pub fn check(dialect: &dyn PlatformDialect) -> Report {
139    let info = dialect.info();
140    let mut findings = Vec::new();
141
142    check_registration(info.name, &mut findings);
143    check_family(info.dialect_family, &mut findings);
144    check_required_rendering(dialect, &mut findings);
145    check_decisions(dialect, &mut findings);
146    check_formula_renderer(dialect, &mut findings);
147    check_paired_facts(dialect, &mut findings);
148
149    findings.sort_by_key(|f| f.severity);
150    Report { dialect: info.name.to_owned(), findings }
151}
152
153/// The name has to resolve, and it has to resolve to exactly one platform.
154fn check_registration(name: &str, findings: &mut Vec<Finding>) {
155    if crate::resolve_dialect_by_name(name).is_err() {
156        findings.push(Finding {
157            severity: Severity::Blocking,
158            what: format!(
159                "the name `{name}` does not resolve — nothing in this program registered it"
160            ),
161            fix: "add `inventory::submit! { DialectRegistration::new(\"<name>\", || \
162                  Arc::new(MyDialect)) }` to your crate, and a `pub fn force_link() {}` beside \
163                  it that the program using your dialect calls once. A crate nothing links is a \
164                  crate the registry cannot see."
165                .to_owned(),
166        });
167        return;
168    }
169    if !crate::resolve::alias_spellings()
170        .iter()
171        .any(|(spelling, canonical)| *spelling == name && *canonical == name)
172    {
173        findings.push(Finding {
174            severity: Severity::Blocking,
175            what: format!(
176                "`{name}` is registered but the resolver's spelling table does not carry it, so \
177                 a design naming this platform is refused at the door"
178            ),
179            fix: "add your platform's accepted spellings to the resolver's alias table, including its \
180                  own \
181                  name, and add the same spellings to `schemas/razeri-design.schema.json`'s \
182                  platform list — a test holds the two set-equal."
183                .to_owned(),
184        });
185    }
186}
187
188/// A family is a claim about spelling, so an unmeasured engine claiming a
189/// measured family's name licenses SQL it may reject.
190fn check_family(family: DialectFamily, findings: &mut Vec<Finding>) {
191    if matches!(family, DialectFamily::Custom(name) if name.trim().is_empty()) {
192        findings.push(Finding {
193            severity: Severity::Blocking,
194            what: "this dialect's family is a custom one with no name".to_owned(),
195            fix: "give `DialectFamily::Custom` your engine's own name — it is what a report \
196                  prints and what a reader looks up."
197                .to_owned(),
198        });
199    }
200}
201
202/// The four required methods must answer for every type a design can declare.
203fn check_required_rendering(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
204    let declarable = declarable_types();
205    let empty_column: Vec<String> = declarable
206        .iter()
207        .filter(|dt| SqlRenderer::render_data_type(dialect, dt).trim().is_empty())
208        .map(|dt| format!("{dt:?}"))
209        .collect();
210    if !empty_column.is_empty() {
211        findings.push(Finding {
212            severity: Severity::Blocking,
213            what: format!(
214                "`render_data_type` answers nothing for {} of the types a design can declare",
215                empty_column.len()
216            ),
217            fix: "every declarable type needs a column spelling on this engine, even if the \
218                  spelling is a wider type that stores it. A blank answer becomes a column \
219                  declaration with no type in it."
220                .to_owned(),
221        });
222    }
223    let empty_cast: Vec<String> = declarable
224        .iter()
225        .filter(|dt| SqlRenderer::render_cast_type(dialect, dt).trim().is_empty())
226        .map(|dt| format!("{dt:?}"))
227        .collect();
228    if !empty_cast.is_empty() {
229        findings.push(Finding {
230            severity: Severity::Blocking,
231            what: format!(
232                "`render_cast_type` answers nothing for {} declarable types",
233                empty_cast.len()
234            ),
235            fix: "the generated load opens by casting every source column to its declared type, \
236                  so a blank answer fails the load on its first statement."
237                .to_owned(),
238        });
239    }
240    let quoted = SqlRenderer::quote_identifier(dialect, "order");
241    if quoted == "order" {
242        findings.push(Finding {
243            severity: Severity::Blocking,
244            what: "`quote_identifier` returns the identifier unchanged, so a reserved word \
245                   reaches this engine bare"
246                .to_owned(),
247            fix: "wrap the identifier in whatever this engine quotes with — double quotes, \
248                  backticks, or brackets."
249                .to_owned(),
250        });
251    }
252    if SqlRenderer::render_current_timestamp(dialect).trim().is_empty() {
253        findings.push(Finding {
254            severity: Severity::Blocking,
255            what: "`render_current_timestamp` answers nothing, and every generated load stamps \
256                   a row with it"
257                .to_owned(),
258            fix: "return this engine's own expression for the current moment.".to_owned(),
259        });
260    }
261}
262
263/// The decisions a dialect states rather than inherits.
264fn check_decisions(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
265    let spelling = SqlRenderer::sid_spelling(dialect);
266    if spelling.platform == UNKNOWN_PLATFORM {
267        findings.push(Finding {
268            severity: Severity::Blocking,
269            what: "this dialect states no surrogate-key spelling, so every key it renders calls \
270                   an `xxhash64` its engine very likely does not have"
271                .to_owned(),
272            fix: "override `SqlRenderer::sid_spelling`. Say what this engine calls LOWER and \
273                  TRIM, how it joins strings, how it casts to text, and how it spells a 64-bit \
274                  hash. If it has no portable hash, name an artifact your prerequisites install \
275                  — never the nearest-looking built-in, which keys a warehouse to a number \
276                  nothing else can reproduce."
277                .to_owned(),
278        });
279    }
280
281    match SqlRenderer::procedure_form(dialect) {
282        // Razeri's own agnostic renderer answers `Agnostic` on purpose: the
283        // annotated form IS its output. Every other dialect that answers it has
284        // simply not said.
285        ProcedureForm::Agnostic if SqlRenderer::is_agnostic_dialect(dialect) => {}
286        ProcedureForm::Agnostic => findings.push(Finding {
287            severity: Severity::Unanswered,
288            what: "this dialect states no stored-procedure grammar, so Razeri emits its own \
289                   annotated form — which no engine parses"
290                .to_owned(),
291            fix: "override `SqlRenderer::procedure_form` with the grammar this engine speaks, \
292                  or with `ProcedureForm::Unsupported(\"…\")` and a sentence saying what it \
293                  offers instead."
294                .to_owned(),
295        }),
296        ProcedureForm::Unsupported(orientation) => {
297            check_refusal_copy("the stored-procedure refusal", orientation, findings);
298        }
299        _ => {}
300    }
301
302    if let Some(orientation) = SqlRenderer::table_function_form(dialect).refusal() {
303        check_refusal_copy("the table-function refusal", orientation, findings);
304    }
305
306    // The note is what a reader of the DDL learns from, and on an engine with
307    // no keyword of its own it is the ONLY place they can learn it.
308    if let ConstraintEnforcement::DeclaredOnly(declared) =
309        DdlRenderer::constraint_enforcement(dialect)
310        && declared.note.split_whitespace().count() < 5
311    {
312        findings.push(Finding {
313            severity: Severity::Blocking,
314            what: "this dialect says its engine accepts constraints it does not enforce, and \
315                   the note explaining that says almost nothing"
316                .to_owned(),
317            fix: "a reader of your rendered DDL sees a PRIMARY KEY that looks like a \
318                  guarantee. Say what this engine really does with the declaration, and where \
319                  the guarantee has to come from instead."
320                .to_owned(),
321        });
322    }
323
324    // **The two refusals are different findings, and that is the point of the
325    // second variant.** `None` is silence, and silence is unanswered. A stated
326    // `Unsupported` is a measured claim; what is left to grade there is whether
327    // the sentence is worth reading, because it is the only thing an operator
328    // gets where the load would have been.
329    match DialectCapabilities::merge_shape(dialect) {
330        MergeShape::None => findings.push(Finding {
331            severity: Severity::Unanswered,
332            what: "this dialect has not stated its upsert shape, so Razeri writes no generated \
333                   load for it"
334                .to_owned(),
335            fix: "if this engine has a MERGE or an upsert statement, say which in \
336                  `DialectCapabilities::merge_shape`. If it genuinely has neither, answer \
337                  `MergeShape::Unsupported` with your engine's own sentence — a refusal an \
338                  operator can act on, rather than a silence."
339                .to_owned(),
340        }),
341        MergeShape::Unsupported(orientation) => {
342            check_refusal_copy("the upsert refusal", orientation, findings);
343        }
344        _ => {}
345    }
346}
347
348/// The one override nothing tells an author about, and every formula in every
349/// design goes through it.
350fn check_formula_renderer(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
351    let formula = SqlRenderer::to_formula_renderer(dialect);
352    let dialect_name = SqlRenderer::dialect_name(dialect);
353    if formula.dialect_name() != dialect_name && dialect_name != "ansi" {
354        findings.push(Finding {
355            severity: Severity::Blocking,
356            what: "`to_formula_renderer` was not overridden, so every formula in every design \
357                   renders through Razeri's ANSI defaults instead of this dialect"
358                .to_owned(),
359            fix: "return `Box::new(Self)`. It is one line, nothing fails without it, and every \
360                  formula this engine sees is silently somebody else's."
361                .to_owned(),
362        });
363    }
364}
365
366/// A refusal that does not say what to do instead is a wall.
367fn check_refusal_copy(subject: &str, copy: &str, findings: &mut Vec<Finding>) {
368    if copy.trim().is_empty() {
369        findings.push(Finding {
370            severity: Severity::Blocking,
371            what: format!("{subject} has no words in it"),
372            fix: "say what this engine offers instead. A reader who hits a refusal needs the \
373                  next thing to try, and it has to be about their engine."
374                .to_owned(),
375        });
376        return;
377    }
378    if copy.split_whitespace().count() < 5 {
379        findings.push(Finding {
380            severity: Severity::Note,
381            what: format!("{subject} is very short"),
382            fix: "a refusal earns its place by naming the alternative — what this engine has \
383                  instead of the thing it cannot do."
384                .to_owned(),
385        });
386    }
387}
388
389/// Fourteen facts are declared twice and must agree.
390fn check_paired_facts(dialect: &dyn PlatformDialect, findings: &mut Vec<Finding>) {
391    let caps: &dyn DialectCapabilities = dialect;
392    let ren: &dyn SqlRenderer = dialect;
393    let ddl: &dyn DdlRenderer = dialect;
394    let mut disagreements: Vec<&'static str> = Vec::new();
395
396    macro_rules! pair {
397        ($m:ident, $renderer_value:expr) => {
398            if caps.$m() != $renderer_value {
399                disagreements.push(stringify!($m));
400            }
401        };
402    }
403    pair!(supports_on_conflict, ren.supports_on_conflict());
404    pair!(supports_insert_overwrite, ren.supports_insert_overwrite());
405    pair!(supports_update_from, ren.supports_update_from());
406    pair!(supports_named_windows, ren.supports_named_windows());
407    pair!(supports_compound_assignment, ren.supports_compound_assignment());
408    pair!(supports_window_frame_exclude, ren.supports_window_frame_exclude());
409    pair!(supports_merge_not_matched_by_source, ren.supports_merge_not_matched_by_source());
410    pair!(merge_not_matched_keyword, ren.merge_not_matched_keyword());
411    pair!(uses_alter_table_update, ren.uses_alter_table_update());
412    pair!(supports_multi_table_insert, ren.supports_multi_table_insert());
413    pair!(supports_cascade_drop, ddl.supports_cascade_drop());
414    pair!(supports_sync_drop, ddl.supports_sync_drop());
415    pair!(supports_force_drop, ddl.supports_force_drop());
416
417    // **The fourteenth, whose two sides are different types.** The capability
418    // is a boolean; the renderer answer is a form that either renders or
419    // carries a refusal. Both are read — the build door reads the form, and
420    // registry-walking rosters read the capability — so a dialect claiming a
421    // table function on one side and refusing on the other has an engine that
422    // both does and does not have one. `SQLite` was exactly that on the day
423    // this check was written: `false` since the capability existed, and a
424    // renderer still inheriting PostgreSQL's `RETURNS TABLE`.
425    if caps.supports_table_functions() != ren.table_function_form().refusal().is_none() {
426        disagreements.push("supports_table_functions");
427    }
428
429    if !disagreements.is_empty() {
430        findings.push(Finding {
431            severity: Severity::Blocking,
432            what: format!(
433                "this dialect declares {} fact(s) two ways and the two disagree: {}",
434                disagreements.len(),
435                disagreements.join(", ")
436            ),
437            fix: "each of these is declared once on the capability trait and once in the \
438                  renderer hierarchy, and Razeri reads both. Decide which is true of your engine \
439                  and move the other one to match."
440                .to_owned(),
441        });
442    }
443}
444
445/// **What a command has to do for you**, in the order it is owed.
446///
447/// These are Razeri's repository obligations: frozen matrices, generated
448/// bindings, and the allowlist a new crate joins. A dialect built outside this
449/// repository owes none of them; a dialect built inside it owes all of them,
450/// and each has an excellent failure message that nobody can read before it
451/// fires.
452#[must_use]
453pub const fn repository_obligations() -> &'static [Obligation] {
454    &[
455        Obligation {
456            what: "classify the new crate — every workspace member is named exactly once, and \
457                   dialect crates are tier 1",
458            command: "scripts/check-tier-boundary.sh",
459        },
460        Obligation {
461            what: "freeze the DDL your platform renders for all eleven corpus designs",
462            command: "UPDATE_GOLDEN=1 cargo test -p razeri-materialize --test golden_ddl",
463        },
464        Obligation {
465            what: "freeze the derived-column projections your platform renders",
466            command: "UPDATE_GOLDEN=1 cargo test -p razeri-materialize --test expression_matrix",
467        },
468        Obligation {
469            what: "freeze the transaction envelope your platform renders",
470            command: "UPDATE_GOLDEN=1 cargo test -p razeri-materialize \
471                      --test load_transaction_matrix",
472        },
473        Obligation {
474            what: "regenerate the typed platform constants for Python, TypeScript, Go, C# and \
475                   Java",
476            command: "cargo run -p razeri-dialects --bin gen-platform-constants",
477        },
478        Obligation {
479            what: "give every registry-walking roster a row for your platform — they fail one \
480                   at a time otherwise",
481            command: "cargo test -p razeri-materialize -p razeri-sql",
482        },
483        Obligation {
484            what: "prove the artifacts deploy and run: an execution leg per routine kind and \
485                   per generated load is what finished means here",
486            command: "cargo test -p integration-tests",
487        },
488    ]
489}
490
491/// Every type a design can declare, which is the set the required methods owe
492/// an answer for.
493fn declarable_types() -> Vec<DataType> {
494    vec![
495        DataType::tinyint(),
496        DataType::smallint(),
497        DataType::int(),
498        DataType::bigint(),
499        DataType::double(),
500        DataType::boolean(),
501        DataType::text(),
502        DataType::varchar(200),
503        DataType::uuid(),
504        DataType::json(),
505        DataType::date(),
506        DataType::timestamp(),
507        DataType::timestamp_tz(),
508        DataType::decimal(18, 2),
509    ]
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515    use crate::dialect::DialectInfo;
516    use razeri_plugin_contract::{
517        DmlRenderer, ExpressionRenderer, FormulaRenderer, MergeRestrictions, ProcedureRenderer,
518        ReplicationRenderer, ScalarFunctionForm, SidSpelling, StorageRenderer, TaskRenderer,
519        TextCast,
520    };
521
522    // ── The broken fixture ────────────────────────────────────────────
523    //
524    // A plugin that compiles, registers nothing it should, and answers every
525    // question with somebody else's default. This is what the kit exists to
526    // catch, and it is deliberately the plugin an author gets by implementing
527    // only the four required methods and stopping — which the guide, until
528    // now, told them was enough.
529
530    struct BrokenPlugin;
531
532    impl SqlRenderer for BrokenPlugin {
533        fn render_data_type(&self, _dt: &DataType) -> String {
534            String::new()
535        }
536        fn quote_identifier(&self, name: &str) -> String {
537            name.to_owned()
538        }
539        fn render_cast_type(&self, _dt: &DataType) -> String {
540            String::new()
541        }
542        fn qualify_name(&self, _schema: Option<&str>, name: &str) -> String {
543            name.to_owned()
544        }
545        fn render_current_timestamp(&self) -> String {
546            String::new()
547        }
548        fn dialect_name(&self) -> &'static str {
549            "brokendb"
550        }
551        // No `sid_spelling`, no `procedure_form`, no `to_formula_renderer` —
552        // and one fact declared twice, two ways.
553        fn supports_on_conflict(&self) -> bool {
554            true
555        }
556    }
557    impl ExpressionRenderer for BrokenPlugin {}
558    impl DdlRenderer for BrokenPlugin {}
559    impl DmlRenderer for BrokenPlugin {}
560    impl ProcedureRenderer for BrokenPlugin {}
561    impl FormulaRenderer for BrokenPlugin {}
562    impl TaskRenderer for BrokenPlugin {}
563    impl ReplicationRenderer for BrokenPlugin {}
564    impl StorageRenderer for BrokenPlugin {}
565    impl DialectCapabilities for BrokenPlugin {
566        fn supports_on_conflict(&self) -> bool {
567            false
568        }
569    }
570    impl PlatformDialect for BrokenPlugin {
571        fn info(&self) -> DialectInfo {
572            DialectInfo {
573                name: "brokendb",
574                display_name: "Broken DB",
575                dialect_family: DialectFamily::Custom(""),
576                family: "Broken",
577                is_cloud_native: false,
578                is_olap: false,
579                is_postgres_compatible: false,
580            }
581        }
582    }
583
584    // ── The healthy fixture ───────────────────────────────────────────
585    //
586    // The same plugin with every question answered. It is not registered
587    // either, so its report still carries the registration finding — which is
588    // the honest report for a dialect nothing has linked, and the one
589    // difference between "run me from your own crate's tests" and "run me
590    // from Razeri's CLI".
591
592    struct HealthyPlugin;
593
594    impl SqlRenderer for HealthyPlugin {
595        fn render_data_type(&self, dt: &DataType) -> String {
596            razeri_plugin_contract::agnostic::render_data_type_ansi(dt)
597        }
598        fn quote_identifier(&self, name: &str) -> String {
599            format!("\"{name}\"")
600        }
601        fn qualify_name(&self, schema: Option<&str>, name: &str) -> String {
602            schema.map_or_else(
603                || self.quote_identifier(name),
604                |s| format!("{}.{}", self.quote_identifier(s), self.quote_identifier(name)),
605            )
606        }
607        fn render_current_timestamp(&self) -> String {
608            "NOW()".to_owned()
609        }
610        fn dialect_name(&self) -> &'static str {
611            "healthydb"
612        }
613        fn sid_spelling(&self) -> SidSpelling {
614            SidSpelling {
615                platform: "healthydb",
616                hash: ("healthy_xxhash64(", ")"),
617                cast_type: TextCast::Direct("STRING"),
618                through_helper: false,
619                ..razeri_plugin_contract::ANSI_SID_SPELLING
620            }
621        }
622        fn procedure_form(&self) -> ProcedureForm {
623            ProcedureForm::Unsupported(
624                "Healthy DB has no stored procedures; write the load as a scheduled query \
625                 instead.",
626            )
627        }
628        fn scalar_function_form(&self) -> ScalarFunctionForm {
629            ScalarFunctionForm::PostgreSql
630        }
631        fn to_formula_renderer(&self) -> Box<dyn FormulaRenderer> {
632            Box::new(Self)
633        }
634    }
635    impl ExpressionRenderer for HealthyPlugin {}
636    impl DdlRenderer for HealthyPlugin {}
637    impl DmlRenderer for HealthyPlugin {}
638    impl ProcedureRenderer for HealthyPlugin {}
639    impl FormulaRenderer for HealthyPlugin {}
640    impl TaskRenderer for HealthyPlugin {}
641    impl ReplicationRenderer for HealthyPlugin {}
642    impl StorageRenderer for HealthyPlugin {}
643    impl DialectCapabilities for HealthyPlugin {
644        fn merge_shape(&self) -> MergeShape {
645            MergeShape::Merge(MergeRestrictions::none())
646        }
647    }
648    impl PlatformDialect for HealthyPlugin {
649        fn info(&self) -> DialectInfo {
650            DialectInfo {
651                name: "healthydb",
652                display_name: "Healthy DB",
653                dialect_family: DialectFamily::Custom("healthydb"),
654                family: "Healthy",
655                is_cloud_native: false,
656                is_olap: false,
657                is_postgres_compatible: false,
658            }
659        }
660    }
661
662    /// Every finding whose `what` line mentions one of these subjects.
663    fn mentions(report: &Report, needle: &str) -> bool {
664        report.findings.iter().any(|f| f.what.contains(needle) || f.fix.contains(needle))
665    }
666
667    /// **The kit's own red.** A plugin that implements the four required
668    /// methods and stops is exactly what the guide used to produce, and every
669    /// one of these findings is a defect a shipped dialect actually had.
670    #[test]
671    fn the_kit_catches_a_deliberately_broken_plugin() {
672        let report = check(&BrokenPlugin);
673        assert!(!report.is_clean(), "the broken plugin reported clean:\n{report}");
674
675        assert!(mentions(&report, "does not resolve"), "unregistered:\n{report}");
676        assert!(mentions(&report, "custom one with no name"), "unnamed family:\n{report}");
677        assert!(mentions(&report, "render_data_type"), "empty column types:\n{report}");
678        assert!(mentions(&report, "render_cast_type"), "empty cast types:\n{report}");
679        assert!(mentions(&report, "identifier unchanged"), "no quoting:\n{report}");
680        assert!(mentions(&report, "render_current_timestamp"), "no now():\n{report}");
681        assert!(mentions(&report, "surrogate-key spelling"), "unspelled key:\n{report}");
682        assert!(mentions(&report, "stored-procedure grammar"), "no procedure form:\n{report}");
683        assert!(mentions(&report, "upsert shape"), "no merge shape:\n{report}");
684        assert!(mentions(&report, "to_formula_renderer"), "formula renderer:\n{report}");
685        assert!(mentions(&report, "supports_on_conflict"), "paired fact:\n{report}");
686
687        assert!(
688            report.count(Severity::Blocking) >= 8,
689            "the broken plugin owes more than {} blocking fixes:\n{report}",
690            report.count(Severity::Blocking)
691        );
692    }
693
694    /// **The kit does not cry wolf.** The same plugin with its questions
695    /// answered reports only what is actually true of it: it is not
696    /// registered in this test binary, and nothing else.
697    #[test]
698    fn the_kit_reports_only_registration_on_a_plugin_that_answered_everything() {
699        let report = check(&HealthyPlugin);
700        let unregistered: Vec<&Finding> =
701            report.findings.iter().filter(|f| f.what.contains("does not resolve")).collect();
702        assert_eq!(unregistered.len(), 1, "the registration finding is the honest one here");
703        assert_eq!(
704            report.findings.len(),
705            1,
706            "a plugin that answered every question owes nothing else:\n{report}"
707        );
708    }
709
710    /// **A stated refusal is a claim, and a claim owes the reader words** — the
711    /// same grading every other refusal gets, because these words are the only
712    /// thing an operator finds where the load would have been.
713    #[test]
714    fn a_stated_upsert_refusal_is_graded_as_copy_and_a_silent_one_as_unanswered() {
715        let mut mute = Vec::new();
716        check_refusal_copy("the upsert refusal", "no merge", &mut mute);
717        assert_eq!(mute.len(), 1, "a three-word refusal is a wall: {mute:?}");
718        assert!(mute[0].what.contains("the upsert refusal"), "{mute:?}");
719
720        let silence = check(&BrokenPlugin);
721        assert!(
722            silence
723                .findings
724                .iter()
725                .any(|f| f.severity == Severity::Unanswered && f.what.contains("upsert shape")),
726            "a dialect that never answered is unanswered, not wrong:\n{silence}"
727        );
728        assert!(
729            silence.findings.iter().any(|f| f.fix.contains("MergeShape::Unsupported")),
730            "the fix line names the way to state a measured refusal:\n{silence}"
731        );
732    }
733
734    /// The report leads with what to fix and says it in words a reader can
735    /// act on — no identifiers from Razeri's own record-keeping, and a
736    /// one-line count before the detail.
737    #[test]
738    fn the_report_reads_as_a_summary_then_a_fix_list() {
739        let text = check(&BrokenPlugin).to_string();
740        let first = text.lines().next().expect("a summary line");
741        assert!(first.starts_with("brokendb:"), "{first}");
742        assert!(first.contains("to fix"), "{first}");
743        assert!(text.contains("      → "), "every finding names the fix:\n{text}");
744    }
745
746    /// The repository obligations are commands, in the order they are owed,
747    /// and every one of them says what it is for.
748    #[test]
749    fn every_repository_obligation_names_a_command_and_a_reason() {
750        let obligations = repository_obligations();
751        assert!(obligations.len() >= 5);
752        for o in obligations {
753            assert!(!o.command.trim().is_empty(), "{o:?}");
754            assert!(o.what.split_whitespace().count() >= 5, "{o:?}");
755        }
756    }
757}