Skip to main content

spikard_cli/codegen/quality/
validator.rs

1//! Quality validation implementation for generated code
2//!
3//! This module implements language-specific validation for syntax, types, and linting.
4
5use crate::codegen::TargetLanguage;
6use std::fmt;
7use std::fs;
8use std::io::Write;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11use std::sync::Mutex;
12use tempfile::{Builder, NamedTempFile, TempDir, tempdir};
13
14/// Process-wide lock guarding every cargo invocation made by the quality
15/// validator. Multiple parallel cargo runs against the global `~/.cargo`
16/// registry cache contend on the package-cache file lock and silently
17/// timeout under heavy CI parallelism, producing intermittent
18/// `cannot create lock`/`Blocking waiting for file lock` failures across
19/// every Rust-target validator test. Serializing cargo invocations
20/// trades wall-clock for stability — the tests already run cargo
21/// sequentially per language matrix in CI, so the wider lock is a
22/// no-op outside of the test parallelism that causes the contention.
23static CARGO_VALIDATOR_LOCK: Mutex<()> = Mutex::new(());
24
25fn cargo_lock_guard() -> std::sync::MutexGuard<'static, ()> {
26    CARGO_VALIDATOR_LOCK
27        .lock()
28        .unwrap_or_else(std::sync::PoisonError::into_inner)
29}
30
31/// Error types for quality validation operations
32#[derive(Debug)]
33pub enum QualityError {
34    /// A required validation tool was not found in the system PATH
35    ToolNotFound(String),
36    /// Validation failed with a specific error message
37    ValidationFailed(String),
38    /// I/O error during file operations
39    IoError(String),
40}
41
42impl fmt::Display for QualityError {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            Self::ToolNotFound(tool) => {
46                write!(f, "Required validation tool not found: {tool}")
47            }
48            Self::ValidationFailed(msg) => {
49                write!(f, "Validation failed: {msg}")
50            }
51            Self::IoError(msg) => {
52                write!(f, "I/O error: {msg}")
53            }
54        }
55    }
56}
57
58impl std::error::Error for QualityError {}
59
60impl From<std::io::Error> for QualityError {
61    fn from(err: std::io::Error) -> Self {
62        Self::IoError(err.to_string())
63    }
64}
65
66/// Comprehensive validation report containing results from all quality gates
67#[derive(Debug, Clone)]
68pub struct ValidationReport {
69    /// Whether syntax validation passed
70    pub syntax_passed: bool,
71    /// Whether type validation passed
72    pub types_passed: bool,
73    /// Whether linting validation passed
74    pub lint_passed: bool,
75    /// List of all validation errors encountered
76    pub errors: Vec<String>,
77}
78
79impl ValidationReport {
80    /// Creates a new empty validation report
81    const fn new() -> Self {
82        Self {
83            syntax_passed: false,
84            types_passed: false,
85            lint_passed: false,
86            errors: Vec::new(),
87        }
88    }
89
90    /// Checks if all validation checks passed
91    ///
92    /// Returns `true` only if syntax, types, and lint all passed without errors.
93    #[must_use]
94    pub const fn is_valid(&self) -> bool {
95        self.syntax_passed && self.types_passed && self.lint_passed && self.errors.is_empty()
96    }
97
98    /// Returns the count of validation errors
99    #[must_use]
100    pub const fn error_count(&self) -> usize {
101        self.errors.len()
102    }
103
104    /// Adds an error message to the report
105    fn add_error(&mut self, error: String) {
106        self.errors.push(error);
107    }
108}
109
110impl fmt::Display for ValidationReport {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        writeln!(f, "Validation Report")?;
113        writeln!(f, "  Syntax: {}", if self.syntax_passed { "PASS" } else { "FAIL" })?;
114        writeln!(f, "  Types:  {}", if self.types_passed { "PASS" } else { "FAIL" })?;
115        writeln!(f, "  Lint:   {}", if self.lint_passed { "PASS" } else { "FAIL" })?;
116
117        if !self.errors.is_empty() {
118            writeln!(f, "  Errors: {}", self.error_count())?;
119            for error in &self.errors {
120                writeln!(f, "    - {error}")?;
121            }
122        }
123
124        Ok(())
125    }
126}
127
128/// Language-specific code quality validator
129///
130/// Orchestrates syntax, type, and lint validation for generated code across
131/// all supported target languages.
132///
133/// # Architecture
134///
135/// The validator follows a layered approach:
136///
137/// 1. **Code staging**: Writes code to a temporary file with appropriate extension
138/// 2. **Tool execution**: Runs language-specific validation tools
139/// 3. **Error parsing**: Extracts and structures error messages
140/// 4. **Report generation**: Compiles results into a [`ValidationReport`]
141///
142/// # Zero-Copy Design
143///
144/// Code is written to disk once and reused for all validation passes, minimizing
145/// I/O overhead. Tools operate directly on the filesystem.
146#[derive(Debug)]
147pub struct QualityValidator {
148    language: TargetLanguage,
149}
150
151impl QualityValidator {
152    /// Creates a new quality validator for the specified language
153    ///
154    /// # Arguments
155    ///
156    /// * `language` - The target language for validation
157    ///
158    /// # Example
159    ///
160    /// ```ignore
161    /// let validator = QualityValidator::new(TargetLanguage::Python);
162    /// ```
163    #[must_use]
164    pub const fn new(language: TargetLanguage) -> Self {
165        Self { language }
166    }
167
168    /// Validates syntax by attempting to parse/compile the code
169    ///
170    /// Each language uses its native compiler or parser:
171    /// - Python: `python3 -m py_compile`
172    /// - TypeScript: `tsc --noEmit`
173    /// - Ruby: `ruby -c`
174    /// - PHP: `php -l`
175    /// - Rust: `cargo check`
176    ///
177    /// # Arguments
178    ///
179    /// * `code` - The source code to validate
180    ///
181    /// # Returns
182    ///
183    /// - `Ok(())` if syntax is valid
184    /// - `Err(QualityError::ToolNotFound)` if the validation tool is unavailable
185    /// - `Err(QualityError::ValidationFailed)` if syntax errors are found
186    /// - `Err(QualityError::IoError)` if file operations fail
187    ///
188    /// # Example
189    ///
190    /// ```ignore
191    /// let validator = QualityValidator::new(TargetLanguage::Python);
192    /// validator.validate_syntax("x = 1")?;
193    /// ```
194    pub fn validate_syntax(&self, code: &str) -> Result<(), QualityError> {
195        match self.language {
196            TargetLanguage::Python => {
197                let project = self.write_temp_python_project(code)?;
198                self.run_tool_in_dir(
199                    "python3",
200                    &[
201                        "-m",
202                        "py_compile",
203                        project.entry_path.file_name().unwrap().to_str().unwrap(),
204                    ],
205                    project.workdir.path(),
206                    code,
207                )
208                .map(|_| ())
209            }
210            TargetLanguage::TypeScript => {
211                let project = self.write_temp_typescript_project(code)?;
212                self.run_tool_in_dir(
213                    "pnpm",
214                    &[
215                        "exec",
216                        "tsc",
217                        "--noEmit",
218                        "--project",
219                        project.config_path.to_str().unwrap(),
220                    ],
221                    Path::new("."),
222                    code,
223                )
224                .map(|_| ())
225            }
226            TargetLanguage::Rust => {
227                let project = self.write_temp_rust_project(code)?;
228                let _guard = cargo_lock_guard();
229                self.run_tool_in_dir(
230                    "cargo",
231                    &["check", "--manifest-path", project.manifest_path.to_str().unwrap()],
232                    project.workdir.path(),
233                    code,
234                )
235                .map(|_| ())
236            }
237            TargetLanguage::Ruby => {
238                let file = self.write_temp_file(code, "rb")?;
239                self.run_tool("ruby", &["-c", file.path().to_str().unwrap()], code)
240                    .map(|_| ())
241            }
242            TargetLanguage::Php => {
243                let file = self.write_temp_file(code, "php")?;
244                self.run_tool("php", &["-l", file.path().to_str().unwrap()], code)
245                    .map(|_| ())
246            }
247            TargetLanguage::Elixir => {
248                let project = self.write_temp_elixir_project(code)?;
249                self.run_tool_in_dir(
250                    "mix",
251                    &["compile", "--warnings-as-errors"],
252                    project.workdir.path(),
253                    code,
254                )
255                .map(|_| ())
256            }
257        }
258    }
259
260    /// Validates type correctness using language-specific type checkers
261    ///
262    /// Not all languages support this check; unsupported languages return `Ok(())`.
263    ///
264    /// Tools used:
265    /// - Python: `mypy --strict`
266    /// - TypeScript: `tsc --noEmit`
267    /// - Ruby: `steep check`
268    /// - PHP: Not supported (lint validation covers this)
269    /// - Rust: `cargo check`
270    ///
271    /// # Arguments
272    ///
273    /// * `code` - The source code to validate
274    ///
275    /// # Returns
276    ///
277    /// - `Ok(())` if types are valid or language doesn't support type checking
278    /// - `Err(QualityError::ToolNotFound)` if the type checker is unavailable
279    /// - `Err(QualityError::ValidationFailed)` if type errors are found
280    /// - `Err(QualityError::IoError)` if file operations fail
281    ///
282    /// # Example
283    ///
284    /// ```ignore
285    /// let validator = QualityValidator::new(TargetLanguage::TypeScript);
286    /// validator.validate_types("const x: number = 5;")?;
287    /// ```
288    pub fn validate_types(&self, code: &str) -> Result<(), QualityError> {
289        match self.language {
290            TargetLanguage::Python => {
291                let project = self.write_temp_python_project(code)?;
292                self.run_tool_in_dir_with_env(
293                    "uv",
294                    &["run", "mypy", "--strict", project.entry_path.to_str().unwrap()],
295                    workspace_root(),
296                    &[("MYPYPATH", project.stub_path.as_os_str())],
297                    code,
298                )
299                .map(|_| ())
300            }
301            TargetLanguage::TypeScript => {
302                let project = self.write_temp_typescript_project(code)?;
303                self.run_tool_in_dir(
304                    "pnpm",
305                    &[
306                        "exec",
307                        "tsc",
308                        "--strict",
309                        "--noEmit",
310                        "--project",
311                        project.config_path.to_str().unwrap(),
312                    ],
313                    Path::new("."),
314                    code,
315                )
316                .map(|_| ())
317            }
318            TargetLanguage::Ruby => {
319                let file = self.write_temp_file(code, "rb")?;
320                let package_dir = workspace_root().join("packages/ruby");
321                self.run_tool_in_dir(
322                    "bundle",
323                    &["exec", "steep", "check", file.path().to_str().unwrap()],
324                    &package_dir,
325                    code,
326                )
327                .map(|_| ())
328            }
329            TargetLanguage::Rust => {
330                let project = self.write_temp_rust_project(code)?;
331                let _guard = cargo_lock_guard();
332                self.run_tool_in_dir(
333                    "cargo",
334                    &["check", "--manifest-path", project.manifest_path.to_str().unwrap()],
335                    project.workdir.path(),
336                    code,
337                )
338                .map(|_| ())
339            }
340            TargetLanguage::Php => {
341                // PHP doesn't have a separate type checker; covered by lint
342                Ok(())
343            }
344            TargetLanguage::Elixir => Ok(()),
345        }
346    }
347
348    /// Validates code against linting and style standards
349    ///
350    /// Each language enforces its community standards:
351    /// - Python: `ruff check`
352    /// - TypeScript: `biome check`
353    /// - Ruby: `rubocop`
354    /// - PHP: `phpstan --level=max`
355    /// - Rust: `cargo clippy -- -D warnings`
356    ///
357    /// # Arguments
358    ///
359    /// * `code` - The source code to validate
360    ///
361    /// # Returns
362    ///
363    /// - `Ok(())` if code passes all linting checks
364    /// - `Err(QualityError::ToolNotFound)` if the linter is unavailable
365    /// - `Err(QualityError::ValidationFailed)` if linting violations are found
366    /// - `Err(QualityError::IoError)` if file operations fail
367    ///
368    /// # Example
369    ///
370    /// ```ignore
371    /// let validator = QualityValidator::new(TargetLanguage::Python);
372    /// validator.validate_lint("import os\nx = 1")?;
373    /// ```
374    pub fn validate_lint(&self, code: &str) -> Result<(), QualityError> {
375        match self.language {
376            TargetLanguage::Python => {
377                let project = self.write_temp_python_project(code)?;
378                self.run_tool_in_dir(
379                    "uv",
380                    &["run", "ruff", "check", project.entry_path.to_str().unwrap()],
381                    workspace_root(),
382                    code,
383                )
384                .map(|_| ())
385            }
386            TargetLanguage::TypeScript => Ok(()),
387            TargetLanguage::Ruby => {
388                let file = self.write_temp_file(code, "rb")?;
389                let package_dir = workspace_root().join("packages/ruby");
390                self.run_tool_in_dir(
391                    "bundle",
392                    &[
393                        "exec",
394                        "rubocop",
395                        "--disable-pending-cops",
396                        "--except",
397                        "Naming/FileName",
398                        file.path().to_str().unwrap(),
399                    ],
400                    &package_dir,
401                    code,
402                )
403                .map(|_| ())
404            }
405            TargetLanguage::Php => {
406                let file = self.write_temp_file(code, "php")?;
407                let bootstrap = self.write_php_validation_bootstrap()?;
408                let package_dir = workspace_root().join("packages/php");
409                self.run_tool_in_dir(
410                    "composer",
411                    &[
412                        "exec",
413                        "--",
414                        "phpstan",
415                        "analyse",
416                        "--no-progress",
417                        "--error-format=raw",
418                        "--level=max",
419                        "--autoload-file",
420                        bootstrap.path().to_str().unwrap(),
421                        file.path().to_str().unwrap(),
422                    ],
423                    &package_dir,
424                    code,
425                )
426                .map(|_| ())
427            }
428            TargetLanguage::Elixir => {
429                let project = self.write_temp_elixir_project(code)?;
430                let generated = project.generated_path.strip_prefix(project.workdir.path()).unwrap();
431                self.run_tool_in_dir(
432                    "mix",
433                    &[
434                        "format",
435                        "--check-formatted",
436                        generated.to_str().unwrap(),
437                        "mix.exs",
438                        ".formatter.exs",
439                        "lib/spikard/router.ex",
440                        "lib/spikard/request.ex",
441                        "lib/spikard/response.ex",
442                    ],
443                    project.workdir.path(),
444                    code,
445                )
446                .map(|_| ())
447            }
448            TargetLanguage::Rust => {
449                let project = self.write_temp_rust_project(code)?;
450                let _guard = cargo_lock_guard();
451                self.run_tool_in_dir(
452                    "cargo",
453                    &[
454                        "clippy",
455                        "--manifest-path",
456                        project.manifest_path.to_str().unwrap(),
457                        "--",
458                        "-D",
459                        "warnings",
460                    ],
461                    project.workdir.path(),
462                    code,
463                )
464                .map(|_| ())
465            }
466        }
467    }
468
469    /// Runs all validation checks (syntax, types, lint) and returns a comprehensive report
470    ///
471    /// This method executes all three quality gates sequentially and compiles results
472    /// into a single [`ValidationReport`]. All errors are captured, allowing callers
473    /// to see the complete picture of validation failures.
474    ///
475    /// # Arguments
476    ///
477    /// * `code` - The source code to validate
478    ///
479    /// # Returns
480    ///
481    /// - `Ok(report)` with validation results
482    /// - `Err(QualityError)` only if an I/O error occurs; validation failures are captured in the report
483    ///
484    /// # Example
485    ///
486    /// ```ignore
487    /// let validator = QualityValidator::new(TargetLanguage::Python);
488    /// let report = validator.validate_all("x = 1")?;
489    ///
490    /// if report.is_valid() {
491    ///     println!("Code is production-ready");
492    /// } else {
493    ///     eprintln!("Found {} validation errors", report.error_count());
494    /// }
495    /// ```
496    pub fn validate_all(&self, code: &str) -> Result<ValidationReport, QualityError> {
497        let mut report = ValidationReport::new();
498
499        // Syntax validation
500        match self.validate_syntax(code) {
501            Ok(()) => report.syntax_passed = true,
502            Err(e) => {
503                report.syntax_passed = false;
504                report.add_error(format!("Syntax: {e}"));
505            }
506        }
507
508        // Type validation
509        match self.validate_types(code) {
510            Ok(()) => report.types_passed = true,
511            Err(e) => {
512                report.types_passed = false;
513                report.add_error(format!("Types: {e}"));
514            }
515        }
516
517        // Lint validation
518        match self.validate_lint(code) {
519            Ok(()) => report.lint_passed = true,
520            Err(e) => {
521                report.lint_passed = false;
522                report.add_error(format!("Lint: {e}"));
523            }
524        }
525
526        Ok(report)
527    }
528
529    /// Writes code to a temporary file with the specified extension
530    ///
531    /// # Arguments
532    ///
533    /// * `code` - The source code to write
534    /// * `ext` - File extension (without leading dot)
535    ///
536    /// # Returns
537    ///
538    /// - `Ok(file)` - A named temporary file handle
539    /// - `Err(QualityError::IoError)` - If the file cannot be created or written
540    fn write_temp_file(&self, code: &str, ext: &str) -> Result<NamedTempFile, QualityError> {
541        let mut file = Builder::new()
542            .prefix("generated_")
543            .suffix(&format!(".{ext}"))
544            .tempfile()
545            .map_err(|e: std::io::Error| QualityError::IoError(e.to_string()))?;
546        file.write_all(code.as_bytes())
547            .map_err(|e: std::io::Error| QualityError::IoError(e.to_string()))?;
548        file.flush()
549            .map_err(|e: std::io::Error| QualityError::IoError(e.to_string()))?;
550        Ok(file)
551    }
552
553    fn write_temp_rust_project(&self, code: &str) -> Result<RustTempProject, QualityError> {
554        let workdir = tempdir().map_err(|e| QualityError::IoError(e.to_string()))?;
555        let src_dir = workdir.path().join("src");
556        fs::create_dir_all(&src_dir).map_err(|e| QualityError::IoError(e.to_string()))?;
557
558        let manifest_path = workdir.path().join("Cargo.toml");
559        let lib_path = src_dir.join("lib.rs");
560
561        fs::write(&manifest_path, rust_temp_manifest()).map_err(|e| QualityError::IoError(e.to_string()))?;
562        fs::write(&lib_path, code).map_err(|e| QualityError::IoError(e.to_string()))?;
563
564        Ok(RustTempProject { workdir, manifest_path })
565    }
566
567    fn write_temp_python_project(&self, code: &str) -> Result<PythonTempProject, QualityError> {
568        let workdir = tempdir().map_err(|e| QualityError::IoError(e.to_string()))?;
569        let entry_path = workdir.path().join("generated.py");
570        let stub_path = workdir.path().join("stubs");
571
572        fs::write(&entry_path, code).map_err(|e| QualityError::IoError(e.to_string()))?;
573        fs::create_dir_all(&stub_path).map_err(|e| QualityError::IoError(e.to_string()))?;
574        write_python_validation_stubs(&stub_path)?;
575
576        Ok(PythonTempProject {
577            workdir,
578            entry_path,
579            stub_path,
580        })
581    }
582
583    fn write_temp_typescript_project(&self, code: &str) -> Result<TypeScriptTempProject, QualityError> {
584        let workdir = tempdir().map_err(|e| QualityError::IoError(e.to_string()))?;
585        let entry_path = workdir.path().join("generated.ts");
586        let config_path = workdir.path().join("tsconfig.json");
587        let spikard_stub_path = workdir.path().join("spikard.d.ts");
588        let zod_stub_path = workdir.path().join("zod.d.ts");
589        let graphql_stub_path = workdir.path().join("graphql.d.ts");
590        let graphql_tools_stub_path = workdir.path().join("graphql-tools-schema.d.ts");
591        let protobufjs_stub_path = workdir.path().join("protobufjs.d.ts");
592
593        fs::write(&entry_path, code).map_err(|e| QualityError::IoError(e.to_string()))?;
594        fs::write(
595            &config_path,
596            r#"{
597  "compilerOptions": {
598    "target": "ES2022",
599    "module": "ESNext",
600    "moduleResolution": "Bundler",
601    "strict": true,
602    "skipLibCheck": true,
603    "noEmit": true
604  },
605  "files": [
606    "generated.ts",
607    "spikard.d.ts",
608    "zod.d.ts",
609    "graphql.d.ts",
610    "graphql-tools-schema.d.ts",
611    "protobufjs.d.ts"
612  ]
613}
614"#,
615        )
616        .map_err(|e| QualityError::IoError(e.to_string()))?;
617        fs::write(
618            &spikard_stub_path,
619            r#"declare module "spikard" {
620  export type RouteMetadata = {
621    method: string;
622    path: string;
623    handler_name: string;
624    is_async: boolean;
625  };
626
627  export type SpikardApp = {
628    routes: RouteMetadata[];
629    handlers: Record<string, unknown>;
630  };
631
632  export class StreamingResponse {
633    constructor(body?: unknown, init?: unknown);
634  }
635
636  export class Spikard {
637    start(config?: unknown): Promise<void>;
638  }
639
640  export type Body<T> = T;
641  export type Path<T> = T;
642  export type Query<T> = T;
643  export type Request = Record<string, unknown>;
644
645  export function route(...args: unknown[]): any;
646}
647"#,
648        )
649        .map_err(|e| QualityError::IoError(e.to_string()))?;
650        fs::write(
651            &zod_stub_path,
652            r#"declare module "zod" {
653  export namespace z {
654    export type infer<T> = any;
655  }
656
657  export const z: any;
658}
659"#,
660        )
661        .map_err(|e| QualityError::IoError(e.to_string()))?;
662        fs::write(
663            &graphql_stub_path,
664            r#"declare module "graphql" {
665  export interface GraphQLResolveInfo {}
666}
667"#,
668        )
669        .map_err(|e| QualityError::IoError(e.to_string()))?;
670        fs::write(
671            &graphql_tools_stub_path,
672            r#"declare module "@graphql-tools/schema" {
673  export function makeExecutableSchema(config: {
674    typeDefs: string;
675    resolvers: unknown;
676  }): unknown;
677}
678"#,
679        )
680        .map_err(|e| QualityError::IoError(e.to_string()))?;
681        fs::write(
682            &protobufjs_stub_path,
683            r#"declare module "protobufjs" {
684  const protobuf: Record<string, unknown>;
685  export = protobuf;
686}
687"#,
688        )
689        .map_err(|e| QualityError::IoError(e.to_string()))?;
690
691        Ok(TypeScriptTempProject {
692            _workdir: workdir,
693            config_path,
694        })
695    }
696
697    fn write_temp_elixir_project(&self, code: &str) -> Result<ElixirTempProject, QualityError> {
698        let workdir = tempdir().map_err(|e| QualityError::IoError(e.to_string()))?;
699        let mix_exs = workdir.path().join("mix.exs");
700        let formatter = workdir.path().join(".formatter.exs");
701        let lib_dir = workdir.path().join("lib");
702        let spikard_dir = lib_dir.join("spikard");
703        let generated_path = lib_dir.join("generated.ex");
704
705        fs::create_dir_all(&spikard_dir).map_err(|e| QualityError::IoError(e.to_string()))?;
706
707        fs::write(
708            &mix_exs,
709            r#"defmodule GeneratedValidation.MixProject do
710  use Mix.Project
711
712  def project do
713    [
714      app: :generated_validation,
715      version: "0.1.0",
716      elixir: "~> 1.18",
717      deps: []
718    ]
719  end
720end
721"#,
722        )
723        .map_err(|e| QualityError::IoError(e.to_string()))?;
724        fs::write(
725            &formatter,
726            r#"[
727  inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
728  line_length: 120
729]
730"#,
731        )
732        .map_err(|e| QualityError::IoError(e.to_string()))?;
733        fs::write(
734            spikard_dir.join("router.ex"),
735            r#"defmodule Spikard.Router do
736  defmacro __using__(_opts) do
737    quote do
738      import Spikard.Router
739      Module.register_attribute(__MODULE__, :spikard_routes, accumulate: true)
740    end
741  end
742
743  for method <- ~w(get post put patch delete)a do
744    defmacro unquote(method)(path, handler, opts \\ []) do
745      quote do
746        @spikard_routes {unquote(path), unquote(handler), unquote(opts)}
747      end
748    end
749  end
750end
751"#,
752        )
753        .map_err(|e| QualityError::IoError(e.to_string()))?;
754        fs::write(
755            spikard_dir.join("request.ex"),
756            r#"defmodule Spikard.Request do
757  @type t :: map()
758
759  @spec get_path_param(t(), String.t()) :: term()
760  def get_path_param(_request, _key), do: nil
761
762  @spec get_query_param(t(), String.t()) :: term()
763  def get_query_param(_request, _key), do: nil
764
765  @spec get_header(t(), String.t()) :: term()
766  def get_header(_request, _key), do: nil
767
768  @spec get_cookie(t(), String.t()) :: term()
769  def get_cookie(_request, _key), do: nil
770
771  @spec get_body(t()) :: term()
772  def get_body(_request), do: %{}
773end
774"#,
775        )
776        .map_err(|e| QualityError::IoError(e.to_string()))?;
777        fs::write(
778            spikard_dir.join("response.ex"),
779            r#"defmodule Spikard.Response do
780  @type t :: %{status: non_neg_integer(), headers: [{String.t(), String.t()}], body: term()}
781
782  @spec json(term(), keyword()) :: t()
783  def json(body, opts \\ []) do
784    %{status: Keyword.get(opts, :status, 200), headers: [{"content-type", "application/json"}], body: body}
785  end
786
787  @spec status(non_neg_integer()) :: t()
788  def status(code) do
789    %{status: code, headers: [], body: nil}
790  end
791end
792"#,
793        )
794        .map_err(|e| QualityError::IoError(e.to_string()))?;
795        fs::write(
796            spikard_dir.join("grpc.ex"),
797            r#"defmodule Spikard.Grpc do
798  defmodule Request do
799    @type t :: %{
800            service_name: String.t(),
801            method_name: String.t(),
802            payload: binary(),
803            metadata: %{optional(String.t()) => String.t()}
804          }
805  end
806
807  defmodule Error do
808    defstruct [:code, :message, metadata: %{}]
809    @type t :: %__MODULE__{code: term(), message: String.t(), metadata: map()}
810  end
811
812  defmodule Response do
813    defstruct payload: <<>>, metadata: %{}
814    @type t :: %__MODULE__{payload: binary(), metadata: %{optional(String.t()) => String.t()}}
815
816    @spec error(String.t(), term()) :: {:error, Spikard.Grpc.Error.t()}
817    def error(message, code \\ :internal) do
818      {:error, %Spikard.Grpc.Error{code: code, message: message, metadata: %{}}}
819    end
820  end
821
822  defmodule Service do
823    defstruct services: %{}
824    @type t :: %__MODULE__{services: map()}
825
826    @spec new() :: t()
827    def new, do: %__MODULE__{}
828
829    @spec register(t(), String.t(), String.t(), atom(), function()) :: t()
830    def register(%__MODULE__{services: services} = service, service_name, method_name, rpc_mode, handler) do
831      methods =
832        services
833        |> Map.get(service_name, %{})
834        |> Map.put(method_name, {rpc_mode, handler})
835
836      %{service | services: Map.put(services, service_name, methods)}
837    end
838  end
839end
840"#,
841        )
842        .map_err(|e| QualityError::IoError(e.to_string()))?;
843        fs::write(
844            spikard_dir.join("websocket.ex"),
845            r#"defmodule Spikard.WebSocket do
846  @callback handle_connect(term(), term()) :: {:ok, term()} | {:error, term()}
847  @callback handle_message(term(), term()) ::
848              {:reply, term(), term()} | {:noreply, term()} | {:error, term()}
849  @callback handle_disconnect(term(), term()) :: :ok | {:error, term()}
850
851  defmacro __using__(_opts) do
852    quote do
853      @behaviour Spikard.WebSocket
854
855      @impl true
856      def handle_connect(_ws, _opts), do: {:ok, nil}
857
858      @impl true
859      def handle_message(message, state), do: {:reply, message, state}
860
861      @impl true
862      def handle_disconnect(_ws, _state), do: :ok
863
864      defoverridable handle_connect: 2, handle_message: 2, handle_disconnect: 2
865    end
866  end
867end
868"#,
869        )
870        .map_err(|e| QualityError::IoError(e.to_string()))?;
871        fs::write(
872            spikard_dir.join("sse.ex"),
873            r#"defmodule Spikard.Sse.Event do
874  defstruct [:data, :event, :id]
875  @type t :: %__MODULE__{data: term(), event: String.t() | nil, id: String.t() | nil}
876end
877
878defmodule Spikard.Sse.Producer do
879  @callback init(term()) :: {:ok, term()} | {:error, term()}
880  @callback next_event(term()) ::
881              {:ok, Spikard.Sse.Event.t(), term()} | :done | :error
882
883  defmacro __using__(_opts) do
884    quote do
885      @behaviour Spikard.Sse.Producer
886
887      @impl true
888      def init(_opts), do: {:ok, nil}
889
890      defoverridable init: 1
891    end
892  end
893end
894"#,
895        )
896        .map_err(|e| QualityError::IoError(e.to_string()))?;
897        fs::write(&generated_path, code).map_err(|e| QualityError::IoError(e.to_string()))?;
898
899        Ok(ElixirTempProject {
900            workdir,
901            generated_path,
902        })
903    }
904
905    fn write_php_validation_bootstrap(&self) -> Result<NamedTempFile, QualityError> {
906        self.write_temp_file(
907            r#"<?php
908declare(strict_types=1);
909
910namespace SpikardGenerated;
911
912#[\Attribute(\Attribute::TARGET_METHOD)]
913final class Route
914{
915    public function __construct(
916        public string $path,
917        public array $methods = [],
918    ) {}
919}
920
921namespace Spikard\Handlers;
922
923interface WebSocketHandlerInterface
924{
925    public function onConnect(): void;
926
927    public function onMessage(string $message): void;
928
929    public function onClose(int $code, ?string $reason = null): void;
930}
931
932interface SseEventProducerInterface
933{
934    /** @return \Generator<int, string, mixed, void> */
935    public function __invoke(): \Generator;
936}
937
938namespace Spikard;
939
940final class App
941{
942    public function addWebSocket(
943        string $path,
944        \Spikard\Handlers\WebSocketHandlerInterface $handler
945    ): self {
946        return $this;
947    }
948
949    public function addSse(
950        string $path,
951        \Spikard\Handlers\SseEventProducerInterface $producer
952    ): self {
953        return $this;
954    }
955}
956
957namespace Google\Protobuf\Internal;
958
959class Message {}
960
961namespace GraphQL\Type\Definition;
962
963class Type
964{
965    public static function string(): self
966    {
967        return new self();
968    }
969
970    public static function int(): self
971    {
972        return new self();
973    }
974
975    public static function float(): self
976    {
977        return new self();
978    }
979
980    public static function boolean(): self
981    {
982        return new self();
983    }
984
985    public static function id(): self
986    {
987        return new self();
988    }
989
990    public static function nonNull(self $type): self
991    {
992        return $type;
993    }
994
995    public static function listOf(self $type): self
996    {
997        return $type;
998    }
999}
1000
1001class ObjectType extends Type
1002{
1003    /** @param array<string, mixed> $config */
1004    public function __construct(array $config = [])
1005    {
1006    }
1007}
1008
1009class InputObjectType extends Type
1010{
1011    /** @param array<string, mixed> $config */
1012    public function __construct(array $config = [])
1013    {
1014    }
1015}
1016
1017class InterfaceType extends Type
1018{
1019    /** @param array<string, mixed> $config */
1020    public function __construct(array $config = [])
1021    {
1022    }
1023}
1024
1025class UnionType extends Type
1026{
1027    /** @param array<string, mixed> $config */
1028    public function __construct(array $config = [])
1029    {
1030    }
1031}
1032
1033class EnumType extends Type
1034{
1035    /** @param array<string, mixed> $config */
1036    public function __construct(array $config = [])
1037    {
1038    }
1039}
1040
1041namespace GraphQL\Type;
1042
1043class Schema
1044{
1045    /** @param array<string, mixed> $config */
1046    public function __construct(array $config = [])
1047    {
1048    }
1049}
1050"#,
1051            "php",
1052        )
1053    }
1054
1055    /// Executes a validation tool and captures its output
1056    ///
1057    /// This method runs an external command with the given arguments and interprets
1058    /// the exit code. A zero exit code indicates success; non-zero indicates failure.
1059    /// Both stdout and stderr are captured and included in error messages.
1060    ///
1061    /// # Arguments
1062    ///
1063    /// * `tool` - The executable name (resolved from PATH)
1064    /// * `args` - Command-line arguments
1065    /// * `code` - The original code (for error context)
1066    ///
1067    /// # Returns
1068    ///
1069    /// - `Ok(output)` - The tool's stdout if successful
1070    /// - `Err(QualityError::ToolNotFound)` - If the tool is not found in PATH
1071    /// - `Err(QualityError::ValidationFailed)` - If the tool exits with non-zero status
1072    /// - `Err(QualityError::IoError)` - If execution fails
1073    fn run_tool(&self, tool: &str, args: &[&str], _code: &str) -> Result<String, QualityError> {
1074        self.run_tool_in_dir(tool, args, Path::new("."), _code)
1075    }
1076
1077    fn run_tool_in_dir(&self, tool: &str, args: &[&str], cwd: &Path, _code: &str) -> Result<String, QualityError> {
1078        self.run_tool_in_dir_with_env(tool, args, cwd, &[], _code)
1079    }
1080
1081    fn run_tool_in_dir_with_env(
1082        &self,
1083        tool: &str,
1084        args: &[&str],
1085        cwd: &Path,
1086        envs: &[(&str, &std::ffi::OsStr)],
1087        _code: &str,
1088    ) -> Result<String, QualityError> {
1089        // mypy 2.x has a known nondeterministic INTERNAL ERROR crash. Retry up to
1090        // 3 times when we detect the signature; surface all other failures
1091        // immediately.
1092        let max_attempts = 3;
1093        let mut last_err: Option<QualityError> = None;
1094        for _ in 0..max_attempts {
1095            let mut command = Command::new(tool);
1096            command.args(args).current_dir(cwd);
1097            for (key, value) in envs {
1098                command.env(key, value);
1099            }
1100
1101            let output = command.output().map_err(|e| {
1102                if e.kind() == std::io::ErrorKind::NotFound {
1103                    QualityError::ToolNotFound(tool.to_string())
1104                } else {
1105                    QualityError::IoError(e.to_string())
1106                }
1107            })?;
1108
1109            if output.status.success() {
1110                return Ok(String::from_utf8_lossy(&output.stdout).to_string());
1111            }
1112
1113            let stderr = String::from_utf8_lossy(&output.stderr);
1114            let stdout = String::from_utf8_lossy(&output.stdout);
1115            let message = if stderr.is_empty() {
1116                stdout.to_string()
1117            } else {
1118                stderr.to_string()
1119            };
1120            let is_mypy_internal_error = message.contains("INTERNAL ERROR");
1121            last_err = Some(QualityError::ValidationFailed(message));
1122            if !is_mypy_internal_error {
1123                break;
1124            }
1125        }
1126        Err(last_err.expect("at least one attempt always runs"))
1127    }
1128}
1129
1130struct RustTempProject {
1131    workdir: TempDir,
1132    manifest_path: PathBuf,
1133}
1134
1135struct TypeScriptTempProject {
1136    // _workdir holds the TempDir alive for RAII cleanup; path is not read directly.
1137    _workdir: TempDir,
1138    config_path: PathBuf,
1139}
1140
1141struct PythonTempProject {
1142    workdir: TempDir,
1143    entry_path: PathBuf,
1144    stub_path: PathBuf,
1145}
1146
1147struct ElixirTempProject {
1148    workdir: TempDir,
1149    generated_path: PathBuf,
1150}
1151
1152fn write_python_validation_stubs(root: &Path) -> Result<(), QualityError> {
1153    write_stub_file(
1154        &root.join("msgspec.py"),
1155        r#"from typing import Any, TypeVar, cast
1156
1157T = TypeVar("T")
1158
1159class Struct:
1160    def __init__(self, **kwargs: object) -> None: ...
1161
1162    def __init_subclass__(cls, *, frozen: bool = False, kw_only: bool = False) -> None: ...
1163
1164def field(*, default: object = ..., name: str | None = None) -> Any: ...
1165
1166def convert(value: object, *, type: object) -> Any:
1167    return value
1168
1169def to_builtins(value: object) -> object: ...
1170"#,
1171    )?;
1172
1173    write_stub_file(&root.join("graphql.py"), "class GraphQLResolveInfo: ...\n")?;
1174
1175    write_stub_file(
1176        &root.join("ariadne.py"),
1177        r#"from __future__ import annotations
1178
1179from collections.abc import Callable
1180from typing import Any
1181
1182Resolver = Callable[..., Any]
1183
1184class QueryType:
1185    def set_field(self, _name: str, _resolver: Resolver) -> None: ...
1186
1187class MutationType:
1188    def set_field(self, _name: str, _resolver: Resolver) -> None: ...
1189
1190class SubscriptionType:
1191    def set_field(self, _name: str, _resolver: Resolver) -> None: ...
1192    def set_source(self, _name: str, _resolver: Resolver) -> None: ...
1193
1194def make_executable_schema(*_args: object, **_kwargs: object) -> object:
1195    return object()
1196"#,
1197    )?;
1198
1199    let spikard_dir = root.join("spikard");
1200    fs::create_dir_all(&spikard_dir).map_err(|e| QualityError::IoError(e.to_string()))?;
1201    write_stub_file(
1202        &spikard_dir.join("__init__.py"),
1203        r#"from __future__ import annotations
1204
1205from collections.abc import Callable
1206from typing import Generic, TypeVar
1207
1208F = TypeVar("F", bound=Callable[..., object])
1209T = TypeVar("T")
1210
1211class Body(Generic[T]): ...
1212
1213class Path(Generic[T]): ...
1214
1215class Query(Generic[T]):
1216    def __init__(self, default: T | None = None) -> None:
1217        self.default = default
1218
1219class Request: ...
1220
1221class Spikard:
1222    def route(self, *_args: object, **_kwargs: object) -> Callable[[F], F]:
1223        def decorator(fn: F) -> F:
1224            return fn
1225        return decorator
1226
1227    def post(self, *_args: object, **_kwargs: object) -> Callable[[F], F]:
1228        def decorator(fn: F) -> F:
1229            return fn
1230        return decorator
1231
1232    def get(self, *_args: object, **_kwargs: object) -> Callable[[F], F]:
1233        def decorator(fn: F) -> F:
1234            return fn
1235        return decorator
1236
1237    def run(self, *_args: object, **_kwargs: object) -> None:
1238        return None
1239
1240def route(*_args: object, **_kwargs: object) -> Callable[[F], F]:
1241    def decorator(fn: F) -> F:
1242        return fn
1243    return decorator
1244
1245def websocket(*_args: object, **_kwargs: object) -> Callable[[F], F]:
1246    def decorator(fn: F) -> F:
1247        return fn
1248    return decorator
1249
1250def sse(*_args: object, **_kwargs: object) -> Callable[[F], F]:
1251    def decorator(fn: F) -> F:
1252        return fn
1253    return decorator
1254"#,
1255    )?;
1256    write_stub_file(
1257        &spikard_dir.join("config.py"),
1258        r#"class ServerConfig:
1259    def __init__(self, host: str = "0.0.0.0", port: int = 8000) -> None:
1260        self.host = host
1261        self.port = port
1262"#,
1263    )?;
1264
1265    let google_protobuf_dir = root.join("google").join("protobuf");
1266    fs::create_dir_all(&google_protobuf_dir).map_err(|e| QualityError::IoError(e.to_string()))?;
1267    write_stub_file(&root.join("google").join("__init__.py"), "")?;
1268    write_stub_file(&google_protobuf_dir.join("__init__.py"), "")?;
1269    write_stub_file(&google_protobuf_dir.join("message.py"), "class Message: ...\n")?;
1270
1271    let websockets_dir = root.join("websockets");
1272    fs::create_dir_all(&websockets_dir).map_err(|e| QualityError::IoError(e.to_string()))?;
1273    write_stub_file(&websockets_dir.join("__init__.py"), "")?;
1274    write_stub_file(
1275        &websockets_dir.join("client.py"),
1276        "class WebSocketClientProtocol: ...\n",
1277    )?;
1278
1279    Ok(())
1280}
1281
1282fn write_stub_file(path: &Path, contents: &str) -> Result<(), QualityError> {
1283    if let Some(parent) = path.parent() {
1284        fs::create_dir_all(parent).map_err(|e| QualityError::IoError(e.to_string()))?;
1285    }
1286    fs::write(path, contents).map_err(|e| QualityError::IoError(e.to_string()))
1287}
1288
1289fn rust_temp_manifest() -> String {
1290    let spikard_path = workspace_root().join("crates/spikard");
1291
1292    // alloc-stdlib is pinned to =0.2.2 to keep alloc-no-stdlib at 2.0.4.
1293    // brotli 8.0.3 pulls alloc-no-stdlib 2.0.4 directly, but the unbounded
1294    // alloc-stdlib transitive (via brotli-decompressor) resolves to 0.2.3,
1295    // which pulls alloc-no-stdlib 3.0.0. The graph then contains both
1296    // versions and their incompatible Allocator<T> trait impls, breaking
1297    // every brotli-touching crate the validator includes via spikard.
1298    format!(
1299        r#"[package]
1300name = "spikard_codegen_validation"
1301version = "0.1.0"
1302edition = "2024"
1303
1304[lib]
1305path = "src/lib.rs"
1306
1307[dependencies]
1308alloc-no-stdlib = "=2.0.4"
1309alloc-stdlib = "=0.2.2"
1310brotli-decompressor = "=5.0.1"
1311async-graphql = "7"
1312async-trait = "0.1"
1313axum = "0.8"
1314bytes = "1"
1315chrono = {{ version = "0.4", features = ["serde"] }}
1316futures-core = "0.3"
1317futures-util = "0.3"
1318prost = "0.14"
1319schemars = {{ version = "1.2", features = ["derive", "chrono04", "uuid1"] }}
1320serde = {{ version = "1", features = ["derive"] }}
1321serde_json = "1"
1322spikard = {{ path = "{}" }}
1323tokio = {{ version = "1", features = ["full"] }}
1324tonic = "0.14"
1325uuid = {{ version = "1", features = ["serde", "v4"] }}
1326"#,
1327        spikard_path.display()
1328    )
1329}
1330
1331fn workspace_root() -> &'static Path {
1332    Path::new(env!("CARGO_MANIFEST_DIR"))
1333        .parent()
1334        .and_then(Path::parent)
1335        .expect("workspace root should be two levels above crates/spikard-cli")
1336}
1337
1338#[cfg(test)]
1339mod tests {
1340    use super::*;
1341
1342    #[test]
1343    fn test_validation_report_is_valid() {
1344        let mut report = ValidationReport::new();
1345        assert!(!report.is_valid());
1346
1347        report.syntax_passed = true;
1348        report.types_passed = true;
1349        report.lint_passed = true;
1350        assert!(report.is_valid());
1351
1352        report.add_error("test error".to_string());
1353        assert!(!report.is_valid());
1354    }
1355
1356    #[test]
1357    fn test_validation_report_error_count() {
1358        let mut report = ValidationReport::new();
1359        assert_eq!(report.error_count(), 0);
1360
1361        report.add_error("error 1".to_string());
1362        report.add_error("error 2".to_string());
1363        assert_eq!(report.error_count(), 2);
1364    }
1365
1366    #[test]
1367    fn test_quality_validator_creation() {
1368        let validator = QualityValidator::new(TargetLanguage::Python);
1369        assert_eq!(validator.language, TargetLanguage::Python);
1370
1371        let validator = QualityValidator::new(TargetLanguage::TypeScript);
1372        assert_eq!(validator.language, TargetLanguage::TypeScript);
1373
1374        let validator = QualityValidator::new(TargetLanguage::Elixir);
1375        assert_eq!(validator.language, TargetLanguage::Elixir);
1376    }
1377
1378    #[test]
1379    fn test_quality_error_display() {
1380        let err = QualityError::ToolNotFound("mypy".to_string());
1381        assert_eq!(err.to_string(), "Required validation tool not found: mypy");
1382
1383        let err = QualityError::ValidationFailed("syntax error".to_string());
1384        assert!(err.to_string().contains("Validation failed"));
1385
1386        let err = QualityError::IoError("file not found".to_string());
1387        assert!(err.to_string().contains("I/O error"));
1388    }
1389
1390    #[test]
1391    fn test_validation_report_display() {
1392        let mut report = ValidationReport::new();
1393        report.syntax_passed = true;
1394        report.types_passed = false;
1395        report.add_error("type mismatch".to_string());
1396
1397        let display = report.to_string();
1398        assert!(display.contains("Syntax: PASS"));
1399        assert!(display.contains("Types:  FAIL"));
1400        assert!(display.contains("type mismatch"));
1401    }
1402
1403    #[test]
1404    fn test_rust_quality_validator_accepts_valid_code() {
1405        let validator = QualityValidator::new(TargetLanguage::Rust);
1406        validator
1407            .validate_syntax("pub fn add(a: i32, b: i32) -> i32 { a + b }")
1408            .expect("rust syntax validation should pass");
1409        validator
1410            .validate_types("pub fn add(a: i32, b: i32) -> i32 { a + b }")
1411            .expect("rust type validation should pass");
1412    }
1413}