Skip to main content

scythe_codegen/
validation.rs

1use std::process::Command;
2
3/// Validate generated code structurally for a given backend.
4/// Returns a list of errors (empty = passed).
5pub fn validate_structural(code: &str, backend_name: &str) -> Vec<String> {
6    match backend_name {
7        "python-psycopg3" | "python-asyncpg" | "python-aiomysql" | "python-aiosqlite" | "python-duckdb"
8        | "python-pyodbc" | "python-oracledb" | "python-snowflake" => validate_python(code),
9        "typescript-postgres"
10        | "typescript-pg"
11        | "typescript-mysql2"
12        | "typescript-better-sqlite3"
13        | "typescript-node-sqlite"
14        | "typescript-wasm-sqlite"
15        | "typescript-duckdb"
16        | "typescript-kysely"
17        | "typescript-mssql"
18        | "typescript-oracledb"
19        | "typescript-snowflake" => validate_typescript(code),
20        "javascript-pg" | "javascript-postgres" | "javascript-mysql2" | "javascript-better-sqlite3" => {
21            validate_javascript(code)
22        }
23        "go-pgx" | "go-database-sql" | "go-godror" | "go-gosnowflake" => validate_go(code),
24        "java-jdbc" => validate_java(code),
25        "java-r2dbc" => validate_java_r2dbc(code),
26        "kotlin-exposed" => validate_kotlin_exposed(code),
27        "kotlin-jdbc" => validate_kotlin(code),
28        "kotlin-r2dbc" => validate_kotlin_r2dbc(code),
29        "csharp-npgsql"
30        | "csharp-mysqlconnector"
31        | "csharp-microsoft-sqlite"
32        | "csharp-sqlclient"
33        | "csharp-oracle"
34        | "csharp-snowflake" => validate_csharp(code),
35        "elixir-postgrex" | "elixir-ecto" | "elixir-myxql" | "elixir-exqlite" | "elixir-tds" | "elixir-jamdb" => {
36            validate_elixir(code)
37        }
38        "ruby-pg" | "ruby-mysql2" | "ruby-sqlite3" | "ruby-trilogy" | "ruby-oci8" | "ruby-tiny-tds" => {
39            validate_ruby(code)
40        }
41        "php-pdo" | "php-amphp" => validate_php(code),
42        "rust-sqlx" | "rust-tokio-postgres" | "rust-tiberius" | "rust-sibyl" => vec![],
43        _ => vec![format!("unknown backend: {}", backend_name)],
44    }
45}
46
47fn validate_python(code: &str) -> Vec<String> {
48    let mut errors = Vec::new();
49
50    if code.contains("from __future__ import annotations") {
51        errors.push("unnecessary `from __future__ import annotations` — target is Python 3.10+".into());
52    }
53
54    let has_struct = code.contains("@dataclass")
55        || code.contains("(BaseModel)")
56        || code.contains("(msgspec.Struct)")
57        || code.contains("class ");
58    if !has_struct && !code.contains("async def ") && !code.contains("def ") {
59        errors.push("missing `@dataclass`/`class` and `def ` -- no meaningful output".into());
60    }
61
62    if !code.contains("async def ") && !code.contains("def ") {
63        errors.push("missing `async def ` or `def ` (for query functions)".into());
64    }
65
66    if code.contains("from typing import Union") {
67        errors.push("contains `from typing import Union` (pre-3.10 style)".into());
68    }
69
70    if code.contains("from typing import Optional") {
71        errors.push("contains `from typing import Optional` (pre-3.10 style)".into());
72    }
73
74    if code.contains("List[") {
75        errors.push("contains `List[` (use lowercase `list[`)".into());
76    }
77
78    if code.contains("Dict[") {
79        errors.push("contains `Dict[` (use lowercase `dict[`)".into());
80    }
81
82    for (i, line) in code.lines().enumerate() {
83        if line.starts_with('\t') {
84            errors.push(format!("line {} uses tab indentation (should use 4 spaces)", i + 1));
85            break;
86        }
87    }
88
89    errors
90}
91
92/// Structurally validate the `javascript-*` (JSDoc emit mode, #81) backends.
93///
94/// Unlike [`validate_typescript`], this rejects the TypeScript-only
95/// declaration forms those backends' generated `.ts` output legitimately
96/// uses (`export interface`, `export enum`) -- a plain `.js` file can never
97/// contain them, so their presence here means a `js_mode` branch fell
98/// through to the TypeScript path by mistake.
99fn validate_javascript(code: &str) -> Vec<String> {
100    let mut errors = Vec::new();
101
102    let has_function = code.contains("export async function") || code.contains("export function");
103    let has_typedef = code.contains("@typedef");
104    if !has_typedef && !has_function {
105        errors.push("missing `@typedef` (for DTOs) and `export function`/`export async function`".into());
106    }
107    if !has_function {
108        errors.push("missing `export async function` or `export function`".into());
109    }
110    if code.contains("export interface") {
111        errors.push("contains `export interface` -- TypeScript-only syntax, invalid in plain .js".into());
112    }
113    if code.contains("export enum") {
114        errors.push("contains `export enum` -- TypeScript-only syntax, invalid in plain .js".into());
115    }
116
117    for line in code.lines() {
118        let trimmed = line.trim();
119        if find_disallowed_any_usage(trimmed).is_some() {
120            errors.push(format!(
121                "contains `any` type (should use `unknown` or specific): {}",
122                trimmed
123            ));
124            break;
125        }
126    }
127
128    errors
129}
130
131fn validate_typescript(code: &str) -> Vec<String> {
132    let mut errors = Vec::new();
133
134    let has_function = code.contains("export async function") || code.contains("export function");
135
136    let has_zod = code.contains("z.object(") || code.contains("z.infer<");
137    if !code.contains("export interface") && !code.contains("export type") && !has_zod && !has_function {
138        errors.push("missing `export interface` or `export type` (for DTOs)".into());
139    }
140
141    if !has_function {
142        errors.push("missing `export async function` or `export function`".into());
143    }
144
145    for line in code.lines() {
146        let trimmed = line.trim();
147        if find_disallowed_any_usage(trimmed).is_some() {
148            errors.push(format!(
149                "contains `any` type (should use `unknown` or specific): {}",
150                trimmed
151            ));
152            break;
153        }
154    }
155
156    errors
157}
158
159/// Scan a line of generated TypeScript for a standalone `any` type token.
160///
161/// This is token-aware (matches `any` at identifier boundaries) rather than
162/// the previous fixed set of punctuation suffixes (`: any`, `<any>`, `any;`,
163/// `any,`, `any)`), which happened to omit `any>` and let the Kysely
164/// backend's `<DB = any>` generic-default slip through undetected rather
165/// than deliberately allowed.
166///
167/// The Kysely `<DB = any>` idiom is explicitly allowlisted here: `DB` is a
168/// real generic type parameter threaded through `Kysely<DB>` in the
169/// function signature, not an escape hatch. A caller who supplies
170/// `Kysely<MyDB>` gets full column typing; the `= any` default only lets
171/// callers who don't care about DB-shape typing omit the type argument.
172fn find_disallowed_any_usage(trimmed: &str) -> Option<&str> {
173    let mut offset = 0;
174    while let Some(rel) = trimmed[offset..].find("any") {
175        let start = offset + rel;
176        let end = start + "any".len();
177        let before_is_ident = start > 0 && is_ident_byte(trimmed.as_bytes()[start - 1]);
178        let after_is_ident = end < trimmed.len() && is_ident_byte(trimmed.as_bytes()[end]);
179        if !before_is_ident && !after_is_ident && !is_kysely_db_generic_default(trimmed, start, end) {
180            return Some(trimmed);
181        }
182        offset = end;
183    }
184    None
185}
186
187fn is_ident_byte(b: u8) -> bool {
188    b.is_ascii_alphanumeric() || b == b'_'
189}
190
191/// True when the `any` token spanning `[start, end)` in `line` is the
192/// default of a `<DB = any>` generic type parameter, e.g.
193/// `export async function foo<DB = any>(db: Kysely<DB>): ... {`.
194fn is_kysely_db_generic_default(line: &str, start: usize, end: usize) -> bool {
195    line[..start].ends_with("<DB = ") && line[end..].starts_with('>')
196}
197
198fn validate_go(code: &str) -> Vec<String> {
199    let mut errors = Vec::new();
200
201    let has_func = code.contains("func ");
202    let has_struct = code.contains("type ") && code.contains("struct {");
203
204    if !has_struct && !has_func {
205        errors.push("missing `type ... struct {` (for structs)".into());
206    }
207
208    if !has_func {
209        errors.push("missing `func ` (for functions)".into());
210    }
211
212    if !code.contains("context.Context") {
213        errors.push("missing `context.Context` as first param".into());
214    }
215
216    let has_indented_lines = code.lines().any(|l| l.starts_with('\t') || l.starts_with("  "));
217    if has_indented_lines {
218        let uses_spaces = code.lines().any(|l| l.starts_with("    ") && !l.trim().is_empty());
219        if uses_spaces {
220            errors.push("uses space indentation (Go standard is tabs)".into());
221        }
222    }
223
224    if has_struct && !code.contains("json:\"") {
225        errors.push("missing `json:\"` tags on struct fields".into());
226    }
227
228    errors
229}
230
231fn validate_java(code: &str) -> Vec<String> {
232    let mut errors = Vec::new();
233
234    let has_static = code.contains("public static ");
235
236    if !code.contains("public record ") && !has_static {
237        errors.push("missing `public record ` (for DTOs)".into());
238    }
239
240    if !has_static {
241        errors.push("missing `public static ` (for query methods)".into());
242    }
243
244    if !code.contains("throws SQLException") {
245        errors.push("missing `throws SQLException`".into());
246    }
247
248    if !code.contains("try (") {
249        errors.push("missing `try (` (try-with-resources)".into());
250    }
251
252    errors
253}
254
255fn validate_kotlin(code: &str) -> Vec<String> {
256    let mut errors = Vec::new();
257
258    let has_fun = code.contains("fun ");
259
260    if !code.contains("data class ") && !has_fun {
261        errors.push("missing `data class ` (for DTOs)".into());
262    }
263
264    if !has_fun {
265        errors.push("missing `fun ` (for functions)".into());
266    }
267
268    if !code.contains(".use {") {
269        errors.push("missing `.use {` (resource management)".into());
270    }
271
272    errors
273}
274
275fn validate_kotlin_exposed(code: &str) -> Vec<String> {
276    let mut errors = Vec::new();
277
278    let has_fun = code.contains("fun ");
279
280    if !code.contains("data class ") && !code.contains("object ") && !has_fun {
281        errors.push("missing `data class ` or `object ` (for DTOs/Tables)".into());
282    }
283
284    if !has_fun {
285        errors.push("missing `fun ` (for functions)".into());
286    }
287
288    if !code.contains("transaction {") {
289        errors.push("missing `transaction {` (Exposed transaction block)".into());
290    }
291
292    errors
293}
294
295fn validate_java_r2dbc(code: &str) -> Vec<String> {
296    let mut errors = Vec::new();
297
298    let has_static = code.contains("public static ");
299
300    if !code.contains("public record ") && !has_static {
301        errors.push("missing `public record ` (for DTOs)".into());
302    }
303
304    if !has_static {
305        errors.push("missing `public static ` (for query methods)".into());
306    }
307
308    if !code.contains("Mono<") && !code.contains("Flux<") {
309        errors.push("missing `Mono<` or `Flux<` (reactive types)".into());
310    }
311
312    if !code.contains("ConnectionFactory") {
313        errors.push("missing `ConnectionFactory` parameter".into());
314    }
315
316    errors
317}
318
319fn validate_kotlin_r2dbc(code: &str) -> Vec<String> {
320    let mut errors = Vec::new();
321
322    let has_fun = code.contains("fun ");
323
324    if !code.contains("data class ") && !has_fun {
325        errors.push("missing `data class ` (for DTOs)".into());
326    }
327
328    if !has_fun {
329        errors.push("missing `fun ` (for functions)".into());
330    }
331
332    if !code.contains("ConnectionFactory") {
333        errors.push("missing `ConnectionFactory` parameter".into());
334    }
335
336    if !code.contains("suspend fun") && !code.contains("Flow<") {
337        errors.push("missing `suspend fun` or `Flow<` (coroutine/reactive types)".into());
338    }
339
340    errors
341}
342
343fn validate_csharp(code: &str) -> Vec<String> {
344    let mut errors = Vec::new();
345
346    let has_async = code.contains("async Task<") || code.contains("async Task ");
347
348    if !code.contains("public record ") && !has_async {
349        errors.push("missing `public record ` (for DTOs)".into());
350    }
351
352    if !has_async {
353        errors.push("missing `async Task<` or `async Task` (for async methods)".into());
354    }
355
356    if !code.contains("await ") {
357        errors.push("missing `await `".into());
358    }
359
360    errors
361}
362
363fn validate_elixir(code: &str) -> Vec<String> {
364    let mut errors = Vec::new();
365
366    let has_def = code.contains("def ") || code.contains("defp ");
367
368    if !code.contains("defmodule ") && !has_def {
369        errors.push("missing `defmodule ` (for modules)".into());
370    }
371
372    if !code.contains("defstruct") && !has_def {
373        errors.push("missing `defstruct` (for structs)".into());
374    }
375
376    if !has_def {
377        errors.push("missing `def ` or `defp ` (for functions)".into());
378    }
379
380    if !code.contains("@type ") && !code.contains("@spec ") {
381        errors.push("missing `@type ` or `@spec ` (for typespecs)".into());
382    }
383
384    errors
385}
386
387fn validate_ruby(code: &str) -> Vec<String> {
388    let mut errors = Vec::new();
389
390    let has_method = code.contains("def self.");
391
392    if !code.contains("Data.define") && !has_method {
393        errors.push("missing `Data.define` (for DTOs)".into());
394    }
395
396    if !has_method {
397        errors.push("missing `def self.` (for module methods)".into());
398    }
399
400    if !code.contains("# frozen_string_literal: true") {
401        errors.push("missing `# frozen_string_literal: true`".into());
402    }
403
404    if !code.contains("module Queries") {
405        errors.push("missing `module Queries` wrapper".into());
406    }
407
408    errors
409}
410
411fn validate_php(code: &str) -> Vec<String> {
412    let mut errors = Vec::new();
413
414    let has_function = code.contains("function ");
415
416    if !code.contains("readonly class ") && !has_function {
417        errors.push("missing `readonly class ` (for DTOs)".into());
418    }
419
420    if !has_function {
421        errors.push("missing `function ` (for query functions)".into());
422    }
423
424    if !code.contains("declare(strict_types=1)") {
425        errors.push("missing `declare(strict_types=1)`".into());
426    }
427
428    if !code.contains("<?php") {
429        errors.push("missing `<?php`".into());
430    }
431
432    errors
433}
434
435/// Environment variable that turns on strict tool validation.
436///
437/// Set to `1` (or `true`) to make a missing tool a hard failure instead of a
438/// skip. CI sets it after installing every checker; a developer's laptop
439/// leaves it unset and still gets whatever checkers they happen to have.
440pub const STRICT_ENV_VAR: &str = "SCYTHE_VALIDATE_STRICT";
441
442/// Whether strict mode is on, read from [`STRICT_ENV_VAR`].
443///
444/// Deliberately an environment variable rather than a separate
445/// `validate_with_tools_strict` entry point. The call sites that matter are
446/// the generated-code assertions in `tests/tool_validation.rs`, and the
447/// property we want is "CI checks every language, a laptop checks what it
448/// can" -- with a second entry point every one of those call sites would have
449/// to branch on the environment itself, which is precisely the per-call-site
450/// policy decision that let the original `None`-is-a-pass bug spread to nine
451/// places. One switch, read in one function, cannot drift.
452pub fn strict_mode_enabled() -> bool {
453    matches!(
454        std::env::var(STRICT_ENV_VAR).as_deref(),
455        Ok("1") | Ok("true") | Ok("TRUE")
456    )
457}
458
459/// What happened when one external checker was pointed at a generated file.
460///
461/// The distinction between these variants is the whole point of this type.
462/// `Ran { errors: [] }` means a real tool inspected the code and found
463/// nothing wrong; `Missing` means nothing was inspected at all. Collapsing
464/// the two -- which is what returning `Option<Vec<String>>` did, since both
465/// arrived at the call site as "no errors to report" -- turns an uninstalled
466/// linter into a green check mark.
467#[derive(Debug, Clone, PartialEq, Eq)]
468pub enum ToolOutcome {
469    /// The tool was found and ran to completion. An empty `errors` genuinely
470    /// means this checker is satisfied.
471    Ran {
472        /// Executable name, as it would be typed on a command line.
473        tool: &'static str,
474        /// Findings, already prefixed with the tool name.
475        errors: Vec<String>,
476    },
477    /// The tool is not on `PATH`, so whatever it would have caught went
478    /// unchecked. Never an error outside strict mode, never a pass inside it.
479    Missing {
480        /// Executable name that was looked for and not found.
481        tool: &'static str,
482    },
483    /// The tool is installed but the harness could not run it -- a temp file
484    /// that would not write, a process that would not spawn.
485    ///
486    /// This is neither a pass nor a skip: it is always an error, strict mode
487    /// or not. The previous implementation reached these paths through `?` on
488    /// an `Option` and returned `None`, i.e. reported a broken harness as an
489    /// uninstalled tool, which then read as a pass.
490    Failed {
491        /// Executable name the harness was trying to drive.
492        tool: &'static str,
493        /// What went wrong, in the harness rather than in the checked code.
494        reason: String,
495    },
496}
497
498/// Every checker's verdict for one generated file.
499#[derive(Debug, Clone, PartialEq, Eq)]
500pub enum ToolValidation {
501    /// No tool-based validator exists for this backend's language.
502    ///
503    /// Distinct from "the tools are missing": Java, C#, Elixir and Rust have
504    /// no validator written for them at all, and reporting that as a skip
505    /// would suggest installing something would help.
506    Unsupported,
507    /// A validator exists; one entry per checker it tried to run.
508    Attempted(Vec<ToolOutcome>),
509}
510
511impl ToolValidation {
512    /// Findings from the checkers that actually ran.
513    pub fn errors(&self) -> Vec<&str> {
514        match self {
515            Self::Unsupported => vec![],
516            Self::Attempted(outcomes) => outcomes
517                .iter()
518                .flat_map(|outcome| match outcome {
519                    ToolOutcome::Ran { errors, .. } => errors.iter().map(String::as_str).collect::<Vec<_>>(),
520                    ToolOutcome::Missing { .. } => vec![],
521                    ToolOutcome::Failed { reason, .. } => vec![reason.as_str()],
522                })
523                .collect(),
524        }
525    }
526
527    /// Checkers that were not installed.
528    pub fn missing_tools(&self) -> Vec<&'static str> {
529        match self {
530            Self::Unsupported => vec![],
531            Self::Attempted(outcomes) => outcomes
532                .iter()
533                .filter_map(|outcome| match outcome {
534                    ToolOutcome::Missing { tool } => Some(*tool),
535                    _ => None,
536                })
537                .collect(),
538        }
539    }
540
541    /// Checkers that ran to completion.
542    pub fn tools_run(&self) -> Vec<&'static str> {
543        match self {
544            Self::Unsupported => vec![],
545            Self::Attempted(outcomes) => outcomes
546                .iter()
547                .filter_map(|outcome| match outcome {
548                    ToolOutcome::Ran { tool, .. } => Some(*tool),
549                    _ => None,
550                })
551                .collect(),
552        }
553    }
554
555    /// Whether every checker this validator knows about actually ran.
556    ///
557    /// False for [`Self::Unsupported`]: nothing checked the code, so claiming
558    /// it was fully checked would be the same lie in a different shape.
559    pub fn fully_checked(&self) -> bool {
560        match self {
561            Self::Unsupported => false,
562            Self::Attempted(outcomes) => {
563                !outcomes.is_empty()
564                    && outcomes
565                        .iter()
566                        .all(|outcome| matches!(outcome, ToolOutcome::Ran { .. }))
567            }
568        }
569    }
570
571    /// Collapse to pass/fail under the current [`strict_mode_enabled`] policy.
572    ///
573    /// Outside strict mode a missing tool is tolerated. Inside it, a missing
574    /// tool is a failure -- that is what stops a checker from quietly falling
575    /// out of CI and taking its coverage with it.
576    pub fn into_result(self) -> Result<(), Vec<String>> {
577        self.into_result_with_strictness(strict_mode_enabled())
578    }
579
580    /// [`Self::into_result`] with the policy passed explicitly, so tests can
581    /// exercise both modes without mutating process-global environment state
582    /// (which races under `cargo test`'s thread-per-test execution).
583    ///
584    /// [`Self::Unsupported`] is deliberately **not** a strict-mode failure.
585    /// Strict mode exists to catch a checker that fell out of the CI image,
586    /// and every one of its failures should be fixable by installing
587    /// something. A language with no validator written for it is a real gap
588    /// -- Java, C#, Elixir and Rust are all in it -- but no CI configuration
589    /// closes it, so failing here would leave a permanently red build with no
590    /// action attached. That gap is tracked by an explicit inventory test
591    /// (`backends_with_no_tool_validator_are_a_known_and_shrinking_set` in
592    /// `tests/tool_validation.rs`) which fails when the set changes in either
593    /// direction, rather than by a signal nobody can act on.
594    pub fn into_result_with_strictness(self, strict: bool) -> Result<(), Vec<String>> {
595        let mut failures: Vec<String> = self.errors().into_iter().map(str::to_string).collect();
596
597        if strict {
598            for tool in self.missing_tools() {
599                failures.push(format!(
600                    "strict validation: `{tool}` is not installed, so nothing it checks was verified"
601                ));
602            }
603        }
604
605        if failures.is_empty() { Ok(()) } else { Err(failures) }
606    }
607}
608
609/// A generated source file written to a temporary path, removed on drop.
610///
611/// `Drop` rather than a trailing `remove_file` call: every validator below
612/// has early-return paths, and the previous hand-rolled cleanup was skipped
613/// on all of them, leaking a temp file per failed validation.
614struct TempSource {
615    path: std::path::PathBuf,
616}
617
618impl TempSource {
619    fn new(tool: &'static str, code: &str, ext: &str) -> Result<Self, ToolOutcome> {
620        write_temp(code, ext)
621            .map(|path| Self { path })
622            .ok_or_else(|| ToolOutcome::Failed {
623                tool,
624                reason: format!("{tool}: could not write a temporary `{ext}` file"),
625            })
626    }
627
628    fn arg(&self, tool: &'static str) -> Result<&str, ToolOutcome> {
629        self.path.to_str().ok_or_else(|| ToolOutcome::Failed {
630            tool,
631            reason: format!("{tool}: temporary file path is not valid UTF-8"),
632        })
633    }
634}
635
636impl Drop for TempSource {
637    fn drop(&mut self) {
638        let _ = std::fs::remove_file(&self.path);
639    }
640}
641
642/// Whether `tool` is on `PATH`, probed by running it with `probe_arg`.
643fn tool_present(tool: &str, probe_arg: &str) -> bool {
644    Command::new(tool).arg(probe_arg).output().is_ok()
645}
646
647/// Which of a process's streams a checker writes its diagnostics to.
648#[derive(Clone, Copy)]
649enum Stream {
650    Stdout,
651    Stderr,
652    /// Both, concatenated. `poly` splits its report across the two, so
653    /// picking either one alone drops half the diagnostic.
654    Both,
655}
656
657/// Run `tool` with `args` and turn a non-zero exit into findings.
658///
659/// `max_lines` caps how much of the diagnostic output is kept; a syntax error
660/// can produce hundreds of cascading lines and only the first few locate it.
661fn run_tool(tool: &'static str, args: &[&str], stream: Stream, max_lines: usize) -> Result<Vec<String>, ToolOutcome> {
662    let output = Command::new(tool)
663        .args(args)
664        .output()
665        .map_err(|error| ToolOutcome::Failed {
666            tool,
667            reason: format!("{tool}: could not be executed: {error}"),
668        })?;
669
670    if output.status.success() {
671        return Ok(vec![]);
672    }
673
674    let raw = match stream {
675        Stream::Stdout => String::from_utf8_lossy(&output.stdout),
676        Stream::Stderr => String::from_utf8_lossy(&output.stderr),
677        Stream::Both => std::borrow::Cow::Owned(format!(
678            "{}{}",
679            String::from_utf8_lossy(&output.stdout),
680            String::from_utf8_lossy(&output.stderr)
681        )),
682    };
683
684    Ok(raw
685        .lines()
686        .filter(|line| !line.trim().is_empty())
687        .take(max_lines)
688        .map(|line| format!("{tool}: {line}"))
689        .collect())
690}
691
692/// Run one checker end to end: probe, write the source, execute, collect.
693fn check_with(
694    tool: &'static str,
695    probe_arg: &str,
696    code: &str,
697    ext: &str,
698    build_args: impl Fn(&str) -> Vec<String>,
699    stream: Stream,
700    max_lines: usize,
701) -> ToolOutcome {
702    if !tool_present(tool, probe_arg) {
703        return ToolOutcome::Missing { tool };
704    }
705
706    let source = match TempSource::new(tool, code, ext) {
707        Ok(source) => source,
708        Err(outcome) => return outcome,
709    };
710    let path = match source.arg(tool) {
711        Ok(path) => path,
712        Err(outcome) => return outcome,
713    };
714
715    let args = build_args(path);
716    let args: Vec<&str> = args.iter().map(String::as_str).collect();
717
718    match run_tool(tool, &args, stream, max_lines) {
719        Ok(errors) => ToolOutcome::Ran { tool, errors },
720        Err(outcome) => outcome,
721    }
722}
723
724/// Validate generated code with the real compilers and linters for its
725/// language.
726///
727/// Returns [`ToolValidation::Unsupported`] when no validator exists for the
728/// backend's language, and a per-tool [`ToolOutcome`] list otherwise. Callers
729/// must not treat "no errors" as "checked" without consulting
730/// [`ToolValidation::fully_checked`] or [`ToolValidation::into_result`] --
731/// see [`ToolOutcome`] for why.
732pub fn validate_with_tools(code: &str, backend_name: &str) -> ToolValidation {
733    let outcomes = match backend_name {
734        name if name.starts_with("python") => validate_python_tools(code),
735        name if name.starts_with("javascript") => validate_javascript_tools(code),
736        name if name.starts_with("typescript") => validate_typescript_tools(code),
737        name if name.starts_with("go") => validate_go_tools(code),
738        name if name.starts_with("ruby") => validate_ruby_tools(code),
739        name if name.starts_with("php") => validate_php_tools(code),
740        // Both Java backends compile against real `javac`. `java-jdbc` needs
741        // only the two-annotation JSR-305 stub (see
742        // `java_annotation_stub_paths`); `java-r2dbc` additionally needs
743        // `io.r2dbc.spi` and Reactor's `Mono`/`Flux`, stubbed in
744        // `tests/java_stubs/` -- see `java_r2dbc_stub_paths` for why that is
745        // worth doing despite the generic surface involved.
746        "java-jdbc" => validate_java_tools(code),
747        "java-r2dbc" => validate_java_r2dbc_tools(code),
748        // Only the JDBC backend: `kotlin-exposed` needs the Exposed DSL
749        // framework (`transaction { }`, `exec(sql, args) { rs -> }`, and a
750        // `*ColumnType` per scalar), and `kotlin-r2dbc` needs
751        // `kotlinx.coroutines.flow.Flow` plus the `awaitFirst`/`asFlow`
752        // suspend bridges, whose stubs would have to reproduce the coroutines
753        // compiler plugin's view of `suspend`. `kotlin-jdbc` itself touches
754        // nothing but `java.sql`/`java.math`/`java.time`, so `kotlinc` alone
755        // (no extra classpath) resolves it.
756        "kotlin-jdbc" => validate_kotlin_tools(code),
757        // Every `elixir-*` backend, not just one: unlike Java/Kotlin/C#,
758        // Elixir does not resolve a remote *function* call at compile time --
759        // `Postgrex.query/3` when `Postgrex` was never compiled is a
760        // *warning* ("module is not available or is yet to be defined"), not
761        // an error, and `elixirc` still exits 0. A struct reference is the
762        // exception (verified against real `elixirc` output, not assumed):
763        // `%Mod{...}` expands `__struct__/1` at compile time regardless, so
764        // the two backends that construct or match one against an external
765        // driver's struct (`elixir-myxql`, `elixir-tds`) need the small stubs
766        // in `tests/elixir_stubs/` -- see `validate_elixir_tools`.
767        name if name.starts_with("elixir") => validate_elixir_tools(code),
768        // C#: every `csharp-*` backend references a NuGet-only driver
769        // (Npgsql, MySqlConnector, Microsoft.Data.Sqlite, Microsoft.Data.
770        // SqlClient, Oracle.ManagedDataAccess.Core, Snowflake.Data.Client).
771        // `using Npgsql;` with no compiled `Npgsql.dll` on the reference path
772        // is a hard `CS0246`, unlike Elixir's soft warning above, so there is
773        // no stub-free path through `dotnet build`/`csc` here, and six
774        // different driver APIs is too much surface to hand-stub credibly
775        // (unlike the two-interface JSR-305 case above). `validate_structural`
776        // still covers these backends.
777        //
778        // Rust: `rust-sqlx` expands `sqlx::query_as!`/`sqlx::query!` at
779        // compile time, which needs either a live database connection or an
780        // `SQLX_OFFLINE` `.sqlx` query cache -- there is no way to satisfy
781        // that from a bare generated file. `rust-tokio-postgres`,
782        // `rust-tiberius` and `rust-sibyl` reference their driver crates by
783        // fully-qualified path (`tokio_postgres::Row`, etc.) with no `use`
784        // to stub around; making `rustc --emit=metadata` resolve them needs
785        // `--extern` pointing at real compiled `.rlib`s, i.e. standing up a
786        // full Cargo dependency graph per backend -- disproportionate to a
787        // single-file lightweight check when every one of these backends'
788        // generated code is already syntax-checked by `syn::parse_file` in
789        // `crates/scythe-cli/tests/compile_check.rs` and the generated test
790        // suite.
791        _ => return ToolValidation::Unsupported,
792    };
793
794    ToolValidation::Attempted(outcomes)
795}
796
797/// A validation result for one backend's generated output, shaped for a
798/// caller (the CLI, in particular) to report directly to a user rather than
799/// for a test assertion to pick apart.
800///
801/// Three variants, not two, on purpose: collapsing [`Self::Unsupported`] into
802/// [`Self::Passed`] is the exact dishonesty [`ToolValidation`] exists to
803/// prevent -- see that type's doc comment. A caller that only distinguished
804/// pass from fail would report "OK" for a `csharp-npgsql` file that no
805/// compiler ever looked at, which is the bug this whole module was written to
806/// close.
807#[derive(Debug, Clone, PartialEq, Eq)]
808pub enum ValidationOutcome {
809    /// No tool-based validator exists for this backend's language at all
810    /// (see [`ToolValidation::Unsupported`]). Not a pass and not a failure --
811    /// report it as "not checked", never as "checked, no problems".
812    Unsupported,
813    /// A validator exists and every checker that ran found nothing wrong.
814    ///
815    /// `tools_run` names the checkers that actually inspected the code;
816    /// `tools_missing` names any this validator would also have liked to run
817    /// but could not find on `PATH`. Both are carried through rather than
818    /// collapsed, because "passed, nothing was installed to check it"
819    /// (`tools_run` empty) and "passed, `poly` verified it" are different
820    /// claims a caller may want to report differently even though neither is
821    /// a build failure under the default, non-strict policy this is built
822    /// on (see [`ToolValidation::into_result_with_strictness`]).
823    Passed {
824        /// Checkers that ran to completion and found nothing wrong.
825        tools_run: Vec<&'static str>,
826        /// Checkers this validator wanted to run but could not find.
827        tools_missing: Vec<&'static str>,
828    },
829    /// A checker found a problem with the generated code, or the harness
830    /// itself could not drive an installed tool. Never produced by a
831    /// merely-missing tool -- see [`ToolOutcome::Missing`] vs.
832    /// [`ToolOutcome::Failed`], which is exactly the distinction that keeps
833    /// an uninstalled linter from reading as a build failure here.
834    Failed {
835        /// Checkers that ran to completion (regardless of whether they were
836        /// the one that reported a problem).
837        tools_run: Vec<&'static str>,
838        /// Checkers this validator wanted to run but could not find.
839        tools_missing: Vec<&'static str>,
840        /// Findings, already prefixed with the tool name that reported each
841        /// one -- see [`ToolValidation::errors`].
842        errors: Vec<String>,
843    },
844}
845
846impl ValidationOutcome {
847    /// Whether this outcome should stop a build: `true` only for
848    /// [`Self::Failed`].
849    ///
850    /// Both [`Self::Unsupported`] and [`Self::Passed`] are "let it through"
851    /// outcomes under the default policy -- a caller that wants "no
852    /// validator for this language" to also block a build (unusual: it would
853    /// stop every C#, Rust, `kotlin-exposed`, and `kotlin-r2dbc` backend from
854    /// ever generating) must match on the variant itself rather than use
855    /// this helper.
856    #[must_use]
857    pub fn is_failure(&self) -> bool {
858        matches!(self, Self::Failed { .. })
859    }
860}
861
862/// Validate one backend's generated output with the real compilers and
863/// linters for its target language, collapsed to the three outcomes a
864/// caller needs to decide what to print and whether to fail a build.
865///
866/// This is [`validate_with_tools`] built on top of, not reimplemented: the
867/// pass/fail split follows the same non-strict policy as
868/// [`ToolValidation::into_result`] outside [`STRICT_ENV_VAR`] -- a tool that
869/// is not installed is tolerated, never a build failure. Keeping the collapse
870/// here, delegating to [`ToolValidation::into_result_with_strictness`] rather
871/// than re-deriving "missing is fine, a real finding is not" a second time,
872/// is what keeps that policy defined in exactly one place; see the doc
873/// comment on [`ToolValidation::into_result_with_strictness`] for why
874/// [`ValidationOutcome::Unsupported`] is exempt from strictness entirely
875/// rather than becoming a fourth, stricter failure case.
876///
877/// # This shells out to real external tools
878///
879/// `poly`, `tsc`, `node`, `gofmt`, `javac`, `kotlinc`, `elixirc`, `ruby`, and
880/// others, depending on `backend_name` -- see [`validate_with_tools`]'s match
881/// arms. When a tool is not installed, probing for it
882/// (`Command::new(tool).arg(probe_arg).output()`) fails fast: the OS reports
883/// "no such file" as soon as it tries to spawn the process, so an absent
884/// tool costs microseconds and never blocks. The cost that matters is the
885/// opposite case -- a tool that *is* installed. `javac` and `kotlinc` each
886/// pay JVM startup (commonly over a second) on top of a real compile;
887/// `tsc --checkJs --strict` runs a full TypeScript typecheck. A caller that
888/// invokes this unconditionally on every `scythe generate` would make every
889/// run pay that cost for every backend the file targets, on any machine that
890/// happens to have the tool installed -- and would silently validate less on
891/// a machine that doesn't, with no way for the user to tell which happened
892/// short of reading [`ValidationOutcome::Passed`]'s `tools_run`/
893/// `tools_missing` fields. **Callers should gate this behind an explicit
894/// opt-in (e.g. a `--verify` flag), not run it unconditionally on every
895/// generate.**
896#[must_use]
897pub fn validate_generated_code(code: &str, backend_name: &str) -> ValidationOutcome {
898    let validation = validate_with_tools(code, backend_name);
899    if matches!(validation, ToolValidation::Unsupported) {
900        return ValidationOutcome::Unsupported;
901    }
902
903    let tools_run = validation.tools_run();
904    let tools_missing = validation.missing_tools();
905
906    match validation.into_result_with_strictness(false) {
907        Ok(()) => ValidationOutcome::Passed {
908            tools_run,
909            tools_missing,
910        },
911        Err(errors) => ValidationOutcome::Failed {
912            tools_run,
913            tools_missing,
914            errors,
915        },
916    }
917}
918
919fn write_temp(code: &str, ext: &str) -> Option<std::path::PathBuf> {
920    use std::sync::atomic::{AtomicU64, Ordering};
921    static COUNTER: AtomicU64 = AtomicU64::new(0);
922    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
923    let basename = if ext == ".kt" {
924        format!("ScytheValidate{n}")
925    } else {
926        format!("scythe_validate_{n}")
927    };
928    let path = std::env::temp_dir().join(format!("{basename}{ext}"));
929    let trimmed = format!("{}\n", code.trim_end());
930    std::fs::write(&path, trimmed).ok()?;
931    Some(path)
932}
933
934/// A directory a checker writes build artifacts into, removed on drop.
935///
936/// `javac -d`, `kotlinc -d` and `elixirc -o` all require a writable output
937/// directory and happily leave `.class`/`.beam` files behind in it -- unlike
938/// `poly`, `gofmt`, `ruby -c` and `node --check`, none of which write
939/// anything to disk. Without this, running these checkers would litter
940/// whatever directory `cargo test` happens to run from. Mirrors
941/// [`TempSource`]'s cleanup guarantee, and is deliberately its own per-call
942/// directory (not a fixed shared path) so parallel test threads cannot race
943/// on each other's output.
944struct TempOutDir {
945    path: std::path::PathBuf,
946}
947
948impl TempOutDir {
949    fn new(tool: &'static str) -> Result<Self, ToolOutcome> {
950        use std::sync::atomic::{AtomicU64, Ordering};
951        static COUNTER: AtomicU64 = AtomicU64::new(0);
952        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
953        let path = std::env::temp_dir().join(format!("scythe_validate_out_{tool}_{n}"));
954        std::fs::create_dir_all(&path).map_err(|error| ToolOutcome::Failed {
955            tool,
956            reason: format!("{tool}: could not create output directory: {error}"),
957        })?;
958        Ok(Self { path })
959    }
960
961    fn arg(&self, tool: &'static str) -> Result<&str, ToolOutcome> {
962        self.path.to_str().ok_or_else(|| ToolOutcome::Failed {
963            tool,
964            reason: format!("{tool}: temporary output directory path is not valid UTF-8"),
965        })
966    }
967}
968
969impl Drop for TempOutDir {
970    fn drop(&mut self) {
971        let _ = std::fs::remove_dir_all(&self.path);
972    }
973}
974
975/// Like [`check_with`], but for compilers that must be pointed at a writable
976/// output directory. See [`TempOutDir`] for why that can't just be `check_with`
977/// with an extra flag baked into `build_args`.
978fn check_with_output(
979    tool: &'static str,
980    probe_arg: &str,
981    code: &str,
982    ext: &str,
983    build_args: impl Fn(&str, &str) -> Vec<String>,
984    stream: Stream,
985    max_lines: usize,
986) -> ToolOutcome {
987    if !tool_present(tool, probe_arg) {
988        return ToolOutcome::Missing { tool };
989    }
990
991    let source = match TempSource::new(tool, code, ext) {
992        Ok(source) => source,
993        Err(outcome) => return outcome,
994    };
995    let source_path = match source.arg(tool) {
996        Ok(path) => path,
997        Err(outcome) => return outcome,
998    };
999
1000    let out_dir = match TempOutDir::new(tool) {
1001        Ok(dir) => dir,
1002        Err(outcome) => return outcome,
1003    };
1004    let out_path = match out_dir.arg(tool) {
1005        Ok(path) => path,
1006        Err(outcome) => return outcome,
1007    };
1008
1009    let args = build_args(source_path, out_path);
1010    let args: Vec<&str> = args.iter().map(String::as_str).collect();
1011
1012    match run_tool(tool, &args, stream, max_lines) {
1013        Ok(errors) => ToolOutcome::Ran { tool, errors },
1014        Err(outcome) => outcome,
1015    }
1016}
1017
1018/// Run `poly` against a generated file.
1019///
1020/// `poly` is this repository's linter and formatter, and it bundles the
1021/// engines it needs in-process: `oxc` for JavaScript and TypeScript, `ruff`
1022/// for Python, `mago` for PHP. One already-required binary therefore replaces
1023/// three separately-installed ones -- `biome`, `ruff` and `php` -- and
1024/// because it parses as well as lints, it also subsumes the `python3 -m ast`
1025/// syntax pass.
1026///
1027/// `--no-workspace` is not optional. Without it `poly lint` also runs the
1028/// whole-project tier -- `cargo clippy` over this very workspace -- once per
1029/// generated file, turning a millisecond check into minutes.
1030///
1031/// `poly` exits non-zero only on error-severity findings, so warnings appear
1032/// in the message without failing the check. That is the same threshold the
1033/// repository's own `poly lint .` gate uses.
1034fn poly_check(code: &str, ext: &str) -> ToolOutcome {
1035    let config = generated_code_poly_config();
1036    check_with(
1037        "poly",
1038        "--version",
1039        code,
1040        ext,
1041        |path| {
1042            ["lint", "--no-workspace", "--config", &config.to_string_lossy(), path]
1043                .iter()
1044                .map(|arg| (*arg).to_string())
1045                .collect()
1046        },
1047        Stream::Both,
1048        6,
1049    )
1050}
1051
1052/// Path to the poly config used for generated code.
1053///
1054/// Passed explicitly rather than left to discovery: poly resolves config by
1055/// walking up from the file it is given, and the file here is a temporary one
1056/// outside the repository, so poly would find nothing and fall back to its
1057/// built-in defaults. What CI enforced would then depend on where the system
1058/// temp directory happens to sit. See the config file's own header for why it
1059/// differs from the repository's `poly.toml`.
1060fn generated_code_poly_config() -> std::path::PathBuf {
1061    std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/generated-code-poly.toml")).to_path_buf()
1062}
1063
1064/// `poly`'s bundled `ruff`, which reports syntax errors and the `E`/`F`/`I`
1065/// rule families -- the same ground the previous `python3 -m ast` plus
1066/// standalone `ruff` pair covered, in one tool that needs no install step.
1067fn validate_python_tools(code: &str) -> Vec<ToolOutcome> {
1068    vec![poly_check(code, ".py")]
1069}
1070
1071/// `poly`'s bundled `oxc`.
1072///
1073/// This was `biome check` until #98 installed biome and discovered the choice
1074/// had never been tested: `check` also runs the formatter, whose only
1075/// complaints about generated code are that scythe indents with spaces where
1076/// biome would use tabs, and where it would break a line. Generated-code
1077/// layout is the backends' contract and `scythe fmt` owns it, so running the
1078/// formatter here failed 8 TypeScript backends purely on indentation.
1079///
1080/// `poly` lints with `oxc` and does not impose a formatter, so it keeps what
1081/// is worth knowing -- unused bindings, unsafe casts, `useLiteralKeys` and the
1082/// rest of the correctness rules -- and drops a third-party install entirely.
1083fn validate_typescript_tools(code: &str) -> Vec<ToolOutcome> {
1084    vec![poly_check(code, ".ts")]
1085}
1086
1087/// Path to the hand-written ambient `.d.ts` stubs for the driver packages
1088/// (`pg`, `postgres`, `mysql2/promise`, `better-sqlite3`) the
1089/// `javascript-*` (JSDoc emit mode, #81) backends reference via
1090/// `import("pkg").Type` JSDoc annotations.
1091///
1092/// See that file's own header comment for why these are hand-written
1093/// stand-ins rather than the real `@types/*` packages (no `npm install` at
1094/// test time -- this must stay hermetic and offline-safe).
1095fn js_mode_driver_stub_path() -> std::path::PathBuf {
1096    std::path::Path::new(concat!(
1097        env!("CARGO_MANIFEST_DIR"),
1098        "/tests/js_mode_stubs/driver-stubs.d.ts"
1099    ))
1100    .to_path_buf()
1101}
1102
1103/// Validate generated `javascript-*` (JSDoc emit mode, #81) code against the
1104/// real `node` runtime and, when available, real `tsc --checkJs --strict`.
1105///
1106/// #81's verification requirement is specifically "runs under `node`, not
1107/// `tsx`, plus `tsc --checkJs --strict`", so both are reported as separate
1108/// [`ToolOutcome`]s: a machine with `node` but no `tsc` has verified that the
1109/// generated source parses, and verified nothing at all about whether its
1110/// JSDoc annotations typecheck.
1111///
1112/// Writes the temp file with a `.mjs` extension rather than `.js`: `node`'s
1113/// ESM-vs-CommonJS auto-detection for a bare `.js` file depends on Node
1114/// version and on whether some enclosing directory happens to have a
1115/// `package.json` with `"type": "module"` -- both outside this test's
1116/// control, and both irrelevant to what's actually being verified (that the
1117/// generated *source* is valid ESM). `.mjs` is an unambiguous, version-
1118/// independent signal that sidesteps that entirely.
1119fn validate_javascript_tools(code: &str) -> Vec<ToolOutcome> {
1120    // Real `node`, not `tsx`/`ts-node`/a build step: the generated file must
1121    // parse as plain ESM as-is. `--check` parses without executing,
1122    // mirroring the `ruby -c` / `php -l` precedent in this file.
1123    let parse = check_with(
1124        "node",
1125        "--version",
1126        code,
1127        ".mjs",
1128        |path| ["--check", path].iter().map(|arg| (*arg).to_string()).collect(),
1129        Stream::Stderr,
1130        5,
1131    );
1132
1133    let stub = js_mode_driver_stub_path();
1134    let typecheck = check_with(
1135        "tsc",
1136        "--version",
1137        code,
1138        ".mjs",
1139        |path| {
1140            let mut args: Vec<String> = [
1141                "--checkJs",
1142                "--strict",
1143                "--allowJs",
1144                "--noEmit",
1145                "--module",
1146                "nodenext",
1147                "--moduleResolution",
1148                "nodenext",
1149                "--target",
1150                "es2022",
1151            ]
1152            .iter()
1153            .map(|arg| (*arg).to_string())
1154            .collect();
1155            args.push(path.to_string());
1156            args.push(stub.to_string_lossy().into_owned());
1157            args
1158        },
1159        // tsc writes diagnostics to stdout by default.
1160        Stream::Stdout,
1161        10,
1162    );
1163
1164    vec![parse, typecheck]
1165}
1166
1167fn validate_go_tools(code: &str) -> Vec<ToolOutcome> {
1168    vec![check_with(
1169        "gofmt",
1170        "-h",
1171        code,
1172        ".go",
1173        |path| ["-e", path].iter().map(|arg| (*arg).to_string()).collect(),
1174        Stream::Stderr,
1175        3,
1176    )]
1177}
1178
1179fn validate_ruby_tools(code: &str) -> Vec<ToolOutcome> {
1180    vec![check_with(
1181        "ruby",
1182        "--version",
1183        code,
1184        ".rb",
1185        |path| ["-c", path].iter().map(|arg| (*arg).to_string()).collect(),
1186        Stream::Stderr,
1187        1,
1188    )]
1189}
1190
1191/// `poly`'s bundled `mago`, which parses PHP -- the same thing `php -l` did,
1192/// without needing a PHP runtime installed.
1193fn validate_php_tools(code: &str) -> Vec<ToolOutcome> {
1194    vec![poly_check(code, ".php")]
1195}
1196
1197/// Paths to the hand-written JSR-305 `@Nonnull`/`@Nullable` stub sources
1198/// `javac` needs to resolve `java-jdbc`'s nullability annotations. See
1199/// `tests/java_stubs/javax/annotation/Nonnull.java` for why these are
1200/// hand-written stand-ins rather than the real `com.google.code.findbugs:
1201/// jsr305` jar -- same reasoning as [`js_mode_driver_stub_path`], one
1202/// directory over.
1203fn java_annotation_stub_paths() -> Result<(String, String), ToolOutcome> {
1204    const TOOL: &str = "javac";
1205    let dir = std::path::Path::new(concat!(
1206        env!("CARGO_MANIFEST_DIR"),
1207        "/tests/java_stubs/javax/annotation"
1208    ));
1209    let nonnull = dir.join("Nonnull.java");
1210    let nullable = dir.join("Nullable.java");
1211    match (nonnull.to_str(), nullable.to_str()) {
1212        (Some(a), Some(b)) => Ok((a.to_string(), b.to_string())),
1213        _ => Err(ToolOutcome::Failed {
1214            tool: TOOL,
1215            reason: format!("{TOOL}: stub annotation path is not valid UTF-8"),
1216        }),
1217    }
1218}
1219
1220/// A `java-jdbc` source file, written to `<unique dir>/Queries.java`.
1221///
1222/// `javac` rejects a `public class Queries { ... }` unless the file it lives
1223/// in is literally named `Queries.java` -- and `java-jdbc`'s `file_header`
1224/// (`src/backends/java_jdbc.rs`) always wraps its output in exactly that
1225/// class, for every engine. The generic [`TempSource`]/`write_temp` naming
1226/// (`scythe_validate_<n>.java`) would therefore always fail to compile
1227/// regardless of what the generated code itself says, so this exists
1228/// alongside it rather than reusing it. A unique per-call directory, not a
1229/// fixed name in the shared system temp dir, is what keeps this collision-
1230/// safe under parallel `cargo test` execution.
1231struct JavaSource {
1232    dir: std::path::PathBuf,
1233}
1234
1235impl JavaSource {
1236    fn new(code: &str) -> Result<Self, ToolOutcome> {
1237        const TOOL: &str = "javac";
1238        use std::sync::atomic::{AtomicU64, Ordering};
1239        static COUNTER: AtomicU64 = AtomicU64::new(0);
1240        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1241        let dir = std::env::temp_dir().join(format!("scythe_validate_java_{n}"));
1242        std::fs::create_dir_all(&dir).map_err(|error| ToolOutcome::Failed {
1243            tool: TOOL,
1244            reason: format!("{TOOL}: could not create source directory: {error}"),
1245        })?;
1246        let trimmed = format!("{}\n", code.trim_end());
1247        std::fs::write(dir.join("Queries.java"), trimmed).map_err(|error| ToolOutcome::Failed {
1248            tool: TOOL,
1249            reason: format!("{TOOL}: could not write Queries.java: {error}"),
1250        })?;
1251        Ok(Self { dir })
1252    }
1253
1254    fn source_arg(&self) -> Result<String, ToolOutcome> {
1255        self.dir
1256            .join("Queries.java")
1257            .to_str()
1258            .map(str::to_string)
1259            .ok_or_else(|| ToolOutcome::Failed {
1260                tool: "javac",
1261                reason: "javac: temporary source path is not valid UTF-8".to_string(),
1262            })
1263    }
1264}
1265
1266impl Drop for JavaSource {
1267    fn drop(&mut self) {
1268        let _ = std::fs::remove_dir_all(&self.dir);
1269    }
1270}
1271
1272/// Validate generated `java-jdbc` code against the real `javac` compiler.
1273///
1274/// `java-jdbc` needs nothing beyond the JDK standard library plus the
1275/// two-annotation JSR-305 stub from [`java_annotation_stub_paths`], so this
1276/// compiles real generated code with no network access and no extra classpath
1277/// setup. `java-r2dbc` goes through [`validate_java_r2dbc_tools`], which adds
1278/// the R2DBC SPI and Reactor stubs it needs on top.
1279fn validate_java_tools(code: &str) -> Vec<ToolOutcome> {
1280    javac_with_stubs(code, &[])
1281}
1282
1283/// The R2DBC SPI + Reactive Streams + Reactor stub sources `java-r2dbc` needs
1284/// on top of the JSR-305 annotations, in `tests/java_stubs/`.
1285///
1286/// These exist because `java-r2dbc`'s output is almost entirely *inferred*:
1287/// `Mono.usingWhen(...).flatMap(result -> Mono.from(result.map((row, meta) ->
1288/// new GetUserRow(...))))` never spells its own element type, so the row
1289/// mapping -- the part the reader defects behind #191/#213/#214 lived in -- is
1290/// checked by nothing at all unless `Mono`, `Flux`, `Result`, and `Row`
1291/// resolve. Every generic bound in the stubs is copied verbatim from Reactor 3
1292/// and R2DBC SPI 1.0 rather than loosened, so a file these accept is one the
1293/// real libraries accept; that is verified by mutation, not assumed (see the
1294/// header comment on `tests/java_stubs/reactor/core/publisher/Mono.java`).
1295fn java_r2dbc_stub_paths() -> Result<Vec<String>, ToolOutcome> {
1296    const TOOL: &str = "javac";
1297    const RELATIVE: [&str; 9] = [
1298        "org/reactivestreams/Publisher.java",
1299        "io/r2dbc/spi/ConnectionFactory.java",
1300        "io/r2dbc/spi/Connection.java",
1301        "io/r2dbc/spi/Statement.java",
1302        "io/r2dbc/spi/Result.java",
1303        "io/r2dbc/spi/Row.java",
1304        "io/r2dbc/spi/RowMetadata.java",
1305        "reactor/core/publisher/Mono.java",
1306        "reactor/core/publisher/Flux.java",
1307    ];
1308    let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/java_stubs"));
1309    RELATIVE
1310        .iter()
1311        .map(|relative| {
1312            dir.join(relative)
1313                .to_str()
1314                .map(str::to_string)
1315                .ok_or_else(|| ToolOutcome::Failed {
1316                    tool: TOOL,
1317                    reason: format!("{TOOL}: stub source path is not valid UTF-8: {relative}"),
1318                })
1319        })
1320        .collect()
1321}
1322
1323/// Validate generated `java-r2dbc` code against the real `javac` compiler,
1324/// with the R2DBC SPI and Reactor stubs on the source path.
1325fn validate_java_r2dbc_tools(code: &str) -> Vec<ToolOutcome> {
1326    let stubs = match java_r2dbc_stub_paths() {
1327        Ok(paths) => paths,
1328        Err(outcome) => return vec![outcome],
1329    };
1330    javac_with_stubs(code, &stubs)
1331}
1332
1333/// Compile `code` as `Queries.java` with `javac`, alongside the JSR-305
1334/// annotation stubs and any `extra_stubs` the backend needs.
1335fn javac_with_stubs(code: &str, extra_stubs: &[String]) -> Vec<ToolOutcome> {
1336    const TOOL: &str = "javac";
1337
1338    if !tool_present(TOOL, "-version") {
1339        return vec![ToolOutcome::Missing { tool: TOOL }];
1340    }
1341
1342    let source = match JavaSource::new(code) {
1343        Ok(source) => source,
1344        Err(outcome) => return vec![outcome],
1345    };
1346    let source_arg = match source.source_arg() {
1347        Ok(path) => path,
1348        Err(outcome) => return vec![outcome],
1349    };
1350    let (nonnull, nullable) = match java_annotation_stub_paths() {
1351        Ok(paths) => paths,
1352        Err(outcome) => return vec![outcome],
1353    };
1354    let out_dir = match TempOutDir::new(TOOL) {
1355        Ok(dir) => dir,
1356        Err(outcome) => return vec![outcome],
1357    };
1358    let out_arg = match out_dir.arg(TOOL) {
1359        Ok(path) => path,
1360        Err(outcome) => return vec![outcome],
1361    };
1362
1363    let mut args = vec![
1364        "-d".to_string(),
1365        out_arg.to_string(),
1366        "-nowarn".to_string(),
1367        source_arg,
1368        nonnull,
1369        nullable,
1370    ];
1371    args.extend(extra_stubs.iter().cloned());
1372    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
1373
1374    vec![match run_tool(TOOL, &arg_refs, Stream::Both, 20) {
1375        Ok(errors) => ToolOutcome::Ran { tool: TOOL, errors },
1376        Err(outcome) => outcome,
1377    }]
1378}
1379
1380/// Validate generated `kotlin-jdbc` code against the real `kotlinc` compiler.
1381///
1382/// Only `kotlin-jdbc`, not `kotlin-exposed` or `kotlin-r2dbc` -- see the
1383/// comment on that match arm in [`validate_with_tools`] for why those two
1384/// need frameworks this checker cannot cheaply stand up. `kotlin-jdbc`
1385/// touches only `java.sql`/`java.math`/`java.time`, all part of the JDK
1386/// `kotlinc` already resolves against, so unlike those two this needs no
1387/// extra classpath at all -- and unlike `java-jdbc`, Kotlin does not require
1388/// the source file name to match a public class name, so this can reuse the
1389/// generic [`check_with_output`] instead of [`JavaSource`]'s bespoke naming.
1390fn validate_kotlin_tools(code: &str) -> Vec<ToolOutcome> {
1391    vec![check_with_output(
1392        "kotlinc",
1393        "-version",
1394        code,
1395        ".kt",
1396        |source, out| ["-d", out, source].iter().map(|arg| (*arg).to_string()).collect(),
1397        Stream::Both,
1398        10,
1399    )]
1400}
1401
1402/// Every file in `tests/elixir_stubs/`: hand-written stand-ins for the external driver structs
1403/// the `elixir-*` backends construct or pattern-match on (`elixir-myxql`'s `%MyXQL.Result{...}`,
1404/// `elixir-tds`'s `%Tds.Parameter{...}`, `elixir-jamdb`'s `%Jamdb.Oracle.Query{...}`). See
1405/// `tests/elixir_stubs/myxql_result.ex` for why a struct reference needs a stub when a plain
1406/// remote call does not.
1407///
1408/// Read from the directory rather than named one by one: a backend that starts referencing a new
1409/// driver struct then needs only the stub file, and cannot fail here because someone added the
1410/// file and forgot the corresponding line. Missing or unreadable directory is an error, not an
1411/// empty list -- silently validating against no stubs at all would turn every struct reference
1412/// into a compile error attributed to the generated code.
1413fn elixir_stub_paths() -> Result<Vec<String>, ToolOutcome> {
1414    const TOOL: &str = "elixirc";
1415    let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/elixir_stubs"));
1416    let entries = std::fs::read_dir(dir).map_err(|e| ToolOutcome::Failed {
1417        tool: TOOL,
1418        reason: format!("{TOOL}: cannot read stub directory {}: {e}", dir.display()),
1419    })?;
1420    let mut paths: Vec<String> = Vec::new();
1421    for entry in entries.flatten() {
1422        let path = entry.path();
1423        if path.extension().and_then(|e| e.to_str()) != Some("ex") {
1424            continue;
1425        }
1426        match path.to_str() {
1427            Some(p) => paths.push(p.to_string()),
1428            None => {
1429                return Err(ToolOutcome::Failed {
1430                    tool: TOOL,
1431                    reason: format!("{TOOL}: stub struct path is not valid UTF-8"),
1432                });
1433            }
1434        }
1435    }
1436    if paths.is_empty() {
1437        return Err(ToolOutcome::Failed {
1438            tool: TOOL,
1439            reason: format!("{TOOL}: no stub modules found in {}", dir.display()),
1440        });
1441    }
1442    // ~keep Deterministic argument order, so a failure reproduces identically run to run.
1443    paths.sort();
1444    Ok(paths)
1445}
1446
1447/// Validate generated `elixir-*` code against the real `elixirc` compiler.
1448///
1449/// Covers every `elixir-*` backend (`elixir-postgrex`, `-ecto`, `-myxql`,
1450/// `-exqlite`, `-tds`, `-jamdb`). Almost none of them need a stub, which is
1451/// not laziness -- it is a real asymmetry with Java/Kotlin/C#/Rust. Elixir
1452/// does not resolve a remote *function* call at compile time:
1453/// `Postgrex.query/3` when `Postgrex` itself was never compiled is a
1454/// *warning* ("module is not available or is yet to be defined"), not an
1455/// error, and `elixirc` still exits `0`. `run_tool` only turns a checker's
1456/// output into findings when the process itself failed
1457/// (`output.status.success()` short-circuits otherwise), so these expected
1458/// warnings about undefined driver modules are silently and correctly
1459/// ignored -- while a genuine syntax error (verified against `elixirc`
1460/// directly: a real `MismatchedDelimiterError` exits `1`) still surfaces
1461/// normally.
1462///
1463/// A struct reference is the one place that asymmetry does not hold: `%Mod{
1464/// ...}` -- construction or pattern match -- expands `Mod.__struct__/1` (or,
1465/// under this Elixir's set-theoretic type checker, statically checks the
1466/// pattern) at compile time, and both fail hard when `Mod` was never
1467/// compiled. Verified directly against `elixirc`, not assumed: `elixir-myxql`
1468/// and `elixir-tds` are the only two backends that reference an external
1469/// struct at all (`%MyXQL.Result{...}`, `%Tds.Parameter{...}`), so
1470/// [`elixir_stub_paths`] supplies both unconditionally -- an unused stub
1471/// module compiles to a harmless, un-invoked `.beam` file for the other four
1472/// backends.
1473fn validate_elixir_tools(code: &str) -> Vec<ToolOutcome> {
1474    let stubs = match elixir_stub_paths() {
1475        Ok(paths) => paths,
1476        Err(outcome) => return vec![outcome],
1477    };
1478    vec![check_with_output(
1479        "elixirc",
1480        "--version",
1481        code,
1482        ".ex",
1483        move |source, out| {
1484            let mut args = vec!["-o".to_string(), out.to_string(), source.to_string()];
1485            args.extend(stubs.iter().cloned());
1486            args
1487        },
1488        Stream::Both,
1489        8,
1490    )]
1491}
1492
1493#[cfg(test)]
1494mod tests {
1495    use super::*;
1496
1497    #[test]
1498    fn test_unknown_backend() {
1499        let errors = validate_structural("some code", "unknown-backend");
1500        assert_eq!(errors.len(), 1);
1501        assert!(errors[0].contains("unknown backend"));
1502    }
1503
1504    #[test]
1505    fn test_rust_backends_skip() {
1506        assert!(validate_structural("anything", "rust-sqlx").is_empty());
1507        assert!(validate_structural("anything", "rust-tokio-postgres").is_empty());
1508    }
1509
1510    #[test]
1511    fn test_python_valid() {
1512        let code = r#"from dataclasses import dataclass
1513
1514@dataclass
1515class ListUsersRow:
1516    id: int
1517    name: str
1518
1519async def list_users(conn) -> list[ListUsersRow]:
1520    pass
1521"#;
1522        let errors = validate_structural(code, "python-psycopg3");
1523        assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
1524    }
1525
1526    #[test]
1527    fn test_python_invalid_typing() {
1528        let code = r#"from typing import Optional
1529
1530@dataclass
1531class Row:
1532    id: int
1533
1534def query() -> List[Row]:
1535    pass
1536"#;
1537        let errors = validate_structural(code, "python-asyncpg");
1538        assert!(errors.iter().any(|e| e.contains("Optional")));
1539        assert!(errors.iter().any(|e| e.contains("List[")));
1540    }
1541
1542    #[test]
1543    fn test_typescript_valid() {
1544        let code = r#"export interface ListUsersRow {
1545  id: number;
1546  name: string;
1547}
1548
1549export async function listUsers(): Promise<ListUsersRow[]> {
1550  // ...
1551}
1552"#;
1553        let errors = validate_structural(code, "typescript-postgres");
1554        assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
1555    }
1556
1557    #[test]
1558    fn test_typescript_allows_kysely_db_generic_default() {
1559        let code = r#"export async function listUsers<DB = any>(db: Kysely<DB>): Promise<ListUsersRow[]> {
1560  return [];
1561}
1562"#;
1563        let errors = validate_structural(code, "typescript-kysely");
1564        assert!(
1565            errors.is_empty(),
1566            "the `<DB = any>` generic default must be allowlisted, got: {:?}",
1567            errors
1568        );
1569    }
1570
1571    #[test]
1572    fn test_typescript_rejects_any_before_closing_angle_bracket() {
1573        // Regression test: the old suffix-list check (`: any`, `<any>`,
1574        // `any;`, `any,`, `any)`) never looked for `any` immediately before
1575        // `>`, so `Array<any>` (or any other non-Kysely-DB `any>` usage)
1576        // slipped through undetected.
1577        let code = r#"export async function listUsers(): Promise<Array<any>> {
1578  return [];
1579}
1580"#;
1581        let errors = validate_structural(code, "typescript-postgres");
1582        assert!(
1583            errors.iter().any(|e| e.contains("any")),
1584            "expected an `any` violation, got: {:?}",
1585            errors
1586        );
1587    }
1588
1589    #[test]
1590    fn test_go_valid() {
1591        let code = "package db\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n)\n\ntype ListUsersRow struct {\n\tID   int    `json:\"id\"`\n\tName string `json:\"name\"`\n}\n\nfunc ListUsers(ctx context.Context) ([]ListUsersRow, error) {\n\treturn nil, nil\n}\n";
1592        let errors = validate_structural(code, "go-pgx");
1593        assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
1594    }
1595
1596    #[test]
1597    fn test_php_valid() {
1598        let code = r#"<?php
1599
1600declare(strict_types=1);
1601
1602readonly class ListUsersRow {
1603    public function __construct(
1604        public int $id,
1605        public string $name,
1606    ) {}
1607}
1608
1609function listUsers($pdo): array {
1610    return [];
1611}
1612"#;
1613        let errors = validate_structural(code, "php-pdo");
1614        assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
1615    }
1616
1617    /// The bug this whole type exists to prevent: before `ToolValidation`,
1618    /// a tool that was never installed and a tool that ran and found nothing
1619    /// both arrived at the call site as "no errors", so an uninstalled linter
1620    /// read as a green check mark.
1621    #[test]
1622    fn a_missing_tool_is_not_the_same_as_a_clean_run() {
1623        let clean = ToolValidation::Attempted(vec![ToolOutcome::Ran {
1624            tool: "ruff",
1625            errors: vec![],
1626        }]);
1627        let absent = ToolValidation::Attempted(vec![ToolOutcome::Missing { tool: "ruff" }]);
1628
1629        // Both report zero findings -- that is exactly why the old
1630        // `Option<Vec<String>>` could not tell them apart.
1631        assert!(clean.errors().is_empty());
1632        assert!(absent.errors().is_empty());
1633
1634        // The type still distinguishes them.
1635        assert!(clean.fully_checked(), "a tool that ran is a real check");
1636        assert!(!absent.fully_checked(), "a tool that never ran checked nothing");
1637        assert_eq!(absent.missing_tools(), vec!["ruff"]);
1638        assert!(clean.missing_tools().is_empty());
1639    }
1640
1641    #[test]
1642    fn strict_mode_turns_a_missing_tool_into_a_failure() {
1643        let absent = ToolValidation::Attempted(vec![ToolOutcome::Missing { tool: "poly" }]);
1644
1645        assert!(
1646            absent.clone().into_result_with_strictness(false).is_ok(),
1647            "outside strict mode a missing tool is tolerated"
1648        );
1649
1650        let failures = absent
1651            .into_result_with_strictness(true)
1652            .expect_err("strict mode must fail on a missing tool");
1653        assert_eq!(failures.len(), 1);
1654        assert!(
1655            failures[0].contains("poly") && failures[0].contains("not installed"),
1656            "the failure must name the tool and say it never ran: {failures:?}"
1657        );
1658    }
1659
1660    /// A validator that runs two tools and finds only one of them has
1661    /// verified half of what it claims to. `validate_python_tools` shipped
1662    /// exactly this shape -- `python3` present, `ruff` absent, `Some([])`
1663    /// returned -- which is the strongest possible false signal.
1664    #[test]
1665    fn a_partially_run_validation_is_not_fully_checked() {
1666        let partial = ToolValidation::Attempted(vec![
1667            ToolOutcome::Ran {
1668                tool: "python3",
1669                errors: vec![],
1670            },
1671            ToolOutcome::Missing { tool: "ruff" },
1672        ]);
1673
1674        assert!(partial.errors().is_empty());
1675        assert!(!partial.fully_checked(), "one of two tools running is not a full check");
1676        assert_eq!(partial.tools_run(), vec!["python3"]);
1677        assert_eq!(partial.missing_tools(), vec!["ruff"]);
1678        assert!(partial.into_result_with_strictness(true).is_err());
1679    }
1680
1681    /// The languages `poly` bundles an engine for are checked by `poly` alone
1682    /// -- one binary, no per-language install. A regression that reintroduced
1683    /// a separate `ruff`/`biome`/`php` invocation would show up here as an
1684    /// extra outcome.
1685    #[test]
1686    fn poly_backed_languages_report_exactly_one_tool() {
1687        for (label, outcomes) in [
1688            ("python", validate_python_tools("x = 1\n")),
1689            ("typescript", validate_typescript_tools("export const x = 1;\n")),
1690            ("php", validate_php_tools("<?php\ndeclare(strict_types=1);\n")),
1691        ] {
1692            assert_eq!(outcomes.len(), 1, "{label} must be checked by poly alone");
1693            let tool = match &outcomes[0] {
1694                ToolOutcome::Ran { tool, .. } | ToolOutcome::Missing { tool } | ToolOutcome::Failed { tool, .. } => {
1695                    *tool
1696                }
1697            };
1698            assert_eq!(tool, "poly", "{label} must go through poly");
1699        }
1700    }
1701
1702    /// A language with no validator must not masquerade as a clean pass
1703    /// either -- `_ => None` previously made Java, C#, Elixir and Rust
1704    /// unconditionally green. `java-jdbc`, `kotlin-jdbc` and every `elixir-*`
1705    /// backend have real validators now (see `validate_with_tools`); `csharp-*`
1706    /// is still in the gap this guards, so it is what this test exercises.
1707    #[test]
1708    fn a_backend_with_no_validator_is_unsupported_not_clean() {
1709        let validation = validate_with_tools("public class Foo {}", "csharp-npgsql");
1710
1711        assert_eq!(validation, ToolValidation::Unsupported);
1712        assert!(validation.errors().is_empty());
1713        assert!(!validation.fully_checked(), "no validator means nothing was checked");
1714        assert!(validation.clone().into_result_with_strictness(false).is_ok());
1715        assert!(
1716            validation.into_result_with_strictness(true).is_ok(),
1717            "strict mode is for tools that can be installed; a language with no \
1718             validator is tracked by an inventory test instead -- see \
1719             into_result_with_strictness"
1720        );
1721    }
1722
1723    /// A harness that cannot run an installed tool is a failure in both
1724    /// modes. The old code reached these paths through `?` on an `Option`
1725    /// and reported them as "tool not installed", which then read as a pass.
1726    #[test]
1727    fn a_harness_failure_is_an_error_even_outside_strict_mode() {
1728        let broken = ToolValidation::Attempted(vec![ToolOutcome::Failed {
1729            tool: "gofmt",
1730            reason: "gofmt: could not be executed: permission denied".into(),
1731        }]);
1732
1733        assert!(!broken.fully_checked());
1734        let failures = broken
1735            .into_result_with_strictness(false)
1736            .expect_err("a broken harness is never a pass");
1737        assert!(failures[0].contains("gofmt"));
1738    }
1739
1740    #[test]
1741    fn strict_mode_reads_the_documented_environment_variable() {
1742        // Guard: the constant and the docs must not drift apart.
1743        assert_eq!(STRICT_ENV_VAR, "SCYTHE_VALIDATE_STRICT");
1744    }
1745}