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