1use crate::codegen::TargetLanguage;
6use std::fmt;
7use std::fs;
8use std::io::Write;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11use tempfile::{Builder, NamedTempFile, TempDir, tempdir};
12
13#[derive(Debug)]
15pub enum QualityError {
16 ToolNotFound(String),
18 ValidationFailed(String),
20 IoError(String),
22}
23
24impl fmt::Display for QualityError {
25 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26 match self {
27 Self::ToolNotFound(tool) => {
28 write!(f, "Required validation tool not found: {tool}")
29 }
30 Self::ValidationFailed(msg) => {
31 write!(f, "Validation failed: {msg}")
32 }
33 Self::IoError(msg) => {
34 write!(f, "I/O error: {msg}")
35 }
36 }
37 }
38}
39
40impl std::error::Error for QualityError {}
41
42impl From<std::io::Error> for QualityError {
43 fn from(err: std::io::Error) -> Self {
44 Self::IoError(err.to_string())
45 }
46}
47
48#[derive(Debug, Clone)]
50pub struct ValidationReport {
51 pub syntax_passed: bool,
53 pub types_passed: bool,
55 pub lint_passed: bool,
57 pub errors: Vec<String>,
59}
60
61impl ValidationReport {
62 const fn new() -> Self {
64 Self {
65 syntax_passed: false,
66 types_passed: false,
67 lint_passed: false,
68 errors: Vec::new(),
69 }
70 }
71
72 #[must_use]
76 pub const fn is_valid(&self) -> bool {
77 self.syntax_passed && self.types_passed && self.lint_passed && self.errors.is_empty()
78 }
79
80 #[must_use]
82 pub const fn error_count(&self) -> usize {
83 self.errors.len()
84 }
85
86 fn add_error(&mut self, error: String) {
88 self.errors.push(error);
89 }
90}
91
92impl fmt::Display for ValidationReport {
93 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 writeln!(f, "Validation Report")?;
95 writeln!(f, " Syntax: {}", if self.syntax_passed { "PASS" } else { "FAIL" })?;
96 writeln!(f, " Types: {}", if self.types_passed { "PASS" } else { "FAIL" })?;
97 writeln!(f, " Lint: {}", if self.lint_passed { "PASS" } else { "FAIL" })?;
98
99 if !self.errors.is_empty() {
100 writeln!(f, " Errors: {}", self.error_count())?;
101 for error in &self.errors {
102 writeln!(f, " - {error}")?;
103 }
104 }
105
106 Ok(())
107 }
108}
109
110#[derive(Debug)]
129pub struct QualityValidator {
130 language: TargetLanguage,
131}
132
133impl QualityValidator {
134 #[must_use]
146 pub const fn new(language: TargetLanguage) -> Self {
147 Self { language }
148 }
149
150 pub fn validate_syntax(&self, code: &str) -> Result<(), QualityError> {
177 match self.language {
178 TargetLanguage::Python => {
179 let project = self.write_temp_python_project(code)?;
180 self.run_tool_in_dir(
181 "python3",
182 &[
183 "-m",
184 "py_compile",
185 project.entry_path.file_name().unwrap().to_str().unwrap(),
186 ],
187 project.workdir.path(),
188 code,
189 )
190 .map(|_| ())
191 }
192 TargetLanguage::TypeScript => {
193 let project = self.write_temp_typescript_project(code)?;
194 self.run_tool_in_dir(
195 "pnpm",
196 &[
197 "exec",
198 "tsc",
199 "--noEmit",
200 "--project",
201 project.config_path.to_str().unwrap(),
202 ],
203 Path::new("."),
204 code,
205 )
206 .map(|_| ())
207 }
208 TargetLanguage::Rust => {
209 let project = self.write_temp_rust_project(code)?;
210 self.run_tool_in_dir(
211 "cargo",
212 &["check", "--manifest-path", project.manifest_path.to_str().unwrap()],
213 project.workdir.path(),
214 code,
215 )
216 .map(|_| ())
217 }
218 TargetLanguage::Ruby => {
219 let file = self.write_temp_file(code, "rb")?;
220 self.run_tool("ruby", &["-c", file.path().to_str().unwrap()], code)
221 .map(|_| ())
222 }
223 TargetLanguage::Php => {
224 let file = self.write_temp_file(code, "php")?;
225 self.run_tool("php", &["-l", file.path().to_str().unwrap()], code)
226 .map(|_| ())
227 }
228 TargetLanguage::Elixir => {
229 let project = self.write_temp_elixir_project(code)?;
230 self.run_tool_in_dir(
231 "mix",
232 &["compile", "--warnings-as-errors"],
233 project.workdir.path(),
234 code,
235 )
236 .map(|_| ())
237 }
238 }
239 }
240
241 pub fn validate_types(&self, code: &str) -> Result<(), QualityError> {
270 match self.language {
271 TargetLanguage::Python => {
272 let project = self.write_temp_python_project(code)?;
273 self.run_tool_in_dir_with_env(
274 "uv",
275 &["run", "mypy", "--strict", project.entry_path.to_str().unwrap()],
276 workspace_root(),
277 &[("MYPYPATH", project.stub_path.as_os_str())],
278 code,
279 )
280 .map(|_| ())
281 }
282 TargetLanguage::TypeScript => {
283 let project = self.write_temp_typescript_project(code)?;
284 self.run_tool_in_dir(
285 "pnpm",
286 &[
287 "exec",
288 "tsc",
289 "--strict",
290 "--noEmit",
291 "--project",
292 project.config_path.to_str().unwrap(),
293 ],
294 Path::new("."),
295 code,
296 )
297 .map(|_| ())
298 }
299 TargetLanguage::Ruby => {
300 let file = self.write_temp_file(code, "rb")?;
301 let package_dir = workspace_root().join("packages/ruby");
302 self.run_tool_in_dir(
303 "bundle",
304 &["exec", "steep", "check", file.path().to_str().unwrap()],
305 &package_dir,
306 code,
307 )
308 .map(|_| ())
309 }
310 TargetLanguage::Rust => {
311 let project = self.write_temp_rust_project(code)?;
312 self.run_tool_in_dir(
313 "cargo",
314 &["check", "--manifest-path", project.manifest_path.to_str().unwrap()],
315 project.workdir.path(),
316 code,
317 )
318 .map(|_| ())
319 }
320 TargetLanguage::Php => {
321 Ok(())
323 }
324 TargetLanguage::Elixir => Ok(()),
325 }
326 }
327
328 pub fn validate_lint(&self, code: &str) -> Result<(), QualityError> {
355 match self.language {
356 TargetLanguage::Python => {
357 let project = self.write_temp_python_project(code)?;
358 self.run_tool_in_dir(
359 "uv",
360 &["run", "ruff", "check", project.entry_path.to_str().unwrap()],
361 workspace_root(),
362 code,
363 )
364 .map(|_| ())
365 }
366 TargetLanguage::TypeScript => Ok(()),
367 TargetLanguage::Ruby => {
368 let file = self.write_temp_file(code, "rb")?;
369 let package_dir = workspace_root().join("packages/ruby");
370 self.run_tool_in_dir(
371 "bundle",
372 &[
373 "exec",
374 "rubocop",
375 "--disable-pending-cops",
376 "--except",
377 "Naming/FileName",
378 file.path().to_str().unwrap(),
379 ],
380 &package_dir,
381 code,
382 )
383 .map(|_| ())
384 }
385 TargetLanguage::Php => {
386 let file = self.write_temp_file(code, "php")?;
387 let bootstrap = self.write_php_validation_bootstrap()?;
388 let package_dir = workspace_root().join("packages/php");
389 self.run_tool_in_dir(
390 "composer",
391 &[
392 "exec",
393 "--",
394 "phpstan",
395 "analyse",
396 "--no-progress",
397 "--error-format=raw",
398 "--level=max",
399 "--autoload-file",
400 bootstrap.path().to_str().unwrap(),
401 file.path().to_str().unwrap(),
402 ],
403 &package_dir,
404 code,
405 )
406 .map(|_| ())
407 }
408 TargetLanguage::Elixir => {
409 let project = self.write_temp_elixir_project(code)?;
410 let generated = project.generated_path.strip_prefix(project.workdir.path()).unwrap();
411 self.run_tool_in_dir(
412 "mix",
413 &[
414 "format",
415 "--check-formatted",
416 generated.to_str().unwrap(),
417 "mix.exs",
418 ".formatter.exs",
419 "lib/spikard/router.ex",
420 "lib/spikard/request.ex",
421 "lib/spikard/response.ex",
422 ],
423 project.workdir.path(),
424 code,
425 )
426 .map(|_| ())
427 }
428 TargetLanguage::Rust => {
429 let project = self.write_temp_rust_project(code)?;
430 self.run_tool_in_dir(
431 "cargo",
432 &[
433 "clippy",
434 "--manifest-path",
435 project.manifest_path.to_str().unwrap(),
436 "--",
437 "-D",
438 "warnings",
439 ],
440 project.workdir.path(),
441 code,
442 )
443 .map(|_| ())
444 }
445 }
446 }
447
448 pub fn validate_all(&self, code: &str) -> Result<ValidationReport, QualityError> {
476 let mut report = ValidationReport::new();
477
478 match self.validate_syntax(code) {
480 Ok(()) => report.syntax_passed = true,
481 Err(e) => {
482 report.syntax_passed = false;
483 report.add_error(format!("Syntax: {e}"));
484 }
485 }
486
487 match self.validate_types(code) {
489 Ok(()) => report.types_passed = true,
490 Err(e) => {
491 report.types_passed = false;
492 report.add_error(format!("Types: {e}"));
493 }
494 }
495
496 match self.validate_lint(code) {
498 Ok(()) => report.lint_passed = true,
499 Err(e) => {
500 report.lint_passed = false;
501 report.add_error(format!("Lint: {e}"));
502 }
503 }
504
505 Ok(report)
506 }
507
508 fn write_temp_file(&self, code: &str, ext: &str) -> Result<NamedTempFile, QualityError> {
520 let mut file = Builder::new()
521 .prefix("generated_")
522 .suffix(&format!(".{ext}"))
523 .tempfile()
524 .map_err(|e: std::io::Error| QualityError::IoError(e.to_string()))?;
525 file.write_all(code.as_bytes())
526 .map_err(|e: std::io::Error| QualityError::IoError(e.to_string()))?;
527 file.flush()
528 .map_err(|e: std::io::Error| QualityError::IoError(e.to_string()))?;
529 Ok(file)
530 }
531
532 fn write_temp_rust_project(&self, code: &str) -> Result<RustTempProject, QualityError> {
533 let workdir = tempdir().map_err(|e| QualityError::IoError(e.to_string()))?;
534 let src_dir = workdir.path().join("src");
535 fs::create_dir_all(&src_dir).map_err(|e| QualityError::IoError(e.to_string()))?;
536
537 let manifest_path = workdir.path().join("Cargo.toml");
538 let lib_path = src_dir.join("lib.rs");
539
540 fs::write(&manifest_path, rust_temp_manifest()).map_err(|e| QualityError::IoError(e.to_string()))?;
541 fs::write(&lib_path, code).map_err(|e| QualityError::IoError(e.to_string()))?;
542
543 Ok(RustTempProject { workdir, manifest_path })
544 }
545
546 fn write_temp_python_project(&self, code: &str) -> Result<PythonTempProject, QualityError> {
547 let workdir = tempdir().map_err(|e| QualityError::IoError(e.to_string()))?;
548 let entry_path = workdir.path().join("generated.py");
549 let stub_path = workdir.path().join("stubs");
550
551 fs::write(&entry_path, code).map_err(|e| QualityError::IoError(e.to_string()))?;
552 fs::create_dir_all(&stub_path).map_err(|e| QualityError::IoError(e.to_string()))?;
553 write_python_validation_stubs(&stub_path)?;
554
555 Ok(PythonTempProject {
556 workdir,
557 entry_path,
558 stub_path,
559 })
560 }
561
562 fn write_temp_typescript_project(&self, code: &str) -> Result<TypeScriptTempProject, QualityError> {
563 let workdir = tempdir().map_err(|e| QualityError::IoError(e.to_string()))?;
564 let entry_path = workdir.path().join("generated.ts");
565 let config_path = workdir.path().join("tsconfig.json");
566 let spikard_stub_path = workdir.path().join("spikard.d.ts");
567 let zod_stub_path = workdir.path().join("zod.d.ts");
568 let graphql_stub_path = workdir.path().join("graphql.d.ts");
569 let graphql_tools_stub_path = workdir.path().join("graphql-tools-schema.d.ts");
570 let protobufjs_stub_path = workdir.path().join("protobufjs.d.ts");
571
572 fs::write(&entry_path, code).map_err(|e| QualityError::IoError(e.to_string()))?;
573 fs::write(
574 &config_path,
575 r#"{
576 "compilerOptions": {
577 "target": "ES2022",
578 "module": "ESNext",
579 "moduleResolution": "Bundler",
580 "strict": true,
581 "skipLibCheck": true,
582 "noEmit": true
583 },
584 "files": [
585 "generated.ts",
586 "spikard.d.ts",
587 "zod.d.ts",
588 "graphql.d.ts",
589 "graphql-tools-schema.d.ts",
590 "protobufjs.d.ts"
591 ]
592}
593"#,
594 )
595 .map_err(|e| QualityError::IoError(e.to_string()))?;
596 fs::write(
597 &spikard_stub_path,
598 r#"declare module "spikard" {
599 export type RouteMetadata = {
600 method: string;
601 path: string;
602 handler_name: string;
603 is_async: boolean;
604 };
605
606 export type SpikardApp = {
607 routes: RouteMetadata[];
608 handlers: Record<string, unknown>;
609 };
610
611 export class StreamingResponse {
612 constructor(body?: unknown, init?: unknown);
613 }
614
615 export class Spikard {
616 start(config?: unknown): Promise<void>;
617 }
618
619 export type Body<T> = T;
620 export type Path<T> = T;
621 export type Query<T> = T;
622 export type Request = Record<string, unknown>;
623
624 export function route(...args: unknown[]): any;
625}
626"#,
627 )
628 .map_err(|e| QualityError::IoError(e.to_string()))?;
629 fs::write(
630 &zod_stub_path,
631 r#"declare module "zod" {
632 export namespace z {
633 export type infer<T> = any;
634 }
635
636 export const z: any;
637}
638"#,
639 )
640 .map_err(|e| QualityError::IoError(e.to_string()))?;
641 fs::write(
642 &graphql_stub_path,
643 r#"declare module "graphql" {
644 export interface GraphQLResolveInfo {}
645}
646"#,
647 )
648 .map_err(|e| QualityError::IoError(e.to_string()))?;
649 fs::write(
650 &graphql_tools_stub_path,
651 r#"declare module "@graphql-tools/schema" {
652 export function makeExecutableSchema(config: {
653 typeDefs: string;
654 resolvers: unknown;
655 }): unknown;
656}
657"#,
658 )
659 .map_err(|e| QualityError::IoError(e.to_string()))?;
660 fs::write(
661 &protobufjs_stub_path,
662 r#"declare module "protobufjs" {
663 const protobuf: Record<string, unknown>;
664 export = protobuf;
665}
666"#,
667 )
668 .map_err(|e| QualityError::IoError(e.to_string()))?;
669
670 Ok(TypeScriptTempProject {
671 workdir,
672 entry_path,
673 config_path,
674 })
675 }
676
677 fn write_temp_elixir_project(&self, code: &str) -> Result<ElixirTempProject, QualityError> {
678 let workdir = tempdir().map_err(|e| QualityError::IoError(e.to_string()))?;
679 let mix_exs = workdir.path().join("mix.exs");
680 let formatter = workdir.path().join(".formatter.exs");
681 let lib_dir = workdir.path().join("lib");
682 let spikard_dir = lib_dir.join("spikard");
683 let generated_path = lib_dir.join("generated.ex");
684
685 fs::create_dir_all(&spikard_dir).map_err(|e| QualityError::IoError(e.to_string()))?;
686
687 fs::write(
688 &mix_exs,
689 r#"defmodule GeneratedValidation.MixProject do
690 use Mix.Project
691
692 def project do
693 [
694 app: :generated_validation,
695 version: "0.1.0",
696 elixir: "~> 1.18",
697 deps: []
698 ]
699 end
700end
701"#,
702 )
703 .map_err(|e| QualityError::IoError(e.to_string()))?;
704 fs::write(
705 &formatter,
706 r#"[
707 inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
708 line_length: 120
709]
710"#,
711 )
712 .map_err(|e| QualityError::IoError(e.to_string()))?;
713 fs::write(
714 spikard_dir.join("router.ex"),
715 r#"defmodule Spikard.Router do
716 defmacro __using__(_opts) do
717 quote do
718 import Spikard.Router
719 Module.register_attribute(__MODULE__, :spikard_routes, accumulate: true)
720 end
721 end
722
723 for method <- ~w(get post put patch delete)a do
724 defmacro unquote(method)(path, handler, opts \\ []) do
725 quote do
726 @spikard_routes {unquote(path), unquote(handler), unquote(opts)}
727 end
728 end
729 end
730end
731"#,
732 )
733 .map_err(|e| QualityError::IoError(e.to_string()))?;
734 fs::write(
735 spikard_dir.join("request.ex"),
736 r#"defmodule Spikard.Request do
737 @type t :: map()
738
739 @spec get_path_param(t(), String.t()) :: term()
740 def get_path_param(_request, _key), do: nil
741
742 @spec get_query_param(t(), String.t()) :: term()
743 def get_query_param(_request, _key), do: nil
744
745 @spec get_header(t(), String.t()) :: term()
746 def get_header(_request, _key), do: nil
747
748 @spec get_cookie(t(), String.t()) :: term()
749 def get_cookie(_request, _key), do: nil
750
751 @spec get_body(t()) :: term()
752 def get_body(_request), do: %{}
753end
754"#,
755 )
756 .map_err(|e| QualityError::IoError(e.to_string()))?;
757 fs::write(
758 spikard_dir.join("response.ex"),
759 r#"defmodule Spikard.Response do
760 @type t :: %{status: non_neg_integer(), headers: [{String.t(), String.t()}], body: term()}
761
762 @spec json(term(), keyword()) :: t()
763 def json(body, opts \\ []) do
764 %{status: Keyword.get(opts, :status, 200), headers: [{"content-type", "application/json"}], body: body}
765 end
766
767 @spec status(non_neg_integer()) :: t()
768 def status(code) do
769 %{status: code, headers: [], body: nil}
770 end
771end
772"#,
773 )
774 .map_err(|e| QualityError::IoError(e.to_string()))?;
775 fs::write(
776 spikard_dir.join("grpc.ex"),
777 r#"defmodule Spikard.Grpc do
778 defmodule Request do
779 @type t :: %{
780 service_name: String.t(),
781 method_name: String.t(),
782 payload: binary(),
783 metadata: %{optional(String.t()) => String.t()}
784 }
785 end
786
787 defmodule Error do
788 defstruct [:code, :message, metadata: %{}]
789 @type t :: %__MODULE__{code: term(), message: String.t(), metadata: map()}
790 end
791
792 defmodule Response do
793 defstruct payload: <<>>, metadata: %{}
794 @type t :: %__MODULE__{payload: binary(), metadata: %{optional(String.t()) => String.t()}}
795
796 @spec error(String.t(), term()) :: {:error, Spikard.Grpc.Error.t()}
797 def error(message, code \\ :internal) do
798 {:error, %Spikard.Grpc.Error{code: code, message: message, metadata: %{}}}
799 end
800 end
801
802 defmodule Service do
803 defstruct services: %{}
804 @type t :: %__MODULE__{services: map()}
805
806 @spec new() :: t()
807 def new, do: %__MODULE__{}
808
809 @spec register(t(), String.t(), String.t(), atom(), function()) :: t()
810 def register(%__MODULE__{services: services} = service, service_name, method_name, rpc_mode, handler) do
811 methods =
812 services
813 |> Map.get(service_name, %{})
814 |> Map.put(method_name, {rpc_mode, handler})
815
816 %{service | services: Map.put(services, service_name, methods)}
817 end
818 end
819end
820"#,
821 )
822 .map_err(|e| QualityError::IoError(e.to_string()))?;
823 fs::write(
824 spikard_dir.join("websocket.ex"),
825 r#"defmodule Spikard.WebSocket do
826 @callback handle_connect(term(), term()) :: {:ok, term()} | {:error, term()}
827 @callback handle_message(term(), term()) ::
828 {:reply, term(), term()} | {:noreply, term()} | {:error, term()}
829 @callback handle_disconnect(term(), term()) :: :ok | {:error, term()}
830
831 defmacro __using__(_opts) do
832 quote do
833 @behaviour Spikard.WebSocket
834
835 @impl true
836 def handle_connect(_ws, _opts), do: {:ok, nil}
837
838 @impl true
839 def handle_message(message, state), do: {:reply, message, state}
840
841 @impl true
842 def handle_disconnect(_ws, _state), do: :ok
843
844 defoverridable handle_connect: 2, handle_message: 2, handle_disconnect: 2
845 end
846 end
847end
848"#,
849 )
850 .map_err(|e| QualityError::IoError(e.to_string()))?;
851 fs::write(
852 spikard_dir.join("sse.ex"),
853 r#"defmodule Spikard.Sse.Event do
854 defstruct [:data, :event, :id]
855 @type t :: %__MODULE__{data: term(), event: String.t() | nil, id: String.t() | nil}
856end
857
858defmodule Spikard.Sse.Producer do
859 @callback init(term()) :: {:ok, term()} | {:error, term()}
860 @callback next_event(term()) ::
861 {:ok, Spikard.Sse.Event.t(), term()} | :done | :error
862
863 defmacro __using__(_opts) do
864 quote do
865 @behaviour Spikard.Sse.Producer
866
867 @impl true
868 def init(_opts), do: {:ok, nil}
869
870 defoverridable init: 1
871 end
872 end
873end
874"#,
875 )
876 .map_err(|e| QualityError::IoError(e.to_string()))?;
877 fs::write(&generated_path, code).map_err(|e| QualityError::IoError(e.to_string()))?;
878
879 Ok(ElixirTempProject {
880 workdir,
881 generated_path,
882 })
883 }
884
885 fn write_php_validation_bootstrap(&self) -> Result<NamedTempFile, QualityError> {
886 self.write_temp_file(
887 r#"<?php
888declare(strict_types=1);
889
890namespace SpikardGenerated;
891
892#[\Attribute(\Attribute::TARGET_METHOD)]
893final class Route
894{
895 public function __construct(
896 public string $path,
897 public array $methods = [],
898 ) {}
899}
900
901namespace Spikard\Handlers;
902
903interface WebSocketHandlerInterface
904{
905 public function onConnect(): void;
906
907 public function onMessage(string $message): void;
908
909 public function onClose(int $code, ?string $reason = null): void;
910}
911
912interface SseEventProducerInterface
913{
914 /** @return \Generator<int, string, mixed, void> */
915 public function __invoke(): \Generator;
916}
917
918namespace Spikard;
919
920final class App
921{
922 public function addWebSocket(
923 string $path,
924 \Spikard\Handlers\WebSocketHandlerInterface $handler
925 ): self {
926 return $this;
927 }
928
929 public function addSse(
930 string $path,
931 \Spikard\Handlers\SseEventProducerInterface $producer
932 ): self {
933 return $this;
934 }
935}
936
937namespace Google\Protobuf\Internal;
938
939class Message {}
940
941namespace GraphQL\Type\Definition;
942
943class Type
944{
945 public static function string(): self
946 {
947 return new self();
948 }
949
950 public static function int(): self
951 {
952 return new self();
953 }
954
955 public static function float(): self
956 {
957 return new self();
958 }
959
960 public static function boolean(): self
961 {
962 return new self();
963 }
964
965 public static function id(): self
966 {
967 return new self();
968 }
969
970 public static function nonNull(self $type): self
971 {
972 return $type;
973 }
974
975 public static function listOf(self $type): self
976 {
977 return $type;
978 }
979}
980
981class ObjectType extends Type
982{
983 /** @param array<string, mixed> $config */
984 public function __construct(array $config = [])
985 {
986 }
987}
988
989class InputObjectType extends Type
990{
991 /** @param array<string, mixed> $config */
992 public function __construct(array $config = [])
993 {
994 }
995}
996
997class InterfaceType extends Type
998{
999 /** @param array<string, mixed> $config */
1000 public function __construct(array $config = [])
1001 {
1002 }
1003}
1004
1005class UnionType extends Type
1006{
1007 /** @param array<string, mixed> $config */
1008 public function __construct(array $config = [])
1009 {
1010 }
1011}
1012
1013class EnumType extends Type
1014{
1015 /** @param array<string, mixed> $config */
1016 public function __construct(array $config = [])
1017 {
1018 }
1019}
1020
1021namespace GraphQL\Type;
1022
1023class Schema
1024{
1025 /** @param array<string, mixed> $config */
1026 public function __construct(array $config = [])
1027 {
1028 }
1029}
1030"#,
1031 "php",
1032 )
1033 }
1034
1035 fn run_tool(&self, tool: &str, args: &[&str], _code: &str) -> Result<String, QualityError> {
1054 self.run_tool_in_dir(tool, args, Path::new("."), _code)
1055 }
1056
1057 fn run_tool_in_dir(&self, tool: &str, args: &[&str], cwd: &Path, _code: &str) -> Result<String, QualityError> {
1058 self.run_tool_in_dir_with_env(tool, args, cwd, &[], _code)
1059 }
1060
1061 fn run_tool_in_dir_with_env(
1062 &self,
1063 tool: &str,
1064 args: &[&str],
1065 cwd: &Path,
1066 envs: &[(&str, &std::ffi::OsStr)],
1067 _code: &str,
1068 ) -> Result<String, QualityError> {
1069 let max_attempts = 3;
1073 let mut last_err: Option<QualityError> = None;
1074 for _ in 0..max_attempts {
1075 let mut command = Command::new(tool);
1076 command.args(args).current_dir(cwd);
1077 for (key, value) in envs {
1078 command.env(key, value);
1079 }
1080
1081 let output = command.output().map_err(|e| {
1082 if e.kind() == std::io::ErrorKind::NotFound {
1083 QualityError::ToolNotFound(tool.to_string())
1084 } else {
1085 QualityError::IoError(e.to_string())
1086 }
1087 })?;
1088
1089 if output.status.success() {
1090 return Ok(String::from_utf8_lossy(&output.stdout).to_string());
1091 }
1092
1093 let stderr = String::from_utf8_lossy(&output.stderr);
1094 let stdout = String::from_utf8_lossy(&output.stdout);
1095 let message = if stderr.is_empty() {
1096 stdout.to_string()
1097 } else {
1098 stderr.to_string()
1099 };
1100 let is_mypy_internal_error = message.contains("INTERNAL ERROR");
1101 last_err = Some(QualityError::ValidationFailed(message));
1102 if !is_mypy_internal_error {
1103 break;
1104 }
1105 }
1106 Err(last_err.expect("at least one attempt always runs"))
1107 }
1108}
1109
1110struct RustTempProject {
1111 workdir: TempDir,
1112 manifest_path: PathBuf,
1113}
1114
1115struct TypeScriptTempProject {
1116 workdir: TempDir,
1117 entry_path: PathBuf,
1118 config_path: PathBuf,
1119}
1120
1121struct PythonTempProject {
1122 workdir: TempDir,
1123 entry_path: PathBuf,
1124 stub_path: PathBuf,
1125}
1126
1127struct ElixirTempProject {
1128 workdir: TempDir,
1129 generated_path: PathBuf,
1130}
1131
1132fn write_python_validation_stubs(root: &Path) -> Result<(), QualityError> {
1133 write_stub_file(
1134 &root.join("msgspec.py"),
1135 r#"from typing import Any, TypeVar, cast
1136
1137T = TypeVar("T")
1138
1139class Struct:
1140 def __init__(self, **kwargs: object) -> None: ...
1141
1142 def __init_subclass__(cls, *, frozen: bool = False, kw_only: bool = False) -> None: ...
1143
1144def field(*, default: object = ..., name: str | None = None) -> Any: ...
1145
1146def convert(value: object, *, type: object) -> Any:
1147 return value
1148
1149def to_builtins(value: object) -> object: ...
1150"#,
1151 )?;
1152
1153 write_stub_file(&root.join("graphql.py"), "class GraphQLResolveInfo: ...\n")?;
1154
1155 write_stub_file(
1156 &root.join("ariadne.py"),
1157 r#"from __future__ import annotations
1158
1159from collections.abc import Callable
1160from typing import Any
1161
1162Resolver = Callable[..., Any]
1163
1164class QueryType:
1165 def set_field(self, _name: str, _resolver: Resolver) -> None: ...
1166
1167class MutationType:
1168 def set_field(self, _name: str, _resolver: Resolver) -> None: ...
1169
1170class SubscriptionType:
1171 def set_field(self, _name: str, _resolver: Resolver) -> None: ...
1172 def set_source(self, _name: str, _resolver: Resolver) -> None: ...
1173
1174def make_executable_schema(*_args: object, **_kwargs: object) -> object:
1175 return object()
1176"#,
1177 )?;
1178
1179 let spikard_dir = root.join("spikard");
1180 fs::create_dir_all(&spikard_dir).map_err(|e| QualityError::IoError(e.to_string()))?;
1181 write_stub_file(
1182 &spikard_dir.join("__init__.py"),
1183 r#"from __future__ import annotations
1184
1185from collections.abc import Callable
1186from typing import Generic, TypeVar
1187
1188F = TypeVar("F", bound=Callable[..., object])
1189T = TypeVar("T")
1190
1191class Body(Generic[T]): ...
1192
1193class Path(Generic[T]): ...
1194
1195class Query(Generic[T]):
1196 def __init__(self, default: T | None = None) -> None:
1197 self.default = default
1198
1199class Request: ...
1200
1201class Spikard:
1202 def route(self, *_args: object, **_kwargs: object) -> Callable[[F], F]:
1203 def decorator(fn: F) -> F:
1204 return fn
1205 return decorator
1206
1207 def post(self, *_args: object, **_kwargs: object) -> Callable[[F], F]:
1208 def decorator(fn: F) -> F:
1209 return fn
1210 return decorator
1211
1212 def get(self, *_args: object, **_kwargs: object) -> Callable[[F], F]:
1213 def decorator(fn: F) -> F:
1214 return fn
1215 return decorator
1216
1217 def run(self, *_args: object, **_kwargs: object) -> None:
1218 return None
1219
1220def route(*_args: object, **_kwargs: object) -> Callable[[F], F]:
1221 def decorator(fn: F) -> F:
1222 return fn
1223 return decorator
1224
1225def websocket(*_args: object, **_kwargs: object) -> Callable[[F], F]:
1226 def decorator(fn: F) -> F:
1227 return fn
1228 return decorator
1229
1230def sse(*_args: object, **_kwargs: object) -> Callable[[F], F]:
1231 def decorator(fn: F) -> F:
1232 return fn
1233 return decorator
1234"#,
1235 )?;
1236 write_stub_file(
1237 &spikard_dir.join("config.py"),
1238 r#"class ServerConfig:
1239 def __init__(self, host: str = "0.0.0.0", port: int = 8000) -> None:
1240 self.host = host
1241 self.port = port
1242"#,
1243 )?;
1244
1245 let google_protobuf_dir = root.join("google").join("protobuf");
1246 fs::create_dir_all(&google_protobuf_dir).map_err(|e| QualityError::IoError(e.to_string()))?;
1247 write_stub_file(&root.join("google").join("__init__.py"), "")?;
1248 write_stub_file(&google_protobuf_dir.join("__init__.py"), "")?;
1249 write_stub_file(&google_protobuf_dir.join("message.py"), "class Message: ...\n")?;
1250
1251 let websockets_dir = root.join("websockets");
1252 fs::create_dir_all(&websockets_dir).map_err(|e| QualityError::IoError(e.to_string()))?;
1253 write_stub_file(&websockets_dir.join("__init__.py"), "")?;
1254 write_stub_file(
1255 &websockets_dir.join("client.py"),
1256 "class WebSocketClientProtocol: ...\n",
1257 )?;
1258
1259 Ok(())
1260}
1261
1262fn write_stub_file(path: &Path, contents: &str) -> Result<(), QualityError> {
1263 if let Some(parent) = path.parent() {
1264 fs::create_dir_all(parent).map_err(|e| QualityError::IoError(e.to_string()))?;
1265 }
1266 fs::write(path, contents).map_err(|e| QualityError::IoError(e.to_string()))
1267}
1268
1269fn rust_temp_manifest() -> String {
1270 let spikard_path = workspace_root().join("crates/spikard");
1271
1272 format!(
1273 r#"[package]
1274name = "spikard_codegen_validation"
1275version = "0.1.0"
1276edition = "2024"
1277
1278[lib]
1279path = "src/lib.rs"
1280
1281[dependencies]
1282async-graphql = "7"
1283async-trait = "0.1"
1284axum = "0.8"
1285bytes = "1"
1286chrono = {{ version = "0.4", features = ["serde"] }}
1287futures-core = "0.3"
1288futures-util = "0.3"
1289prost = "0.14"
1290schemars = {{ version = "1.2", features = ["derive", "chrono04", "uuid1"] }}
1291serde = {{ version = "1", features = ["derive"] }}
1292serde_json = "1"
1293spikard = {{ path = "{}" }}
1294tokio = {{ version = "1", features = ["full"] }}
1295tonic = "0.14"
1296uuid = {{ version = "1", features = ["serde", "v4"] }}
1297"#,
1298 spikard_path.display()
1299 )
1300}
1301
1302fn workspace_root() -> &'static Path {
1303 Path::new(env!("CARGO_MANIFEST_DIR"))
1304 .parent()
1305 .and_then(Path::parent)
1306 .expect("workspace root should be two levels above crates/spikard-cli")
1307}
1308
1309#[cfg(test)]
1310mod tests {
1311 use super::*;
1312
1313 #[test]
1314 fn test_validation_report_is_valid() {
1315 let mut report = ValidationReport::new();
1316 assert!(!report.is_valid());
1317
1318 report.syntax_passed = true;
1319 report.types_passed = true;
1320 report.lint_passed = true;
1321 assert!(report.is_valid());
1322
1323 report.add_error("test error".to_string());
1324 assert!(!report.is_valid());
1325 }
1326
1327 #[test]
1328 fn test_validation_report_error_count() {
1329 let mut report = ValidationReport::new();
1330 assert_eq!(report.error_count(), 0);
1331
1332 report.add_error("error 1".to_string());
1333 report.add_error("error 2".to_string());
1334 assert_eq!(report.error_count(), 2);
1335 }
1336
1337 #[test]
1338 fn test_quality_validator_creation() {
1339 let validator = QualityValidator::new(TargetLanguage::Python);
1340 assert_eq!(validator.language, TargetLanguage::Python);
1341
1342 let validator = QualityValidator::new(TargetLanguage::TypeScript);
1343 assert_eq!(validator.language, TargetLanguage::TypeScript);
1344
1345 let validator = QualityValidator::new(TargetLanguage::Elixir);
1346 assert_eq!(validator.language, TargetLanguage::Elixir);
1347 }
1348
1349 #[test]
1350 fn test_quality_error_display() {
1351 let err = QualityError::ToolNotFound("mypy".to_string());
1352 assert_eq!(err.to_string(), "Required validation tool not found: mypy");
1353
1354 let err = QualityError::ValidationFailed("syntax error".to_string());
1355 assert!(err.to_string().contains("Validation failed"));
1356
1357 let err = QualityError::IoError("file not found".to_string());
1358 assert!(err.to_string().contains("I/O error"));
1359 }
1360
1361 #[test]
1362 fn test_validation_report_display() {
1363 let mut report = ValidationReport::new();
1364 report.syntax_passed = true;
1365 report.types_passed = false;
1366 report.add_error("type mismatch".to_string());
1367
1368 let display = report.to_string();
1369 assert!(display.contains("Syntax: PASS"));
1370 assert!(display.contains("Types: FAIL"));
1371 assert!(display.contains("type mismatch"));
1372 }
1373
1374 #[test]
1375 fn test_rust_quality_validator_accepts_valid_code() {
1376 let validator = QualityValidator::new(TargetLanguage::Rust);
1377 validator
1378 .validate_syntax("pub fn add(a: i32, b: i32) -> i32 { a + b }")
1379 .expect("rust syntax validation should pass");
1380 validator
1381 .validate_types("pub fn add(a: i32, b: i32) -> i32 { a + b }")
1382 .expect("rust type validation should pass");
1383 }
1384}