1use std::process::Command;
2
3pub 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
117fn 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
149fn 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
393pub 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 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}