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        // Kotlin has no validator: `poly` delegates Kotlin to `ktlint` rather
741        // than bundling it, and standing up a JVM plus a downloaded jar in CI
742        // to lint generated Kotlin is out of proportion to what it catches.
743        // `validate_structural` still covers these backends, and the
744        // inventory test in `tests/tool_validation.rs` keeps the gap visible.
745        _ => return ToolValidation::Unsupported,
746    };
747
748    ToolValidation::Attempted(outcomes)
749}
750
751fn write_temp(code: &str, ext: &str) -> Option<std::path::PathBuf> {
752    use std::sync::atomic::{AtomicU64, Ordering};
753    static COUNTER: AtomicU64 = AtomicU64::new(0);
754    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
755    let basename = if ext == ".kt" {
756        format!("ScytheValidate{n}")
757    } else {
758        format!("scythe_validate_{n}")
759    };
760    let path = std::env::temp_dir().join(format!("{basename}{ext}"));
761    let trimmed = format!("{}\n", code.trim_end());
762    std::fs::write(&path, trimmed).ok()?;
763    Some(path)
764}
765
766/// Run `poly` against a generated file.
767///
768/// `poly` is this repository's linter and formatter, and it bundles the
769/// engines it needs in-process: `oxc` for JavaScript and TypeScript, `ruff`
770/// for Python, `mago` for PHP. One already-required binary therefore replaces
771/// three separately-installed ones -- `biome`, `ruff` and `php` -- and
772/// because it parses as well as lints, it also subsumes the `python3 -m ast`
773/// syntax pass.
774///
775/// `--no-workspace` is not optional. Without it `poly lint` also runs the
776/// whole-project tier -- `cargo clippy` over this very workspace -- once per
777/// generated file, turning a millisecond check into minutes.
778///
779/// `poly` exits non-zero only on error-severity findings, so warnings appear
780/// in the message without failing the check. That is the same threshold the
781/// repository's own `poly lint .` gate uses.
782fn poly_check(code: &str, ext: &str) -> ToolOutcome {
783    let config = generated_code_poly_config();
784    check_with(
785        "poly",
786        "--version",
787        code,
788        ext,
789        |path| {
790            ["lint", "--no-workspace", "--config", &config.to_string_lossy(), path]
791                .iter()
792                .map(|arg| (*arg).to_string())
793                .collect()
794        },
795        Stream::Both,
796        6,
797    )
798}
799
800/// Path to the poly config used for generated code.
801///
802/// Passed explicitly rather than left to discovery: poly resolves config by
803/// walking up from the file it is given, and the file here is a temporary one
804/// outside the repository, so poly would find nothing and fall back to its
805/// built-in defaults. What CI enforced would then depend on where the system
806/// temp directory happens to sit. See the config file's own header for why it
807/// differs from the repository's `poly.toml`.
808fn generated_code_poly_config() -> std::path::PathBuf {
809    std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/generated-code-poly.toml")).to_path_buf()
810}
811
812/// `poly`'s bundled `ruff`, which reports syntax errors and the `E`/`F`/`I`
813/// rule families -- the same ground the previous `python3 -m ast` plus
814/// standalone `ruff` pair covered, in one tool that needs no install step.
815fn validate_python_tools(code: &str) -> Vec<ToolOutcome> {
816    vec![poly_check(code, ".py")]
817}
818
819/// `poly`'s bundled `oxc`.
820///
821/// This was `biome check` until #98 installed biome and discovered the choice
822/// had never been tested: `check` also runs the formatter, whose only
823/// complaints about generated code are that scythe indents with spaces where
824/// biome would use tabs, and where it would break a line. Generated-code
825/// layout is the backends' contract and `scythe fmt` owns it, so running the
826/// formatter here failed 8 TypeScript backends purely on indentation.
827///
828/// `poly` lints with `oxc` and does not impose a formatter, so it keeps what
829/// is worth knowing -- unused bindings, unsafe casts, `useLiteralKeys` and the
830/// rest of the correctness rules -- and drops a third-party install entirely.
831fn validate_typescript_tools(code: &str) -> Vec<ToolOutcome> {
832    vec![poly_check(code, ".ts")]
833}
834
835/// Path to the hand-written ambient `.d.ts` stubs for the driver packages
836/// (`pg`, `postgres`, `mysql2/promise`, `better-sqlite3`) the
837/// `javascript-*` (JSDoc emit mode, #81) backends reference via
838/// `import("pkg").Type` JSDoc annotations.
839///
840/// See that file's own header comment for why these are hand-written
841/// stand-ins rather than the real `@types/*` packages (no `npm install` at
842/// test time -- this must stay hermetic and offline-safe).
843fn js_mode_driver_stub_path() -> std::path::PathBuf {
844    std::path::Path::new(concat!(
845        env!("CARGO_MANIFEST_DIR"),
846        "/tests/js_mode_stubs/driver-stubs.d.ts"
847    ))
848    .to_path_buf()
849}
850
851/// Validate generated `javascript-*` (JSDoc emit mode, #81) code against the
852/// real `node` runtime and, when available, real `tsc --checkJs --strict`.
853///
854/// #81's verification requirement is specifically "runs under `node`, not
855/// `tsx`, plus `tsc --checkJs --strict`", so both are reported as separate
856/// [`ToolOutcome`]s: a machine with `node` but no `tsc` has verified that the
857/// generated source parses, and verified nothing at all about whether its
858/// JSDoc annotations typecheck.
859///
860/// Writes the temp file with a `.mjs` extension rather than `.js`: `node`'s
861/// ESM-vs-CommonJS auto-detection for a bare `.js` file depends on Node
862/// version and on whether some enclosing directory happens to have a
863/// `package.json` with `"type": "module"` -- both outside this test's
864/// control, and both irrelevant to what's actually being verified (that the
865/// generated *source* is valid ESM). `.mjs` is an unambiguous, version-
866/// independent signal that sidesteps that entirely.
867fn validate_javascript_tools(code: &str) -> Vec<ToolOutcome> {
868    // Real `node`, not `tsx`/`ts-node`/a build step: the generated file must
869    // parse as plain ESM as-is. `--check` parses without executing,
870    // mirroring the `ruby -c` / `php -l` precedent in this file.
871    let parse = check_with(
872        "node",
873        "--version",
874        code,
875        ".mjs",
876        |path| ["--check", path].iter().map(|arg| (*arg).to_string()).collect(),
877        Stream::Stderr,
878        5,
879    );
880
881    let stub = js_mode_driver_stub_path();
882    let typecheck = check_with(
883        "tsc",
884        "--version",
885        code,
886        ".mjs",
887        |path| {
888            let mut args: Vec<String> = [
889                "--checkJs",
890                "--strict",
891                "--allowJs",
892                "--noEmit",
893                "--module",
894                "nodenext",
895                "--moduleResolution",
896                "nodenext",
897                "--target",
898                "es2022",
899            ]
900            .iter()
901            .map(|arg| (*arg).to_string())
902            .collect();
903            args.push(path.to_string());
904            args.push(stub.to_string_lossy().into_owned());
905            args
906        },
907        // tsc writes diagnostics to stdout by default.
908        Stream::Stdout,
909        10,
910    );
911
912    vec![parse, typecheck]
913}
914
915fn validate_go_tools(code: &str) -> Vec<ToolOutcome> {
916    vec![check_with(
917        "gofmt",
918        "-h",
919        code,
920        ".go",
921        |path| ["-e", path].iter().map(|arg| (*arg).to_string()).collect(),
922        Stream::Stderr,
923        3,
924    )]
925}
926
927fn validate_ruby_tools(code: &str) -> Vec<ToolOutcome> {
928    vec![check_with(
929        "ruby",
930        "--version",
931        code,
932        ".rb",
933        |path| ["-c", path].iter().map(|arg| (*arg).to_string()).collect(),
934        Stream::Stderr,
935        1,
936    )]
937}
938
939/// `poly`'s bundled `mago`, which parses PHP -- the same thing `php -l` did,
940/// without needing a PHP runtime installed.
941fn validate_php_tools(code: &str) -> Vec<ToolOutcome> {
942    vec![poly_check(code, ".php")]
943}
944
945#[cfg(test)]
946mod tests {
947    use super::*;
948
949    #[test]
950    fn test_unknown_backend() {
951        let errors = validate_structural("some code", "unknown-backend");
952        assert_eq!(errors.len(), 1);
953        assert!(errors[0].contains("unknown backend"));
954    }
955
956    #[test]
957    fn test_rust_backends_skip() {
958        assert!(validate_structural("anything", "rust-sqlx").is_empty());
959        assert!(validate_structural("anything", "rust-tokio-postgres").is_empty());
960    }
961
962    #[test]
963    fn test_python_valid() {
964        let code = r#"from dataclasses import dataclass
965
966@dataclass
967class ListUsersRow:
968    id: int
969    name: str
970
971async def list_users(conn) -> list[ListUsersRow]:
972    pass
973"#;
974        let errors = validate_structural(code, "python-psycopg3");
975        assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
976    }
977
978    #[test]
979    fn test_python_invalid_typing() {
980        let code = r#"from typing import Optional
981
982@dataclass
983class Row:
984    id: int
985
986def query() -> List[Row]:
987    pass
988"#;
989        let errors = validate_structural(code, "python-asyncpg");
990        assert!(errors.iter().any(|e| e.contains("Optional")));
991        assert!(errors.iter().any(|e| e.contains("List[")));
992    }
993
994    #[test]
995    fn test_typescript_valid() {
996        let code = r#"export interface ListUsersRow {
997  id: number;
998  name: string;
999}
1000
1001export async function listUsers(): Promise<ListUsersRow[]> {
1002  // ...
1003}
1004"#;
1005        let errors = validate_structural(code, "typescript-postgres");
1006        assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
1007    }
1008
1009    #[test]
1010    fn test_typescript_allows_kysely_db_generic_default() {
1011        let code = r#"export async function listUsers<DB = any>(db: Kysely<DB>): Promise<ListUsersRow[]> {
1012  return [];
1013}
1014"#;
1015        let errors = validate_structural(code, "typescript-kysely");
1016        assert!(
1017            errors.is_empty(),
1018            "the `<DB = any>` generic default must be allowlisted, got: {:?}",
1019            errors
1020        );
1021    }
1022
1023    #[test]
1024    fn test_typescript_rejects_any_before_closing_angle_bracket() {
1025        // Regression test: the old suffix-list check (`: any`, `<any>`,
1026        // `any;`, `any,`, `any)`) never looked for `any` immediately before
1027        // `>`, so `Array<any>` (or any other non-Kysely-DB `any>` usage)
1028        // slipped through undetected.
1029        let code = r#"export async function listUsers(): Promise<Array<any>> {
1030  return [];
1031}
1032"#;
1033        let errors = validate_structural(code, "typescript-postgres");
1034        assert!(
1035            errors.iter().any(|e| e.contains("any")),
1036            "expected an `any` violation, got: {:?}",
1037            errors
1038        );
1039    }
1040
1041    #[test]
1042    fn test_go_valid() {
1043        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";
1044        let errors = validate_structural(code, "go-pgx");
1045        assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
1046    }
1047
1048    #[test]
1049    fn test_php_valid() {
1050        let code = r#"<?php
1051
1052declare(strict_types=1);
1053
1054readonly class ListUsersRow {
1055    public function __construct(
1056        public int $id,
1057        public string $name,
1058    ) {}
1059}
1060
1061function listUsers($pdo): array {
1062    return [];
1063}
1064"#;
1065        let errors = validate_structural(code, "php-pdo");
1066        assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
1067    }
1068
1069    /// The bug this whole type exists to prevent: before `ToolValidation`,
1070    /// a tool that was never installed and a tool that ran and found nothing
1071    /// both arrived at the call site as "no errors", so an uninstalled linter
1072    /// read as a green check mark.
1073    #[test]
1074    fn a_missing_tool_is_not_the_same_as_a_clean_run() {
1075        let clean = ToolValidation::Attempted(vec![ToolOutcome::Ran {
1076            tool: "ruff",
1077            errors: vec![],
1078        }]);
1079        let absent = ToolValidation::Attempted(vec![ToolOutcome::Missing { tool: "ruff" }]);
1080
1081        // Both report zero findings -- that is exactly why the old
1082        // `Option<Vec<String>>` could not tell them apart.
1083        assert!(clean.errors().is_empty());
1084        assert!(absent.errors().is_empty());
1085
1086        // The type still distinguishes them.
1087        assert!(clean.fully_checked(), "a tool that ran is a real check");
1088        assert!(!absent.fully_checked(), "a tool that never ran checked nothing");
1089        assert_eq!(absent.missing_tools(), vec!["ruff"]);
1090        assert!(clean.missing_tools().is_empty());
1091    }
1092
1093    #[test]
1094    fn strict_mode_turns_a_missing_tool_into_a_failure() {
1095        let absent = ToolValidation::Attempted(vec![ToolOutcome::Missing { tool: "poly" }]);
1096
1097        assert!(
1098            absent.clone().into_result_with_strictness(false).is_ok(),
1099            "outside strict mode a missing tool is tolerated"
1100        );
1101
1102        let failures = absent
1103            .into_result_with_strictness(true)
1104            .expect_err("strict mode must fail on a missing tool");
1105        assert_eq!(failures.len(), 1);
1106        assert!(
1107            failures[0].contains("poly") && failures[0].contains("not installed"),
1108            "the failure must name the tool and say it never ran: {failures:?}"
1109        );
1110    }
1111
1112    /// A validator that runs two tools and finds only one of them has
1113    /// verified half of what it claims to. `validate_python_tools` shipped
1114    /// exactly this shape -- `python3` present, `ruff` absent, `Some([])`
1115    /// returned -- which is the strongest possible false signal.
1116    #[test]
1117    fn a_partially_run_validation_is_not_fully_checked() {
1118        let partial = ToolValidation::Attempted(vec![
1119            ToolOutcome::Ran {
1120                tool: "python3",
1121                errors: vec![],
1122            },
1123            ToolOutcome::Missing { tool: "ruff" },
1124        ]);
1125
1126        assert!(partial.errors().is_empty());
1127        assert!(!partial.fully_checked(), "one of two tools running is not a full check");
1128        assert_eq!(partial.tools_run(), vec!["python3"]);
1129        assert_eq!(partial.missing_tools(), vec!["ruff"]);
1130        assert!(partial.into_result_with_strictness(true).is_err());
1131    }
1132
1133    /// The languages `poly` bundles an engine for are checked by `poly` alone
1134    /// -- one binary, no per-language install. A regression that reintroduced
1135    /// a separate `ruff`/`biome`/`php` invocation would show up here as an
1136    /// extra outcome.
1137    #[test]
1138    fn poly_backed_languages_report_exactly_one_tool() {
1139        for (label, outcomes) in [
1140            ("python", validate_python_tools("x = 1\n")),
1141            ("typescript", validate_typescript_tools("export const x = 1;\n")),
1142            ("php", validate_php_tools("<?php\ndeclare(strict_types=1);\n")),
1143        ] {
1144            assert_eq!(outcomes.len(), 1, "{label} must be checked by poly alone");
1145            let tool = match &outcomes[0] {
1146                ToolOutcome::Ran { tool, .. } | ToolOutcome::Missing { tool } | ToolOutcome::Failed { tool, .. } => {
1147                    *tool
1148                }
1149            };
1150            assert_eq!(tool, "poly", "{label} must go through poly");
1151        }
1152    }
1153
1154    /// A language with no validator must not masquerade as a clean pass
1155    /// either -- `_ => None` previously made Java, C#, Elixir and Rust
1156    /// unconditionally green.
1157    #[test]
1158    fn a_backend_with_no_validator_is_unsupported_not_clean() {
1159        let validation = validate_with_tools("public class Foo {}", "java-jdbc");
1160
1161        assert_eq!(validation, ToolValidation::Unsupported);
1162        assert!(validation.errors().is_empty());
1163        assert!(!validation.fully_checked(), "no validator means nothing was checked");
1164        assert!(validation.clone().into_result_with_strictness(false).is_ok());
1165        assert!(
1166            validation.into_result_with_strictness(true).is_ok(),
1167            "strict mode is for tools that can be installed; a language with no \
1168             validator is tracked by an inventory test instead -- see \
1169             into_result_with_strictness"
1170        );
1171    }
1172
1173    /// A harness that cannot run an installed tool is a failure in both
1174    /// modes. The old code reached these paths through `?` on an `Option`
1175    /// and reported them as "tool not installed", which then read as a pass.
1176    #[test]
1177    fn a_harness_failure_is_an_error_even_outside_strict_mode() {
1178        let broken = ToolValidation::Attempted(vec![ToolOutcome::Failed {
1179            tool: "gofmt",
1180            reason: "gofmt: could not be executed: permission denied".into(),
1181        }]);
1182
1183        assert!(!broken.fully_checked());
1184        let failures = broken
1185            .into_result_with_strictness(false)
1186            .expect_err("a broken harness is never a pass");
1187        assert!(failures[0].contains("gofmt"));
1188    }
1189
1190    #[test]
1191    fn strict_mode_reads_the_documented_environment_variable() {
1192        // Guard: the constant and the docs must not drift apart.
1193        assert_eq!(STRICT_ENV_VAR, "SCYTHE_VALIDATE_STRICT");
1194    }
1195}