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        "go-pgx" | "go-database-sql" | "go-godror" | "go-gosnowflake" => validate_go(code),
21        "java-jdbc" => validate_java(code),
22        "java-r2dbc" => validate_java_r2dbc(code),
23        "kotlin-exposed" => validate_kotlin_exposed(code),
24        "kotlin-jdbc" => validate_kotlin(code),
25        "kotlin-r2dbc" => validate_kotlin_r2dbc(code),
26        "csharp-npgsql"
27        | "csharp-mysqlconnector"
28        | "csharp-microsoft-sqlite"
29        | "csharp-sqlclient"
30        | "csharp-oracle"
31        | "csharp-snowflake" => validate_csharp(code),
32        "elixir-postgrex" | "elixir-ecto" | "elixir-myxql" | "elixir-exqlite" | "elixir-tds" | "elixir-jamdb" => {
33            validate_elixir(code)
34        }
35        "ruby-pg" | "ruby-mysql2" | "ruby-sqlite3" | "ruby-trilogy" | "ruby-oci8" | "ruby-tiny-tds" => {
36            validate_ruby(code)
37        }
38        "php-pdo" | "php-amphp" => validate_php(code),
39        "rust-sqlx" | "rust-tokio-postgres" | "rust-tiberius" | "rust-sibyl" => vec![],
40        _ => vec![format!("unknown backend: {}", backend_name)],
41    }
42}
43
44fn validate_python(code: &str) -> Vec<String> {
45    let mut errors = Vec::new();
46
47    if code.contains("from __future__ import annotations") {
48        errors.push("unnecessary `from __future__ import annotations` — target is Python 3.10+".into());
49    }
50
51    let has_struct = code.contains("@dataclass")
52        || code.contains("(BaseModel)")
53        || code.contains("(msgspec.Struct)")
54        || code.contains("class ");
55    if !has_struct && !code.contains("async def ") && !code.contains("def ") {
56        errors.push("missing `@dataclass`/`class` and `def ` -- no meaningful output".into());
57    }
58
59    if !code.contains("async def ") && !code.contains("def ") {
60        errors.push("missing `async def ` or `def ` (for query functions)".into());
61    }
62
63    if code.contains("from typing import Union") {
64        errors.push("contains `from typing import Union` (pre-3.10 style)".into());
65    }
66
67    if code.contains("from typing import Optional") {
68        errors.push("contains `from typing import Optional` (pre-3.10 style)".into());
69    }
70
71    if code.contains("List[") {
72        errors.push("contains `List[` (use lowercase `list[`)".into());
73    }
74
75    if code.contains("Dict[") {
76        errors.push("contains `Dict[` (use lowercase `dict[`)".into());
77    }
78
79    for (i, line) in code.lines().enumerate() {
80        if line.starts_with('\t') {
81            errors.push(format!("line {} uses tab indentation (should use 4 spaces)", i + 1));
82            break;
83        }
84    }
85
86    errors
87}
88
89fn validate_typescript(code: &str) -> Vec<String> {
90    let mut errors = Vec::new();
91
92    let has_function = code.contains("export async function") || code.contains("export function");
93
94    let has_zod = code.contains("z.object(") || code.contains("z.infer<");
95    if !code.contains("export interface") && !code.contains("export type") && !has_zod && !has_function {
96        errors.push("missing `export interface` or `export type` (for DTOs)".into());
97    }
98
99    if !has_function {
100        errors.push("missing `export async function` or `export function`".into());
101    }
102
103    for line in code.lines() {
104        let trimmed = line.trim();
105        if find_disallowed_any_usage(trimmed).is_some() {
106            errors.push(format!(
107                "contains `any` type (should use `unknown` or specific): {}",
108                trimmed
109            ));
110            break;
111        }
112    }
113
114    errors
115}
116
117/// Scan a line of generated TypeScript for a standalone `any` type token.
118///
119/// This is token-aware (matches `any` at identifier boundaries) rather than
120/// the previous fixed set of punctuation suffixes (`: any`, `<any>`, `any;`,
121/// `any,`, `any)`), which happened to omit `any>` and let the Kysely
122/// backend's `<DB = any>` generic-default slip through undetected rather
123/// than deliberately allowed.
124///
125/// The Kysely `<DB = any>` idiom is explicitly allowlisted here: `DB` is a
126/// real generic type parameter threaded through `Kysely<DB>` in the
127/// function signature, not an escape hatch. A caller who supplies
128/// `Kysely<MyDB>` gets full column typing; the `= any` default only lets
129/// callers who don't care about DB-shape typing omit the type argument.
130fn find_disallowed_any_usage(trimmed: &str) -> Option<&str> {
131    let mut offset = 0;
132    while let Some(rel) = trimmed[offset..].find("any") {
133        let start = offset + rel;
134        let end = start + "any".len();
135        let before_is_ident = start > 0 && is_ident_byte(trimmed.as_bytes()[start - 1]);
136        let after_is_ident = end < trimmed.len() && is_ident_byte(trimmed.as_bytes()[end]);
137        if !before_is_ident && !after_is_ident && !is_kysely_db_generic_default(trimmed, start, end) {
138            return Some(trimmed);
139        }
140        offset = end;
141    }
142    None
143}
144
145fn is_ident_byte(b: u8) -> bool {
146    b.is_ascii_alphanumeric() || b == b'_'
147}
148
149/// True when the `any` token spanning `[start, end)` in `line` is the
150/// default of a `<DB = any>` generic type parameter, e.g.
151/// `export async function foo<DB = any>(db: Kysely<DB>): ... {`.
152fn is_kysely_db_generic_default(line: &str, start: usize, end: usize) -> bool {
153    line[..start].ends_with("<DB = ") && line[end..].starts_with('>')
154}
155
156fn validate_go(code: &str) -> Vec<String> {
157    let mut errors = Vec::new();
158
159    let has_func = code.contains("func ");
160    let has_struct = code.contains("type ") && code.contains("struct {");
161
162    if !has_struct && !has_func {
163        errors.push("missing `type ... struct {` (for structs)".into());
164    }
165
166    if !has_func {
167        errors.push("missing `func ` (for functions)".into());
168    }
169
170    if !code.contains("context.Context") {
171        errors.push("missing `context.Context` as first param".into());
172    }
173
174    let has_indented_lines = code.lines().any(|l| l.starts_with('\t') || l.starts_with("  "));
175    if has_indented_lines {
176        let uses_spaces = code.lines().any(|l| l.starts_with("    ") && !l.trim().is_empty());
177        if uses_spaces {
178            errors.push("uses space indentation (Go standard is tabs)".into());
179        }
180    }
181
182    if has_struct && !code.contains("json:\"") {
183        errors.push("missing `json:\"` tags on struct fields".into());
184    }
185
186    errors
187}
188
189fn validate_java(code: &str) -> Vec<String> {
190    let mut errors = Vec::new();
191
192    let has_static = code.contains("public static ");
193
194    if !code.contains("public record ") && !has_static {
195        errors.push("missing `public record ` (for DTOs)".into());
196    }
197
198    if !has_static {
199        errors.push("missing `public static ` (for query methods)".into());
200    }
201
202    if !code.contains("throws SQLException") {
203        errors.push("missing `throws SQLException`".into());
204    }
205
206    if !code.contains("try (") {
207        errors.push("missing `try (` (try-with-resources)".into());
208    }
209
210    errors
211}
212
213fn validate_kotlin(code: &str) -> Vec<String> {
214    let mut errors = Vec::new();
215
216    let has_fun = code.contains("fun ");
217
218    if !code.contains("data class ") && !has_fun {
219        errors.push("missing `data class ` (for DTOs)".into());
220    }
221
222    if !has_fun {
223        errors.push("missing `fun ` (for functions)".into());
224    }
225
226    if !code.contains(".use {") {
227        errors.push("missing `.use {` (resource management)".into());
228    }
229
230    errors
231}
232
233fn validate_kotlin_exposed(code: &str) -> Vec<String> {
234    let mut errors = Vec::new();
235
236    let has_fun = code.contains("fun ");
237
238    if !code.contains("data class ") && !code.contains("object ") && !has_fun {
239        errors.push("missing `data class ` or `object ` (for DTOs/Tables)".into());
240    }
241
242    if !has_fun {
243        errors.push("missing `fun ` (for functions)".into());
244    }
245
246    if !code.contains("transaction {") {
247        errors.push("missing `transaction {` (Exposed transaction block)".into());
248    }
249
250    errors
251}
252
253fn validate_java_r2dbc(code: &str) -> Vec<String> {
254    let mut errors = Vec::new();
255
256    let has_static = code.contains("public static ");
257
258    if !code.contains("public record ") && !has_static {
259        errors.push("missing `public record ` (for DTOs)".into());
260    }
261
262    if !has_static {
263        errors.push("missing `public static ` (for query methods)".into());
264    }
265
266    if !code.contains("Mono<") && !code.contains("Flux<") {
267        errors.push("missing `Mono<` or `Flux<` (reactive types)".into());
268    }
269
270    if !code.contains("ConnectionFactory") {
271        errors.push("missing `ConnectionFactory` parameter".into());
272    }
273
274    errors
275}
276
277fn validate_kotlin_r2dbc(code: &str) -> Vec<String> {
278    let mut errors = Vec::new();
279
280    let has_fun = code.contains("fun ");
281
282    if !code.contains("data class ") && !has_fun {
283        errors.push("missing `data class ` (for DTOs)".into());
284    }
285
286    if !has_fun {
287        errors.push("missing `fun ` (for functions)".into());
288    }
289
290    if !code.contains("ConnectionFactory") {
291        errors.push("missing `ConnectionFactory` parameter".into());
292    }
293
294    if !code.contains("suspend fun") && !code.contains("Flow<") {
295        errors.push("missing `suspend fun` or `Flow<` (coroutine/reactive types)".into());
296    }
297
298    errors
299}
300
301fn validate_csharp(code: &str) -> Vec<String> {
302    let mut errors = Vec::new();
303
304    let has_async = code.contains("async Task<") || code.contains("async Task ");
305
306    if !code.contains("public record ") && !has_async {
307        errors.push("missing `public record ` (for DTOs)".into());
308    }
309
310    if !has_async {
311        errors.push("missing `async Task<` or `async Task` (for async methods)".into());
312    }
313
314    if !code.contains("await ") {
315        errors.push("missing `await `".into());
316    }
317
318    errors
319}
320
321fn validate_elixir(code: &str) -> Vec<String> {
322    let mut errors = Vec::new();
323
324    let has_def = code.contains("def ") || code.contains("defp ");
325
326    if !code.contains("defmodule ") && !has_def {
327        errors.push("missing `defmodule ` (for modules)".into());
328    }
329
330    if !code.contains("defstruct") && !has_def {
331        errors.push("missing `defstruct` (for structs)".into());
332    }
333
334    if !has_def {
335        errors.push("missing `def ` or `defp ` (for functions)".into());
336    }
337
338    if !code.contains("@type ") && !code.contains("@spec ") {
339        errors.push("missing `@type ` or `@spec ` (for typespecs)".into());
340    }
341
342    errors
343}
344
345fn validate_ruby(code: &str) -> Vec<String> {
346    let mut errors = Vec::new();
347
348    let has_method = code.contains("def self.");
349
350    if !code.contains("Data.define") && !has_method {
351        errors.push("missing `Data.define` (for DTOs)".into());
352    }
353
354    if !has_method {
355        errors.push("missing `def self.` (for module methods)".into());
356    }
357
358    if !code.contains("# frozen_string_literal: true") {
359        errors.push("missing `# frozen_string_literal: true`".into());
360    }
361
362    if !code.contains("module Queries") {
363        errors.push("missing `module Queries` wrapper".into());
364    }
365
366    errors
367}
368
369fn validate_php(code: &str) -> Vec<String> {
370    let mut errors = Vec::new();
371
372    let has_function = code.contains("function ");
373
374    if !code.contains("readonly class ") && !has_function {
375        errors.push("missing `readonly class ` (for DTOs)".into());
376    }
377
378    if !has_function {
379        errors.push("missing `function ` (for query functions)".into());
380    }
381
382    if !code.contains("declare(strict_types=1)") {
383        errors.push("missing `declare(strict_types=1)`".into());
384    }
385
386    if !code.contains("<?php") {
387        errors.push("missing `<?php`".into());
388    }
389
390    errors
391}
392
393/// Validate generated code using real language tools (if available).
394/// Returns None if the tool is not installed, Some(errors) otherwise.
395pub fn validate_with_tools(code: &str, backend_name: &str) -> Option<Vec<String>> {
396    match backend_name {
397        name if name.starts_with("python") => validate_python_tools(code),
398        name if name.starts_with("typescript") => validate_typescript_tools(code),
399        name if name.starts_with("go") => validate_go_tools(code),
400        name if name.starts_with("ruby") => validate_ruby_tools(code),
401        name if name.starts_with("php") => validate_php_tools(code),
402        name if name.starts_with("kotlin") => validate_kotlin_tools(code),
403        _ => None,
404    }
405}
406
407fn write_temp(code: &str, ext: &str) -> Option<std::path::PathBuf> {
408    use std::sync::atomic::{AtomicU64, Ordering};
409    static COUNTER: AtomicU64 = AtomicU64::new(0);
410    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
411    let basename = if ext == ".kt" {
412        format!("ScytheValidate{n}")
413    } else {
414        format!("scythe_validate_{n}")
415    };
416    let path = std::env::temp_dir().join(format!("{basename}{ext}"));
417    let trimmed = format!("{}\n", code.trim_end());
418    std::fs::write(&path, trimmed).ok()?;
419    Some(path)
420}
421
422fn validate_python_tools(code: &str) -> Option<Vec<String>> {
423    if Command::new("python3").arg("--version").output().is_err() {
424        return None;
425    }
426    let path = write_temp(code, ".py")?;
427    let mut errors = vec![];
428
429    let out = Command::new("python3")
430        .args(["-c", &format!("import ast; ast.parse(open({:?}).read())", path)])
431        .output()
432        .ok()?;
433    if !out.status.success() {
434        errors.push(format!(
435            "python syntax: {}",
436            String::from_utf8_lossy(&out.stderr).lines().next().unwrap_or("")
437        ));
438    }
439
440    if Command::new("ruff").arg("--version").output().is_ok() {
441        let out = Command::new("ruff")
442            .args([
443                "check",
444                "--select",
445                "E,F,I",
446                "--target-version",
447                "py310",
448                path.to_str()?,
449            ])
450            .output()
451            .ok()?;
452        if !out.status.success() {
453            for line in String::from_utf8_lossy(&out.stdout).lines().take(3) {
454                if !line.trim().is_empty() {
455                    errors.push(format!("ruff: {line}"));
456                }
457            }
458        }
459    }
460
461    let _ = std::fs::remove_file(&path);
462    Some(errors)
463}
464
465fn validate_typescript_tools(code: &str) -> Option<Vec<String>> {
466    if Command::new("biome").arg("--version").output().is_err() {
467        return None;
468    }
469    let path = write_temp(code, ".ts")?;
470    let mut errors = vec![];
471
472    let out = Command::new("biome")
473        .args(["check", "--no-errors-on-unmatched", path.to_str()?])
474        .output()
475        .ok()?;
476    if !out.status.success() {
477        for line in String::from_utf8_lossy(&out.stderr).lines().take(3) {
478            if !line.trim().is_empty() {
479                errors.push(format!("biome: {line}"));
480            }
481        }
482    }
483
484    let _ = std::fs::remove_file(&path);
485    Some(errors)
486}
487
488fn validate_go_tools(code: &str) -> Option<Vec<String>> {
489    if Command::new("gofmt").arg("-h").output().is_err() {
490        return None;
491    }
492    let path = write_temp(code, ".go")?;
493    let mut errors = vec![];
494
495    let out = Command::new("gofmt").args(["-e", path.to_str()?]).output().ok()?;
496    if !out.status.success() {
497        for line in String::from_utf8_lossy(&out.stderr).lines().take(3) {
498            if !line.trim().is_empty() {
499                errors.push(format!("gofmt: {line}"));
500            }
501        }
502    }
503
504    let _ = std::fs::remove_file(&path);
505    Some(errors)
506}
507
508fn validate_ruby_tools(code: &str) -> Option<Vec<String>> {
509    if Command::new("ruby").arg("--version").output().is_err() {
510        return None;
511    }
512    let path = write_temp(code, ".rb")?;
513    let mut errors = vec![];
514
515    let out = Command::new("ruby").args(["-c", path.to_str()?]).output().ok()?;
516    if !out.status.success() {
517        errors.push(format!(
518            "ruby syntax: {}",
519            String::from_utf8_lossy(&out.stderr).lines().next().unwrap_or("")
520        ));
521    }
522
523    let _ = std::fs::remove_file(&path);
524    Some(errors)
525}
526
527fn validate_php_tools(code: &str) -> Option<Vec<String>> {
528    if Command::new("php").arg("--version").output().is_err() {
529        return None;
530    }
531    let path = write_temp(code, ".php")?;
532    let mut errors = vec![];
533
534    let out = Command::new("php").args(["-l", path.to_str()?]).output().ok()?;
535    if !out.status.success() {
536        errors.push(format!(
537            "php syntax: {}",
538            String::from_utf8_lossy(&out.stdout).lines().next().unwrap_or("")
539        ));
540    }
541
542    let _ = std::fs::remove_file(&path);
543    Some(errors)
544}
545
546fn validate_kotlin_tools(code: &str) -> Option<Vec<String>> {
547    if Command::new("ktlint").arg("--version").output().is_err() {
548        return None;
549    }
550    let path = write_temp(code, ".kt")?;
551    let mut errors = vec![];
552
553    let out = Command::new("ktlint")
554        .args(["--log-level=error", path.to_str()?])
555        .output()
556        .ok()?;
557    if !out.status.success() {
558        for line in String::from_utf8_lossy(&out.stdout).lines().take(3) {
559            if !line.trim().is_empty() {
560                errors.push(format!("ktlint: {line}"));
561            }
562        }
563    }
564
565    let _ = std::fs::remove_file(&path);
566    Some(errors)
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    #[test]
574    fn test_unknown_backend() {
575        let errors = validate_structural("some code", "unknown-backend");
576        assert_eq!(errors.len(), 1);
577        assert!(errors[0].contains("unknown backend"));
578    }
579
580    #[test]
581    fn test_rust_backends_skip() {
582        assert!(validate_structural("anything", "rust-sqlx").is_empty());
583        assert!(validate_structural("anything", "rust-tokio-postgres").is_empty());
584    }
585
586    #[test]
587    fn test_python_valid() {
588        let code = r#"from dataclasses import dataclass
589
590@dataclass
591class ListUsersRow:
592    id: int
593    name: str
594
595async def list_users(conn) -> list[ListUsersRow]:
596    pass
597"#;
598        let errors = validate_structural(code, "python-psycopg3");
599        assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
600    }
601
602    #[test]
603    fn test_python_invalid_typing() {
604        let code = r#"from typing import Optional
605
606@dataclass
607class Row:
608    id: int
609
610def query() -> List[Row]:
611    pass
612"#;
613        let errors = validate_structural(code, "python-asyncpg");
614        assert!(errors.iter().any(|e| e.contains("Optional")));
615        assert!(errors.iter().any(|e| e.contains("List[")));
616    }
617
618    #[test]
619    fn test_typescript_valid() {
620        let code = r#"export interface ListUsersRow {
621  id: number;
622  name: string;
623}
624
625export async function listUsers(): Promise<ListUsersRow[]> {
626  // ...
627}
628"#;
629        let errors = validate_structural(code, "typescript-postgres");
630        assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
631    }
632
633    #[test]
634    fn test_typescript_allows_kysely_db_generic_default() {
635        let code = r#"export async function listUsers<DB = any>(db: Kysely<DB>): Promise<ListUsersRow[]> {
636  return [];
637}
638"#;
639        let errors = validate_structural(code, "typescript-kysely");
640        assert!(
641            errors.is_empty(),
642            "the `<DB = any>` generic default must be allowlisted, got: {:?}",
643            errors
644        );
645    }
646
647    #[test]
648    fn test_typescript_rejects_any_before_closing_angle_bracket() {
649        // Regression test: the old suffix-list check (`: any`, `<any>`,
650        // `any;`, `any,`, `any)`) never looked for `any` immediately before
651        // `>`, so `Array<any>` (or any other non-Kysely-DB `any>` usage)
652        // slipped through undetected.
653        let code = r#"export async function listUsers(): Promise<Array<any>> {
654  return [];
655}
656"#;
657        let errors = validate_structural(code, "typescript-postgres");
658        assert!(
659            errors.iter().any(|e| e.contains("any")),
660            "expected an `any` violation, got: {:?}",
661            errors
662        );
663    }
664
665    #[test]
666    fn test_go_valid() {
667        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";
668        let errors = validate_structural(code, "go-pgx");
669        assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
670    }
671
672    #[test]
673    fn test_php_valid() {
674        let code = r#"<?php
675
676declare(strict_types=1);
677
678readonly class ListUsersRow {
679    public function __construct(
680        public int $id,
681        public string $name,
682    ) {}
683}
684
685function listUsers($pdo): array {
686    return [];
687}
688"#;
689        let errors = validate_structural(code, "php-pdo");
690        assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
691    }
692}