1use 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
14static 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#[derive(Debug)]
33pub enum QualityError {
34 ToolNotFound(String),
36 ValidationFailed(String),
38 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#[derive(Debug, Clone)]
68pub struct ValidationReport {
69 pub syntax_passed: bool,
71 pub types_passed: bool,
73 pub lint_passed: bool,
75 pub errors: Vec<String>,
77}
78
79impl ValidationReport {
80 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 #[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 #[must_use]
100 pub const fn error_count(&self) -> usize {
101 self.errors.len()
102 }
103
104 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#[derive(Debug)]
147pub struct QualityValidator {
148 language: TargetLanguage,
149}
150
151impl QualityValidator {
152 #[must_use]
164 pub const fn new(language: TargetLanguage) -> Self {
165 Self { language }
166 }
167
168 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 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 let root = workspace_root();
293 let root_str = root
294 .to_str()
295 .ok_or_else(|| QualityError::IoError("workspace root path is not valid UTF-8".to_string()))?;
296 self.run_tool_in_dir(
297 "uv",
298 &["run", "--project", root_str, "pyrefly", "check"],
299 project.workdir.path(),
300 code,
301 )
302 .map(|_| ())
303 }
304 TargetLanguage::TypeScript => {
305 let project = self.write_temp_typescript_project(code)?;
306 self.run_tool_in_dir(
307 "pnpm",
308 &[
309 "exec",
310 "tsc",
311 "--strict",
312 "--noEmit",
313 "--project",
314 project.config_path.to_str().unwrap(),
315 ],
316 Path::new("."),
317 code,
318 )
319 .map(|_| ())
320 }
321 TargetLanguage::Ruby => {
322 let file = self.write_temp_file(code, "rb")?;
323 let package_dir = workspace_root().join("packages/ruby");
324 self.run_tool_in_dir(
325 "bundle",
326 &["exec", "steep", "check", file.path().to_str().unwrap()],
327 &package_dir,
328 code,
329 )
330 .map(|_| ())
331 }
332 TargetLanguage::Rust => {
333 let project = self.write_temp_rust_project(code)?;
334 let _guard = cargo_lock_guard();
335 self.run_tool_in_dir(
336 "cargo",
337 &["check", "--manifest-path", project.manifest_path.to_str().unwrap()],
338 project.workdir.path(),
339 code,
340 )
341 .map(|_| ())
342 }
343 TargetLanguage::Php => Ok(()),
344 TargetLanguage::Elixir => Ok(()),
345 }
346 }
347
348 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)?;
383 self.run_tool_in_dir(
384 "poly",
385 &["lint", project.entry_path.to_str().unwrap()],
386 project.workdir.path(),
387 code,
388 )
389 .map(|_| ())
390 }
391 TargetLanguage::TypeScript => Ok(()),
392 TargetLanguage::Ruby => {
393 let file = self.write_temp_file(code, "rb")?;
394 let package_dir = workspace_root().join("packages/ruby");
395 self.run_tool_in_dir(
396 "bundle",
397 &[
398 "exec",
399 "rubocop",
400 "--disable-pending-cops",
401 "--except",
402 "Naming/FileName",
403 file.path().to_str().unwrap(),
404 ],
405 &package_dir,
406 code,
407 )
408 .map(|_| ())
409 }
410 TargetLanguage::Php => {
411 let file = self.write_temp_file(code, "php")?;
412 let bootstrap = self.write_php_validation_bootstrap()?;
413 let package_dir = workspace_root().join("packages/php");
414 self.run_tool_in_dir(
415 "composer",
416 &[
417 "exec",
418 "--",
419 "phpstan",
420 "analyse",
421 "--no-progress",
422 "--error-format=raw",
423 "--level=max",
424 "--autoload-file",
425 bootstrap.path().to_str().unwrap(),
426 file.path().to_str().unwrap(),
427 ],
428 &package_dir,
429 code,
430 )
431 .map(|_| ())
432 }
433 TargetLanguage::Elixir => {
434 let project = self.write_temp_elixir_project(code)?;
435 let generated = project.generated_path.strip_prefix(project.workdir.path()).unwrap();
436 self.run_tool_in_dir(
437 "mix",
438 &[
439 "format",
440 "--check-formatted",
441 generated.to_str().unwrap(),
442 "mix.exs",
443 ".formatter.exs",
444 "lib/spikard/router.ex",
445 "lib/spikard/request.ex",
446 "lib/spikard/response.ex",
447 ],
448 project.workdir.path(),
449 code,
450 )
451 .map(|_| ())
452 }
453 TargetLanguage::Rust => {
454 let project = self.write_temp_rust_project(code)?;
455 let _guard = cargo_lock_guard();
456 self.run_tool_in_dir(
457 "cargo",
458 &[
459 "clippy",
460 "--manifest-path",
461 project.manifest_path.to_str().unwrap(),
462 "--",
463 "-D",
464 "warnings",
465 ],
466 project.workdir.path(),
467 code,
468 )
469 .map(|_| ())
470 }
471 }
472 }
473
474 pub fn validate_all(&self, code: &str) -> Result<ValidationReport, QualityError> {
502 let mut report = ValidationReport::new();
503
504 match self.validate_syntax(code) {
505 Ok(()) => report.syntax_passed = true,
506 Err(e) => {
507 report.syntax_passed = false;
508 report.add_error(format!("Syntax: {e}"));
509 }
510 }
511
512 match self.validate_types(code) {
513 Ok(()) => report.types_passed = true,
514 Err(e) => {
515 report.types_passed = false;
516 report.add_error(format!("Types: {e}"));
517 }
518 }
519
520 match self.validate_lint(code) {
521 Ok(()) => report.lint_passed = true,
522 Err(e) => {
523 report.lint_passed = false;
524 report.add_error(format!("Lint: {e}"));
525 }
526 }
527
528 Ok(report)
529 }
530
531 fn write_temp_file(&self, code: &str, ext: &str) -> Result<NamedTempFile, QualityError> {
543 let mut file = Builder::new()
544 .prefix("generated_")
545 .suffix(&format!(".{ext}"))
546 .tempfile()
547 .map_err(|e: std::io::Error| QualityError::IoError(e.to_string()))?;
548 file.write_all(code.as_bytes())
549 .map_err(|e: std::io::Error| QualityError::IoError(e.to_string()))?;
550 file.flush()
551 .map_err(|e: std::io::Error| QualityError::IoError(e.to_string()))?;
552 Ok(file)
553 }
554
555 fn write_temp_rust_project(&self, code: &str) -> Result<RustTempProject, QualityError> {
556 let workdir = tempdir().map_err(|e| QualityError::IoError(e.to_string()))?;
557 let src_dir = workdir.path().join("src");
558 fs::create_dir_all(&src_dir).map_err(|e| QualityError::IoError(e.to_string()))?;
559
560 let manifest_path = workdir.path().join("Cargo.toml");
561 let lib_path = src_dir.join("lib.rs");
562
563 fs::write(&manifest_path, rust_temp_manifest()).map_err(|e| QualityError::IoError(e.to_string()))?;
564 fs::write(&lib_path, code).map_err(|e| QualityError::IoError(e.to_string()))?;
565
566 Ok(RustTempProject { workdir, manifest_path })
567 }
568
569 fn write_temp_python_project(&self, code: &str) -> Result<PythonTempProject, QualityError> {
570 let workdir = tempdir().map_err(|e| QualityError::IoError(e.to_string()))?;
571 let entry_path = workdir.path().join("generated.py");
572 let stub_path = workdir.path().join("stubs");
573 let config_path = workdir.path().join("pyrefly.toml");
574
575 fs::write(&entry_path, code).map_err(|e| QualityError::IoError(e.to_string()))?;
576 fs::create_dir_all(&stub_path).map_err(|e| QualityError::IoError(e.to_string()))?;
577 write_python_validation_stubs(&stub_path)?;
578
579 fs::write(
580 &config_path,
581 "python-version = \"3.10\"\npreset = \"strict\"\nproject-includes = [\"generated.py\"]\nsearch-path = [\"stubs\"]\n",
582 )
583 .map_err(|e| QualityError::IoError(e.to_string()))?;
584
585 Ok(PythonTempProject { workdir, entry_path })
586 }
587
588 fn write_temp_typescript_project(&self, code: &str) -> Result<TypeScriptTempProject, QualityError> {
589 let workdir = tempdir().map_err(|e| QualityError::IoError(e.to_string()))?;
590 let entry_path = workdir.path().join("generated.ts");
591 let config_path = workdir.path().join("tsconfig.json");
592 let spikard_stub_path = workdir.path().join("spikard.d.ts");
593 let zod_stub_path = workdir.path().join("zod.d.ts");
594 let graphql_stub_path = workdir.path().join("graphql.d.ts");
595 let graphql_tools_stub_path = workdir.path().join("graphql-tools-schema.d.ts");
596 let protobufjs_stub_path = workdir.path().join("protobufjs.d.ts");
597
598 fs::write(&entry_path, code).map_err(|e| QualityError::IoError(e.to_string()))?;
599 fs::write(
600 &config_path,
601 r#"{
602 "compilerOptions": {
603 "target": "ES2022",
604 "module": "ESNext",
605 "moduleResolution": "Bundler",
606 "strict": true,
607 "skipLibCheck": true,
608 "noEmit": true
609 },
610 "files": [
611 "generated.ts",
612 "spikard.d.ts",
613 "zod.d.ts",
614 "graphql.d.ts",
615 "graphql-tools-schema.d.ts",
616 "protobufjs.d.ts"
617 ]
618}
619"#,
620 )
621 .map_err(|e| QualityError::IoError(e.to_string()))?;
622 fs::write(
623 &spikard_stub_path,
624 r#"declare module "spikard" {
625 export type RouteMetadata = {
626 method: string;
627 path: string;
628 handler_name: string;
629 is_async: boolean;
630 };
631
632 export type SpikardApp = {
633 routes: RouteMetadata[];
634 handlers: Record<string, unknown>;
635 };
636
637 export class StreamingResponse {
638 constructor(body?: unknown, init?: unknown);
639 }
640
641 export class Spikard {
642 start(config?: unknown): Promise<void>;
643 }
644
645 export type Body<T> = T;
646 export type Path<T> = T;
647 export type Query<T> = T;
648 export type Request = Record<string, unknown>;
649
650 export function route(...args: unknown[]): any;
651}
652"#,
653 )
654 .map_err(|e| QualityError::IoError(e.to_string()))?;
655 fs::write(
656 &zod_stub_path,
657 r#"declare module "zod" {
658 export namespace z {
659 export type infer<T> = any;
660 }
661
662 export const z: any;
663}
664"#,
665 )
666 .map_err(|e| QualityError::IoError(e.to_string()))?;
667 fs::write(
668 &graphql_stub_path,
669 r#"declare module "graphql" {
670 export interface GraphQLResolveInfo {}
671}
672"#,
673 )
674 .map_err(|e| QualityError::IoError(e.to_string()))?;
675 fs::write(
676 &graphql_tools_stub_path,
677 r#"declare module "@graphql-tools/schema" {
678 export function makeExecutableSchema(config: {
679 typeDefs: string;
680 resolvers: unknown;
681 }): unknown;
682}
683"#,
684 )
685 .map_err(|e| QualityError::IoError(e.to_string()))?;
686 fs::write(
687 &protobufjs_stub_path,
688 r#"declare module "protobufjs" {
689 const protobuf: Record<string, unknown>;
690 export = protobuf;
691}
692"#,
693 )
694 .map_err(|e| QualityError::IoError(e.to_string()))?;
695
696 Ok(TypeScriptTempProject {
697 _workdir: workdir,
698 config_path,
699 })
700 }
701
702 fn write_temp_elixir_project(&self, code: &str) -> Result<ElixirTempProject, QualityError> {
703 let workdir = tempdir().map_err(|e| QualityError::IoError(e.to_string()))?;
704 let mix_exs = workdir.path().join("mix.exs");
705 let formatter = workdir.path().join(".formatter.exs");
706 let lib_dir = workdir.path().join("lib");
707 let spikard_dir = lib_dir.join("spikard");
708 let generated_path = lib_dir.join("generated.ex");
709
710 fs::create_dir_all(&spikard_dir).map_err(|e| QualityError::IoError(e.to_string()))?;
711
712 fs::write(
713 &mix_exs,
714 r#"defmodule GeneratedValidation.MixProject do
715 use Mix.Project
716
717 def project do
718 [
719 app: :generated_validation,
720 version: "0.1.0",
721 elixir: "~> 1.18",
722 deps: []
723 ]
724 end
725end
726"#,
727 )
728 .map_err(|e| QualityError::IoError(e.to_string()))?;
729 fs::write(
730 &formatter,
731 r#"[
732 inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
733 line_length: 120
734]
735"#,
736 )
737 .map_err(|e| QualityError::IoError(e.to_string()))?;
738 fs::write(
739 spikard_dir.join("router.ex"),
740 r#"defmodule Spikard.Router do
741 defmacro __using__(_opts) do
742 quote do
743 import Spikard.Router
744 Module.register_attribute(__MODULE__, :spikard_routes, accumulate: true)
745 end
746 end
747
748 for method <- ~w(get post put patch delete)a do
749 defmacro unquote(method)(path, handler, opts \\ []) do
750 quote do
751 @spikard_routes {unquote(path), unquote(handler), unquote(opts)}
752 end
753 end
754 end
755end
756"#,
757 )
758 .map_err(|e| QualityError::IoError(e.to_string()))?;
759 fs::write(
760 spikard_dir.join("request.ex"),
761 r#"defmodule Spikard.Request do
762 @type t :: map()
763
764 @spec get_path_param(t(), String.t()) :: term()
765 def get_path_param(_request, _key), do: nil
766
767 @spec get_query_param(t(), String.t()) :: term()
768 def get_query_param(_request, _key), do: nil
769
770 @spec get_header(t(), String.t()) :: term()
771 def get_header(_request, _key), do: nil
772
773 @spec get_cookie(t(), String.t()) :: term()
774 def get_cookie(_request, _key), do: nil
775
776 @spec get_body(t()) :: term()
777 def get_body(request), do: Map.get(request, :body, %{})
778end
779"#,
780 )
781 .map_err(|e| QualityError::IoError(e.to_string()))?;
782 fs::write(
783 spikard_dir.join("response.ex"),
784 r#"defmodule Spikard.Response do
785 @type t :: %{status: non_neg_integer(), headers: [{String.t(), String.t()}], body: term()}
786
787 @spec json(term(), keyword()) :: t()
788 def json(body, opts \\ []) do
789 %{status: Keyword.get(opts, :status, 200), headers: [{"content-type", "application/json"}], body: body}
790 end
791
792 @spec status(non_neg_integer()) :: t()
793 def status(code) do
794 %{status: code, headers: [], body: nil}
795 end
796end
797"#,
798 )
799 .map_err(|e| QualityError::IoError(e.to_string()))?;
800 fs::write(
801 spikard_dir.join("grpc.ex"),
802 r#"defmodule Spikard.Grpc do
803 defmodule Request do
804 @type t :: %{
805 service_name: String.t(),
806 method_name: String.t(),
807 payload: binary(),
808 metadata: %{optional(String.t()) => String.t()}
809 }
810 end
811
812 defmodule Error do
813 defstruct [:code, :message, metadata: %{}]
814 @type t :: %__MODULE__{code: term(), message: String.t(), metadata: map()}
815 end
816
817 defmodule Response do
818 defstruct payload: <<>>, metadata: %{}
819 @type t :: %__MODULE__{payload: binary(), metadata: %{optional(String.t()) => String.t()}}
820
821 @spec error(String.t(), term()) :: {:error, Spikard.Grpc.Error.t()}
822 def error(message, code \\ :internal) do
823 {:error, %Spikard.Grpc.Error{code: code, message: message, metadata: %{}}}
824 end
825 end
826
827 defmodule Service do
828 defstruct services: %{}
829 @type t :: %__MODULE__{services: map()}
830
831 @spec new() :: t()
832 def new, do: %__MODULE__{}
833
834 @spec register(t(), String.t(), String.t(), atom(), function()) :: t()
835 def register(%__MODULE__{services: services} = service, service_name, method_name, rpc_mode, handler) do
836 methods =
837 services
838 |> Map.get(service_name, %{})
839 |> Map.put(method_name, {rpc_mode, handler})
840
841 %{service | services: Map.put(services, service_name, methods)}
842 end
843 end
844end
845"#,
846 )
847 .map_err(|e| QualityError::IoError(e.to_string()))?;
848 fs::write(
849 spikard_dir.join("websocket.ex"),
850 r#"defmodule Spikard.WebSocket do
851 @callback handle_connect(term(), term()) :: {:ok, term()} | {:error, term()}
852 @callback handle_message(term(), term()) ::
853 {:reply, term(), term()} | {:noreply, term()} | {:error, term()}
854 @callback handle_disconnect(term(), term()) :: :ok | {:error, term()}
855
856 defmacro __using__(_opts) do
857 quote do
858 @behaviour Spikard.WebSocket
859
860 @impl true
861 def handle_connect(_ws, _opts), do: {:ok, nil}
862
863 @impl true
864 def handle_message(message, state), do: {:reply, message, state}
865
866 @impl true
867 def handle_disconnect(_ws, _state), do: :ok
868
869 defoverridable handle_connect: 2, handle_message: 2, handle_disconnect: 2
870 end
871 end
872end
873"#,
874 )
875 .map_err(|e| QualityError::IoError(e.to_string()))?;
876 fs::write(
877 spikard_dir.join("sse.ex"),
878 r#"defmodule Spikard.Sse.Event do
879 defstruct [:data, :event, :id]
880 @type t :: %__MODULE__{data: term(), event: String.t() | nil, id: String.t() | nil}
881end
882
883defmodule Spikard.Sse.Producer do
884 @callback init(term()) :: {:ok, term()} | {:error, term()}
885 @callback next_event(term()) ::
886 {:ok, Spikard.Sse.Event.t(), term()} | :done | :error
887
888 defmacro __using__(_opts) do
889 quote do
890 @behaviour Spikard.Sse.Producer
891
892 @impl true
893 def init(_opts), do: {:ok, nil}
894
895 defoverridable init: 1
896 end
897 end
898end
899"#,
900 )
901 .map_err(|e| QualityError::IoError(e.to_string()))?;
902 fs::write(&generated_path, code).map_err(|e| QualityError::IoError(e.to_string()))?;
903
904 Ok(ElixirTempProject {
905 workdir,
906 generated_path,
907 })
908 }
909
910 fn write_php_validation_bootstrap(&self) -> Result<NamedTempFile, QualityError> {
911 self.write_temp_file(
912 r#"<?php
913declare(strict_types=1);
914
915namespace SpikardGenerated;
916
917#[\Attribute(\Attribute::TARGET_METHOD)]
918final class Route
919{
920 public function __construct(
921 public string $path,
922 public array $methods = [],
923 ) {}
924}
925
926namespace Spikard\Handlers;
927
928interface WebSocketHandlerInterface
929{
930 public function onConnect(): void;
931
932 public function onMessage(string $message): void;
933
934 public function onClose(int $code, ?string $reason = null): void;
935}
936
937interface SseEventProducerInterface
938{
939 /** @return \Generator<int, string, mixed, void> */
940 public function __invoke(): \Generator;
941}
942
943namespace Spikard;
944
945final class App
946{
947 public function addWebSocket(
948 string $path,
949 \Spikard\Handlers\WebSocketHandlerInterface $handler
950 ): self {
951 return $this;
952 }
953
954 public function addSse(
955 string $path,
956 \Spikard\Handlers\SseEventProducerInterface $producer
957 ): self {
958 return $this;
959 }
960}
961
962namespace Google\Protobuf\Internal;
963
964class Message {}
965
966namespace GraphQL\Type\Definition;
967
968class Type
969{
970 public static function string(): self
971 {
972 return new self();
973 }
974
975 public static function int(): self
976 {
977 return new self();
978 }
979
980 public static function float(): self
981 {
982 return new self();
983 }
984
985 public static function boolean(): self
986 {
987 return new self();
988 }
989
990 public static function id(): self
991 {
992 return new self();
993 }
994
995 public static function nonNull(self $type): self
996 {
997 return $type;
998 }
999
1000 public static function listOf(self $type): self
1001 {
1002 return $type;
1003 }
1004}
1005
1006class ObjectType extends Type
1007{
1008 /** @param array<string, mixed> $config */
1009 public function __construct(array $config = [])
1010 {
1011 }
1012}
1013
1014class InputObjectType extends Type
1015{
1016 /** @param array<string, mixed> $config */
1017 public function __construct(array $config = [])
1018 {
1019 }
1020}
1021
1022class InterfaceType extends Type
1023{
1024 /** @param array<string, mixed> $config */
1025 public function __construct(array $config = [])
1026 {
1027 }
1028}
1029
1030class UnionType extends Type
1031{
1032 /** @param array<string, mixed> $config */
1033 public function __construct(array $config = [])
1034 {
1035 }
1036}
1037
1038class EnumType extends Type
1039{
1040 /** @param array<string, mixed> $config */
1041 public function __construct(array $config = [])
1042 {
1043 }
1044}
1045
1046namespace GraphQL\Type;
1047
1048class Schema
1049{
1050 /** @param array<string, mixed> $config */
1051 public function __construct(array $config = [])
1052 {
1053 }
1054}
1055"#,
1056 "php",
1057 )
1058 }
1059
1060 fn run_tool(&self, tool: &str, args: &[&str], _code: &str) -> Result<String, QualityError> {
1079 self.run_tool_in_dir(tool, args, Path::new("."), _code)
1080 }
1081
1082 fn run_tool_in_dir(&self, tool: &str, args: &[&str], cwd: &Path, _code: &str) -> Result<String, QualityError> {
1083 self.run_tool_in_dir_with_env(tool, args, cwd, &[], _code)
1084 }
1085
1086 fn run_tool_in_dir_with_env(
1087 &self,
1088 tool: &str,
1089 args: &[&str],
1090 cwd: &Path,
1091 envs: &[(&str, &std::ffi::OsStr)],
1092 _code: &str,
1093 ) -> Result<String, QualityError> {
1094 let max_attempts = 3;
1095 let mut last_err: Option<QualityError> = None;
1096 for _ in 0..max_attempts {
1097 let mut command = Command::new(tool);
1098 command.args(args).current_dir(cwd);
1099 for (key, value) in envs {
1100 command.env(key, value);
1101 }
1102
1103 let output = command.output().map_err(|e| {
1104 if e.kind() == std::io::ErrorKind::NotFound {
1105 QualityError::ToolNotFound(tool.to_string())
1106 } else {
1107 QualityError::IoError(e.to_string())
1108 }
1109 })?;
1110
1111 if output.status.success() {
1112 return Ok(String::from_utf8_lossy(&output.stdout).to_string());
1113 }
1114
1115 let stderr = String::from_utf8_lossy(&output.stderr);
1116 let stdout = String::from_utf8_lossy(&output.stdout);
1117 let message = if stderr.is_empty() {
1118 stdout.to_string()
1119 } else {
1120 stderr.to_string()
1121 };
1122 let is_transient_internal_error = message.contains("INTERNAL ERROR");
1123 last_err = Some(QualityError::ValidationFailed(message));
1124 if !is_transient_internal_error {
1125 break;
1126 }
1127 }
1128 Err(last_err.expect("at least one attempt always runs"))
1129 }
1130}
1131
1132struct RustTempProject {
1133 workdir: TempDir,
1134 manifest_path: PathBuf,
1135}
1136
1137struct TypeScriptTempProject {
1138 _workdir: TempDir,
1139 config_path: PathBuf,
1140}
1141
1142struct PythonTempProject {
1143 workdir: TempDir,
1144 entry_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 format!(
1293 r#"[package]
1294name = "spikard_codegen_validation"
1295version = "0.1.0"
1296edition = "2024"
1297
1298[lib]
1299path = "src/lib.rs"
1300
1301[dependencies]
1302alloc-no-stdlib = "=2.0.4"
1303alloc-stdlib = "=0.2.2"
1304brotli-decompressor = "=5.0.1"
1305async-graphql = "7"
1306async-trait = "0.1"
1307axum = "0.8"
1308bytes = "1"
1309chrono = {{ version = "0.4", features = ["serde"] }}
1310futures-core = "0.3"
1311futures-util = "0.3"
1312prost = "0.14"
1313schemars = {{ version = "1.2", features = ["derive", "chrono04", "uuid1"] }}
1314serde = {{ version = "1", features = ["derive"] }}
1315serde_json = "1"
1316spikard = {{ path = "{}" }}
1317tokio = {{ version = "1", features = ["full"] }}
1318tonic = "0.14"
1319uuid = {{ version = "1", features = ["serde", "v4"] }}
1320"#,
1321 spikard_path.display()
1322 )
1323}
1324
1325fn workspace_root() -> &'static Path {
1326 Path::new(env!("CARGO_MANIFEST_DIR"))
1327 .parent()
1328 .and_then(Path::parent)
1329 .expect("workspace root should be two levels above crates/spikard-cli")
1330}
1331
1332#[cfg(test)]
1333mod tests {
1334 use super::*;
1335
1336 #[test]
1337 fn test_validation_report_is_valid() {
1338 let mut report = ValidationReport::new();
1339 assert!(!report.is_valid());
1340
1341 report.syntax_passed = true;
1342 report.types_passed = true;
1343 report.lint_passed = true;
1344 assert!(report.is_valid());
1345
1346 report.add_error("test error".to_string());
1347 assert!(!report.is_valid());
1348 }
1349
1350 #[test]
1351 fn test_validation_report_error_count() {
1352 let mut report = ValidationReport::new();
1353 assert_eq!(report.error_count(), 0);
1354
1355 report.add_error("error 1".to_string());
1356 report.add_error("error 2".to_string());
1357 assert_eq!(report.error_count(), 2);
1358 }
1359
1360 #[test]
1361 fn test_quality_validator_creation() {
1362 let validator = QualityValidator::new(TargetLanguage::Python);
1363 assert_eq!(validator.language, TargetLanguage::Python);
1364
1365 let validator = QualityValidator::new(TargetLanguage::TypeScript);
1366 assert_eq!(validator.language, TargetLanguage::TypeScript);
1367
1368 let validator = QualityValidator::new(TargetLanguage::Elixir);
1369 assert_eq!(validator.language, TargetLanguage::Elixir);
1370 }
1371
1372 #[test]
1373 fn test_quality_error_display() {
1374 let err = QualityError::ToolNotFound("pyrefly".to_string());
1375 assert_eq!(err.to_string(), "Required validation tool not found: pyrefly");
1376
1377 let err = QualityError::ValidationFailed("syntax error".to_string());
1378 assert!(err.to_string().contains("Validation failed"));
1379
1380 let err = QualityError::IoError("file not found".to_string());
1381 assert!(err.to_string().contains("I/O error"));
1382 }
1383
1384 #[test]
1385 fn test_validation_report_display() {
1386 let mut report = ValidationReport::new();
1387 report.syntax_passed = true;
1388 report.types_passed = false;
1389 report.add_error("type mismatch".to_string());
1390
1391 let display = report.to_string();
1392 assert!(display.contains("Syntax: PASS"));
1393 assert!(display.contains("Types: FAIL"));
1394 assert!(display.contains("type mismatch"));
1395 }
1396
1397 #[test]
1398 fn test_rust_quality_validator_accepts_valid_code() {
1399 let validator = QualityValidator::new(TargetLanguage::Rust);
1400 validator
1401 .validate_syntax("pub fn add(a: i32, b: i32) -> i32 { a + b }")
1402 .expect("rust syntax validation should pass");
1403 validator
1404 .validate_types("pub fn add(a: i32, b: i32) -> i32 { a + b }")
1405 .expect("rust type validation should pass");
1406 }
1407}