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)?;
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 pub fn validate_all(&self, code: &str) -> Result<ValidationReport, QualityError> {
497 let mut report = ValidationReport::new();
498
499 match self.validate_syntax(code) {
500 Ok(()) => report.syntax_passed = true,
501 Err(e) => {
502 report.syntax_passed = false;
503 report.add_error(format!("Syntax: {e}"));
504 }
505 }
506
507 match self.validate_types(code) {
508 Ok(()) => report.types_passed = true,
509 Err(e) => {
510 report.types_passed = false;
511 report.add_error(format!("Types: {e}"));
512 }
513 }
514
515 match self.validate_lint(code) {
516 Ok(()) => report.lint_passed = true,
517 Err(e) => {
518 report.lint_passed = false;
519 report.add_error(format!("Lint: {e}"));
520 }
521 }
522
523 Ok(report)
524 }
525
526 fn write_temp_file(&self, code: &str, ext: &str) -> Result<NamedTempFile, QualityError> {
538 let mut file = Builder::new()
539 .prefix("generated_")
540 .suffix(&format!(".{ext}"))
541 .tempfile()
542 .map_err(|e: std::io::Error| QualityError::IoError(e.to_string()))?;
543 file.write_all(code.as_bytes())
544 .map_err(|e: std::io::Error| QualityError::IoError(e.to_string()))?;
545 file.flush()
546 .map_err(|e: std::io::Error| QualityError::IoError(e.to_string()))?;
547 Ok(file)
548 }
549
550 fn write_temp_rust_project(&self, code: &str) -> Result<RustTempProject, QualityError> {
551 let workdir = tempdir().map_err(|e| QualityError::IoError(e.to_string()))?;
552 let src_dir = workdir.path().join("src");
553 fs::create_dir_all(&src_dir).map_err(|e| QualityError::IoError(e.to_string()))?;
554
555 let manifest_path = workdir.path().join("Cargo.toml");
556 let lib_path = src_dir.join("lib.rs");
557
558 fs::write(&manifest_path, rust_temp_manifest()).map_err(|e| QualityError::IoError(e.to_string()))?;
559 fs::write(&lib_path, code).map_err(|e| QualityError::IoError(e.to_string()))?;
560
561 Ok(RustTempProject { workdir, manifest_path })
562 }
563
564 fn write_temp_python_project(&self, code: &str) -> Result<PythonTempProject, QualityError> {
565 let workdir = tempdir().map_err(|e| QualityError::IoError(e.to_string()))?;
566 let entry_path = workdir.path().join("generated.py");
567 let stub_path = workdir.path().join("stubs");
568 let config_path = workdir.path().join("pyrefly.toml");
569
570 fs::write(&entry_path, code).map_err(|e| QualityError::IoError(e.to_string()))?;
571 fs::create_dir_all(&stub_path).map_err(|e| QualityError::IoError(e.to_string()))?;
572 write_python_validation_stubs(&stub_path)?;
573
574 fs::write(
575 &config_path,
576 "python-version = \"3.10\"\npreset = \"strict\"\nproject-includes = [\"generated.py\"]\nsearch-path = [\"stubs\"]\n",
577 )
578 .map_err(|e| QualityError::IoError(e.to_string()))?;
579
580 Ok(PythonTempProject { workdir, entry_path })
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 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 let max_attempts = 3;
1090 let mut last_err: Option<QualityError> = None;
1091 for _ in 0..max_attempts {
1092 let mut command = Command::new(tool);
1093 command.args(args).current_dir(cwd);
1094 for (key, value) in envs {
1095 command.env(key, value);
1096 }
1097
1098 let output = command.output().map_err(|e| {
1099 if e.kind() == std::io::ErrorKind::NotFound {
1100 QualityError::ToolNotFound(tool.to_string())
1101 } else {
1102 QualityError::IoError(e.to_string())
1103 }
1104 })?;
1105
1106 if output.status.success() {
1107 return Ok(String::from_utf8_lossy(&output.stdout).to_string());
1108 }
1109
1110 let stderr = String::from_utf8_lossy(&output.stderr);
1111 let stdout = String::from_utf8_lossy(&output.stdout);
1112 let message = if stderr.is_empty() {
1113 stdout.to_string()
1114 } else {
1115 stderr.to_string()
1116 };
1117 let is_transient_internal_error = message.contains("INTERNAL ERROR");
1118 last_err = Some(QualityError::ValidationFailed(message));
1119 if !is_transient_internal_error {
1120 break;
1121 }
1122 }
1123 Err(last_err.expect("at least one attempt always runs"))
1124 }
1125}
1126
1127struct RustTempProject {
1128 workdir: TempDir,
1129 manifest_path: PathBuf,
1130}
1131
1132struct TypeScriptTempProject {
1133 _workdir: TempDir,
1134 config_path: PathBuf,
1135}
1136
1137struct PythonTempProject {
1138 workdir: TempDir,
1139 entry_path: PathBuf,
1140}
1141
1142struct ElixirTempProject {
1143 workdir: TempDir,
1144 generated_path: PathBuf,
1145}
1146
1147fn write_python_validation_stubs(root: &Path) -> Result<(), QualityError> {
1148 write_stub_file(
1149 &root.join("msgspec.py"),
1150 r#"from typing import Any, TypeVar, cast
1151
1152T = TypeVar("T")
1153
1154class Struct:
1155 def __init__(self, **kwargs: object) -> None: ...
1156
1157 def __init_subclass__(cls, *, frozen: bool = False, kw_only: bool = False) -> None: ...
1158
1159def field(*, default: object = ..., name: str | None = None) -> Any: ...
1160
1161def convert(value: object, *, type: object) -> Any:
1162 return value
1163
1164def to_builtins(value: object) -> object: ...
1165"#,
1166 )?;
1167
1168 write_stub_file(&root.join("graphql.py"), "class GraphQLResolveInfo: ...\n")?;
1169
1170 write_stub_file(
1171 &root.join("ariadne.py"),
1172 r#"from __future__ import annotations
1173
1174from collections.abc import Callable
1175from typing import Any
1176
1177Resolver = Callable[..., Any]
1178
1179class QueryType:
1180 def set_field(self, _name: str, _resolver: Resolver) -> None: ...
1181
1182class MutationType:
1183 def set_field(self, _name: str, _resolver: Resolver) -> None: ...
1184
1185class SubscriptionType:
1186 def set_field(self, _name: str, _resolver: Resolver) -> None: ...
1187 def set_source(self, _name: str, _resolver: Resolver) -> None: ...
1188
1189def make_executable_schema(*_args: object, **_kwargs: object) -> object:
1190 return object()
1191"#,
1192 )?;
1193
1194 let spikard_dir = root.join("spikard");
1195 fs::create_dir_all(&spikard_dir).map_err(|e| QualityError::IoError(e.to_string()))?;
1196 write_stub_file(
1197 &spikard_dir.join("__init__.py"),
1198 r#"from __future__ import annotations
1199
1200from collections.abc import Callable
1201from typing import Generic, TypeVar
1202
1203F = TypeVar("F", bound=Callable[..., object])
1204T = TypeVar("T")
1205
1206class Body(Generic[T]): ...
1207
1208class Path(Generic[T]): ...
1209
1210class Query(Generic[T]):
1211 def __init__(self, default: T | None = None) -> None:
1212 self.default = default
1213
1214class Request: ...
1215
1216class Spikard:
1217 def route(self, *_args: object, **_kwargs: object) -> Callable[[F], F]:
1218 def decorator(fn: F) -> F:
1219 return fn
1220 return decorator
1221
1222 def post(self, *_args: object, **_kwargs: object) -> Callable[[F], F]:
1223 def decorator(fn: F) -> F:
1224 return fn
1225 return decorator
1226
1227 def get(self, *_args: object, **_kwargs: object) -> Callable[[F], F]:
1228 def decorator(fn: F) -> F:
1229 return fn
1230 return decorator
1231
1232 def run(self, *_args: object, **_kwargs: object) -> None:
1233 return None
1234
1235def route(*_args: object, **_kwargs: object) -> Callable[[F], F]:
1236 def decorator(fn: F) -> F:
1237 return fn
1238 return decorator
1239
1240def websocket(*_args: object, **_kwargs: object) -> Callable[[F], F]:
1241 def decorator(fn: F) -> F:
1242 return fn
1243 return decorator
1244
1245def sse(*_args: object, **_kwargs: object) -> Callable[[F], F]:
1246 def decorator(fn: F) -> F:
1247 return fn
1248 return decorator
1249"#,
1250 )?;
1251 write_stub_file(
1252 &spikard_dir.join("config.py"),
1253 r#"class ServerConfig:
1254 def __init__(self, host: str = "0.0.0.0", port: int = 8000) -> None:
1255 self.host = host
1256 self.port = port
1257"#,
1258 )?;
1259
1260 let google_protobuf_dir = root.join("google").join("protobuf");
1261 fs::create_dir_all(&google_protobuf_dir).map_err(|e| QualityError::IoError(e.to_string()))?;
1262 write_stub_file(&root.join("google").join("__init__.py"), "")?;
1263 write_stub_file(&google_protobuf_dir.join("__init__.py"), "")?;
1264 write_stub_file(&google_protobuf_dir.join("message.py"), "class Message: ...\n")?;
1265
1266 let websockets_dir = root.join("websockets");
1267 fs::create_dir_all(&websockets_dir).map_err(|e| QualityError::IoError(e.to_string()))?;
1268 write_stub_file(&websockets_dir.join("__init__.py"), "")?;
1269 write_stub_file(
1270 &websockets_dir.join("client.py"),
1271 "class WebSocketClientProtocol: ...\n",
1272 )?;
1273
1274 Ok(())
1275}
1276
1277fn write_stub_file(path: &Path, contents: &str) -> Result<(), QualityError> {
1278 if let Some(parent) = path.parent() {
1279 fs::create_dir_all(parent).map_err(|e| QualityError::IoError(e.to_string()))?;
1280 }
1281 fs::write(path, contents).map_err(|e| QualityError::IoError(e.to_string()))
1282}
1283
1284fn rust_temp_manifest() -> String {
1285 let spikard_path = workspace_root().join("crates/spikard");
1286
1287 format!(
1288 r#"[package]
1289name = "spikard_codegen_validation"
1290version = "0.1.0"
1291edition = "2024"
1292
1293[lib]
1294path = "src/lib.rs"
1295
1296[dependencies]
1297alloc-no-stdlib = "=2.0.4"
1298alloc-stdlib = "=0.2.2"
1299brotli-decompressor = "=5.0.1"
1300async-graphql = "7"
1301async-trait = "0.1"
1302axum = "0.8"
1303bytes = "1"
1304chrono = {{ version = "0.4", features = ["serde"] }}
1305futures-core = "0.3"
1306futures-util = "0.3"
1307prost = "0.14"
1308schemars = {{ version = "1.2", features = ["derive", "chrono04", "uuid1"] }}
1309serde = {{ version = "1", features = ["derive"] }}
1310serde_json = "1"
1311spikard = {{ path = "{}" }}
1312tokio = {{ version = "1", features = ["full"] }}
1313tonic = "0.14"
1314uuid = {{ version = "1", features = ["serde", "v4"] }}
1315"#,
1316 spikard_path.display()
1317 )
1318}
1319
1320fn workspace_root() -> &'static Path {
1321 Path::new(env!("CARGO_MANIFEST_DIR"))
1322 .parent()
1323 .and_then(Path::parent)
1324 .expect("workspace root should be two levels above crates/spikard-cli")
1325}
1326
1327#[cfg(test)]
1328mod tests {
1329 use super::*;
1330
1331 #[test]
1332 fn test_validation_report_is_valid() {
1333 let mut report = ValidationReport::new();
1334 assert!(!report.is_valid());
1335
1336 report.syntax_passed = true;
1337 report.types_passed = true;
1338 report.lint_passed = true;
1339 assert!(report.is_valid());
1340
1341 report.add_error("test error".to_string());
1342 assert!(!report.is_valid());
1343 }
1344
1345 #[test]
1346 fn test_validation_report_error_count() {
1347 let mut report = ValidationReport::new();
1348 assert_eq!(report.error_count(), 0);
1349
1350 report.add_error("error 1".to_string());
1351 report.add_error("error 2".to_string());
1352 assert_eq!(report.error_count(), 2);
1353 }
1354
1355 #[test]
1356 fn test_quality_validator_creation() {
1357 let validator = QualityValidator::new(TargetLanguage::Python);
1358 assert_eq!(validator.language, TargetLanguage::Python);
1359
1360 let validator = QualityValidator::new(TargetLanguage::TypeScript);
1361 assert_eq!(validator.language, TargetLanguage::TypeScript);
1362
1363 let validator = QualityValidator::new(TargetLanguage::Elixir);
1364 assert_eq!(validator.language, TargetLanguage::Elixir);
1365 }
1366
1367 #[test]
1368 fn test_quality_error_display() {
1369 let err = QualityError::ToolNotFound("pyrefly".to_string());
1370 assert_eq!(err.to_string(), "Required validation tool not found: pyrefly");
1371
1372 let err = QualityError::ValidationFailed("syntax error".to_string());
1373 assert!(err.to_string().contains("Validation failed"));
1374
1375 let err = QualityError::IoError("file not found".to_string());
1376 assert!(err.to_string().contains("I/O error"));
1377 }
1378
1379 #[test]
1380 fn test_validation_report_display() {
1381 let mut report = ValidationReport::new();
1382 report.syntax_passed = true;
1383 report.types_passed = false;
1384 report.add_error("type mismatch".to_string());
1385
1386 let display = report.to_string();
1387 assert!(display.contains("Syntax: PASS"));
1388 assert!(display.contains("Types: FAIL"));
1389 assert!(display.contains("type mismatch"));
1390 }
1391
1392 #[test]
1393 fn test_rust_quality_validator_accepts_valid_code() {
1394 let validator = QualityValidator::new(TargetLanguage::Rust);
1395 validator
1396 .validate_syntax("pub fn add(a: i32, b: i32) -> i32 { a + b }")
1397 .expect("rust syntax validation should pass");
1398 validator
1399 .validate_types("pub fn add(a: i32, b: i32) -> i32 { a + b }")
1400 .expect("rust type validation should pass");
1401 }
1402}