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" | "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
92fn 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
159fn 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
191fn 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
435pub const STRICT_ENV_VAR: &str = "SCYTHE_VALIDATE_STRICT";
441
442pub 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#[derive(Debug, Clone, PartialEq, Eq)]
468pub enum ToolOutcome {
469 Ran {
472 tool: &'static str,
474 errors: Vec<String>,
476 },
477 Missing {
480 tool: &'static str,
482 },
483 Failed {
491 tool: &'static str,
493 reason: String,
495 },
496}
497
498#[derive(Debug, Clone, PartialEq, Eq)]
500pub enum ToolValidation {
501 Unsupported,
507 Attempted(Vec<ToolOutcome>),
509}
510
511impl ToolValidation {
512 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 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 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 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 pub fn into_result(self) -> Result<(), Vec<String>> {
577 self.into_result_with_strictness(strict_mode_enabled())
578 }
579
580 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
609struct 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
642fn tool_present(tool: &str, probe_arg: &str) -> bool {
644 Command::new(tool).arg(probe_arg).output().is_ok()
645}
646
647#[derive(Clone, Copy)]
649enum Stream {
650 Stdout,
651 Stderr,
652 Both,
655}
656
657fn 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
692fn 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
724pub 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 _ => 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
766fn 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
800fn 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
812fn validate_python_tools(code: &str) -> Vec<ToolOutcome> {
816 vec![poly_check(code, ".py")]
817}
818
819fn validate_typescript_tools(code: &str) -> Vec<ToolOutcome> {
832 vec![poly_check(code, ".ts")]
833}
834
835fn 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
851fn validate_javascript_tools(code: &str) -> Vec<ToolOutcome> {
868 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 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
939fn 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 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 #[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 assert!(clean.errors().is_empty());
1084 assert!(absent.errors().is_empty());
1085
1086 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 #[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 #[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 #[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 #[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 assert_eq!(STRICT_ENV_VAR, "SCYTHE_VALIDATE_STRICT");
1194 }
1195}