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 "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
99fn 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
166fn 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
198fn 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
442pub const STRICT_ENV_VAR: &str = "SCYTHE_VALIDATE_STRICT";
448
449pub 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#[derive(Debug, Clone, PartialEq, Eq)]
475pub enum ToolOutcome {
476 Ran {
479 tool: &'static str,
481 errors: Vec<String>,
483 },
484 Missing {
487 tool: &'static str,
489 },
490 Failed {
498 tool: &'static str,
500 reason: String,
502 },
503}
504
505#[derive(Debug, Clone, PartialEq, Eq)]
507pub enum ToolValidation {
508 Unsupported,
514 Attempted(Vec<ToolOutcome>),
516}
517
518impl ToolValidation {
519 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 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 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 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 pub fn into_result(self) -> Result<(), Vec<String>> {
584 self.into_result_with_strictness(strict_mode_enabled())
585 }
586
587 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
616struct 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
649fn tool_present(tool: &str, probe_arg: &str) -> bool {
651 Command::new(tool).arg(probe_arg).output().is_ok()
652}
653
654#[derive(Clone, Copy)]
656enum Stream {
657 Stdout,
658 Stderr,
659 Both,
662}
663
664fn 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
699fn 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
731pub 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 "java-jdbc" => validate_java_tools(code),
754 "java-r2dbc" => validate_java_r2dbc_tools(code),
755 "kotlin-jdbc" => validate_kotlin_tools(code),
764 name if name.starts_with("elixir") => validate_elixir_tools(code),
775 _ => return ToolValidation::Unsupported,
799 };
800
801 ToolValidation::Attempted(outcomes)
802}
803
804#[derive(Debug, Clone, PartialEq, Eq)]
815pub enum ValidationOutcome {
816 Unsupported,
820 Passed {
831 tools_run: Vec<&'static str>,
833 tools_missing: Vec<&'static str>,
835 },
836 Failed {
842 tools_run: Vec<&'static str>,
845 tools_missing: Vec<&'static str>,
847 errors: Vec<String>,
850 },
851}
852
853impl ValidationOutcome {
854 #[must_use]
864 pub fn is_failure(&self) -> bool {
865 matches!(self, Self::Failed { .. })
866 }
867}
868
869#[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
941struct 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
982fn 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
1025fn 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
1059fn 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
1071fn validate_python_tools(code: &str) -> Vec<ToolOutcome> {
1075 vec![poly_check(code, ".py")]
1076}
1077
1078fn validate_typescript_tools(code: &str) -> Vec<ToolOutcome> {
1091 vec![poly_check(code, ".ts")]
1092}
1093
1094fn 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
1110fn validate_javascript_tools(code: &str) -> Vec<ToolOutcome> {
1127 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 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
1198fn validate_php_tools(code: &str) -> Vec<ToolOutcome> {
1201 vec![poly_check(code, ".php")]
1202}
1203
1204fn 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
1227struct 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
1279fn validate_java_tools(code: &str) -> Vec<ToolOutcome> {
1287 javac_with_stubs(code, &[])
1288}
1289
1290fn 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
1330fn 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
1340fn 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
1387fn 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
1409fn 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 paths.sort();
1451 Ok(paths)
1452}
1453
1454fn 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 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 #[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 assert!(clean.errors().is_empty());
1639 assert!(absent.errors().is_empty());
1640
1641 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 #[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 #[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 #[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 #[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 assert_eq!(STRICT_ENV_VAR, "SCYTHE_VALIDATE_STRICT");
1751 }
1752}