From a7faaae8e405573e97233ade33e30de5cba88678 Mon Sep 17 00:00:00 2001 From: arpitJ-dev Date: Tue, 11 Aug 2026 04:13:29 -0700 Subject: [PATCH 1/7] feat(cli): add versioned analysis protocol --- clauseguard/cli.py | 222 +++++++-- clauseguard/comparison.py | 174 ++++++-- clauseguard/events.py | 154 +++++++ clauseguard/exit_codes.py | 40 ++ clauseguard/model_router.py | 8 +- clauseguard/pipeline.py | 91 +++- clauseguard/schemas.py | 46 ++ .../schemas/analysis-report-v1.schema.json | 421 ++++++++++++++++++ .../schemas/comparison-report-v1.schema.json | 267 +++++++++++ .../schemas/progress-event-v1.schema.json | 117 +++++ tests/test_cli.py | 131 +++++- tests/test_exit_codes.py | 20 + tests/test_model_router.py | 35 +- 13 files changed, 1638 insertions(+), 88 deletions(-) create mode 100644 clauseguard/events.py create mode 100644 clauseguard/exit_codes.py create mode 100644 contracts/schemas/analysis-report-v1.schema.json create mode 100644 contracts/schemas/comparison-report-v1.schema.json create mode 100644 contracts/schemas/progress-event-v1.schema.json create mode 100644 tests/test_exit_codes.py diff --git a/clauseguard/cli.py b/clauseguard/cli.py index 17e187d..511b474 100644 --- a/clauseguard/cli.py +++ b/clauseguard/cli.py @@ -3,16 +3,44 @@ import argparse import json import sys +import uuid +from pathlib import Path +from typing import Type + +from pydantic import BaseModel from clauseguard.comparison import compare_documents -from clauseguard.config import ConfigError -from clauseguard.dataset_benchmark import DatasetBenchmarkError, build_repo_dataset_benchmark -from clauseguard.document import DocumentLoadError -from clauseguard.evaluation import EvaluationError, run_benchmark +from clauseguard.dataset_benchmark import build_repo_dataset_benchmark +from clauseguard.evaluation import run_benchmark +from clauseguard.events import JsonLineEventWriter, ProgressEvent +from clauseguard.exit_codes import ExitCode, classify_exception from clauseguard.model_catalog import configured_model_rows -from clauseguard.model_router import ModelCallError from clauseguard.pipeline import ClauseGuardPipeline from clauseguard.postprocessor import Postprocessor +from clauseguard.schemas import AnalysisReport, ComparisonReport + +SCHEMA_MODELS: dict[str, Type[BaseModel]] = { + "analysis-report": AnalysisReport, + "comparison-report": ComparisonReport, + "progress-event": ProgressEvent, +} + + +def _add_machine_options(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--quiet", + action="store_true", + help="Suppress human-readable success output.", + ) + parser.add_argument( + "--events-jsonl", + action="store_true", + help="Emit versioned NDJSON progress events to stdout; implies --quiet.", + ) + parser.add_argument( + "--run-id", + help="Caller-supplied run identifier for job correlation.", + ) def build_parser() -> argparse.ArgumentParser: @@ -35,6 +63,7 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Use deterministic model fixtures for repeatable local analysis.", ) + _add_machine_options(analyze) models = subparsers.add_parser("models", help="Show configured model roles and safety limits.") models.add_argument("--json", action="store_true", help="Print model configuration as JSON.") @@ -50,11 +79,18 @@ def build_parser() -> argparse.ArgumentParser: default="analysis_outputs/comparison", help="Directory for comparison report files.", ) + compare.add_argument( + "--format", + choices=["json", "markdown", "both"], + default="both", + help="Output format to write.", + ) compare.add_argument( "--real-models", action="store_true", help="Use configured hosted models during preprocessing.", ) + _add_machine_options(compare) evaluate = subparsers.add_parser("evaluate", help="Run labeled benchmark evaluation.") evaluate.add_argument( @@ -100,34 +136,116 @@ def build_parser() -> argparse.ArgumentParser: default="docs/DATASET_INVENTORY.md", help="Dataset inventory Markdown path to write.", ) + + schema = subparsers.add_parser( + "schema", help="Export a versioned machine-readable JSON Schema." + ) + schema.add_argument("name", choices=sorted(SCHEMA_MODELS), help="Schema to export.") + schema.add_argument("--output", help="File path to write instead of stdout.") return parser +def _event_writer(args: argparse.Namespace, run_id: str) -> JsonLineEventWriter | None: + if not getattr(args, "events_jsonl", False): + return None + return JsonLineEventWriter(sys.stdout, run_id) + + +def _emit_queued(writer: JsonLineEventWriter | None, command: str) -> None: + if writer is None: + return + writer.emit( + event_type="progress", + stage="queued", + status="queued", + progress=0, + message=f"{command.capitalize()} job accepted", + details={"command": command}, + ) + + +def _return_error(exc: BaseException, writer: JsonLineEventWriter | None = None) -> int: + exit_code, error_code = classify_exception(exc) + message = str(exc) + if exit_code == ExitCode.INTERNAL: + message = f"Unexpected internal error ({type(exc).__name__})." + if exit_code == ExitCode.CANCELLED: + message = "Operation cancelled." + + if writer is not None: + writer.failed(error_code, message, details={"exit_code": int(exit_code)}) + print(f"ClauseGuard error: {message}", file=sys.stderr) + return int(exit_code) + + +def _analysis_report_files(output_dir: str | Path, formats: tuple[str, ...]) -> list[str]: + names = {"json": "analysis_report.json", "markdown": "analysis_report.md"} + target = Path(output_dir) + return [str(target / names[format_name]) for format_name in formats] + + +def _comparison_report_files(output_dir: str | Path, formats: tuple[str, ...]) -> list[str]: + names = {"json": "comparison_report.json", "markdown": "comparison_report.md"} + target = Path(output_dir) + return [str(target / names[format_name]) for format_name in formats] + + +def _write_schema(name: str, output: str | None) -> None: + payload = SCHEMA_MODELS[name].model_json_schema() + encoded = json.dumps(payload, indent=2, sort_keys=True) + "\n" + if output is None: + sys.stdout.write(encoded) + return + + target = Path(output) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(encoded, encoding="utf-8") + + def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) if args.command == "analyze": formats = ("json", "markdown") if args.format == "both" else (args.format,) + run_id = args.run_id or str(uuid.uuid4()) + writer = _event_writer(args, run_id) + _emit_queued(writer, "analysis") try: pipeline = ClauseGuardPipeline.from_env(mock_models=args.mock_models) report = pipeline.analyze( - args.document, output_dir=args.output_dir, output_formats=formats + args.document, + output_dir=args.output_dir, + output_formats=formats, + run_id=run_id, + progress_callback=writer.progress if writer else None, + ) + except KeyboardInterrupt as exc: + return _return_error(exc, writer) + except Exception as exc: + return _return_error(exc, writer) + + report_files = _analysis_report_files(args.output_dir, formats) + if writer is not None: + writer.completed( + "Analysis completed", + { + "document_id": report.document_id, + "report_files": report_files, + "accepted_findings": sum(1 for finding in report.findings if finding.accepted), + }, ) - except (ConfigError, DocumentLoadError, ModelCallError) as exc: - print(f"ClauseGuard error: {exc}", file=sys.stderr) - return 2 - print(Postprocessor().to_markdown(report)) - print(f"Reports written to: {args.output_dir}") - return 0 + if not (args.quiet or args.events_jsonl): + print(Postprocessor().to_markdown(report)) + print(f"Reports written to: {args.output_dir}") + return int(ExitCode.SUCCESS) if args.command == "models": try: pipeline = ClauseGuardPipeline.from_env(mock_models=True) - except ConfigError as exc: - print(f"ClauseGuard error: {exc}", file=sys.stderr) - return 2 + except Exception as exc: + return _return_error(exc) rows = configured_model_rows(pipeline.config) if args.json: @@ -143,28 +261,49 @@ def main(argv: list[str] | None = None) -> int: f" cap: {row['max_requests']} requests, {row['max_input_tokens']} estimated input tokens" ) print(f" use: {row['purpose']}") - return 0 + return int(ExitCode.SUCCESS) if args.command == "compare": + formats = ("json", "markdown") if args.format == "both" else (args.format,) + run_id = args.run_id or str(uuid.uuid4()) + writer = _event_writer(args, run_id) + _emit_queued(writer, "comparison") try: comparison = compare_documents( args.original, args.modified, output_dir=args.output_dir, + output_formats=formats, mock_models=not args.real_models, + comparison_id=run_id, + progress_callback=writer.progress if writer else None, ) - except (ConfigError, DocumentLoadError, ModelCallError) as exc: - print(f"ClauseGuard error: {exc}", file=sys.stderr) - return 2 + except KeyboardInterrupt as exc: + return _return_error(exc, writer) + except Exception as exc: + return _return_error(exc, writer) summary = comparison["summary"] - print("Comparison complete") - print( - f"Matched: {summary['matched']} | Changed: {summary['changed']} | Added: {summary['added']} | Removed: {summary['removed']}" - ) - print(f"Risk signals: {len(comparison['risk_signals'])}") - print(f"Results written to: {args.output_dir}") - return 0 + if writer is not None: + writer.completed( + "Comparison completed", + { + "comparison_id": comparison["comparison_id"], + "report_files": _comparison_report_files(args.output_dir, formats), + "summary": summary, + "risk_signal_count": len(comparison["risk_signals"]), + }, + ) + + if not (args.quiet or args.events_jsonl): + print("Comparison complete") + print( + f"Matched: {summary['matched']} | Changed: {summary['changed']} | " + f"Added: {summary['added']} | Removed: {summary['removed']}" + ) + print(f"Risk signals: {len(comparison['risk_signals'])}") + print(f"Results written to: {args.output_dir}") + return int(ExitCode.SUCCESS) if args.command == "evaluate": if args.mock_models and args.real_models: @@ -172,7 +311,7 @@ def main(argv: list[str] | None = None) -> int: "ClauseGuard error: choose either --mock-models or --real-models, not both.", file=sys.stderr, ) - return 2 + return int(ExitCode.USAGE) try: summary = run_benchmark( @@ -182,9 +321,10 @@ def main(argv: list[str] | None = None) -> int: max_cases=args.max_cases, allow_multiple_real_cases=args.allow_multiple_real_cases, ) - except (ConfigError, DocumentLoadError, ModelCallError, EvaluationError) as exc: - print(f"ClauseGuard error: {exc}", file=sys.stderr) - return 2 + except KeyboardInterrupt as exc: + return _return_error(exc) + except Exception as exc: + return _return_error(exc) aggregate = summary["aggregate"] print("Benchmark evaluation complete") @@ -195,21 +335,31 @@ def main(argv: list[str] | None = None) -> int: f"{aggregate['precision']:.4f} | Recall: {aggregate['recall']:.4f} | F1: {aggregate['f1']:.4f}" ) print(f"Results written to: {args.output_dir}") - return 0 + return int(ExitCode.SUCCESS) if args.command == "build-dataset-benchmark": try: inventory = build_repo_dataset_benchmark(args.output, args.inventory) - except DatasetBenchmarkError as exc: - print(f"ClauseGuard error: {exc}", file=sys.stderr) - return 2 + except KeyboardInterrupt as exc: + return _return_error(exc) + except Exception as exc: + return _return_error(exc) print("Repo dataset benchmark built") print(f"Benchmark cases: {inventory['benchmark_case_count']}") print(f"Perturbation records: {inventory['perturbation_count']}") print(f"Benchmark path: {inventory['benchmark_path']}") print(f"Inventory written to: {args.inventory}") - return 0 + return int(ExitCode.SUCCESS) + + if args.command == "schema": + try: + _write_schema(args.name, args.output) + except KeyboardInterrupt as exc: + return _return_error(exc) + except Exception as exc: + return _return_error(exc) + return int(ExitCode.SUCCESS) parser.print_help() - return 1 + return int(ExitCode.USAGE) diff --git a/clauseguard/comparison.py b/clauseguard/comparison.py index 0d06cdd..cb63768 100644 --- a/clauseguard/comparison.py +++ b/clauseguard/comparison.py @@ -2,11 +2,19 @@ import json import re +import uuid from pathlib import Path from typing import Any, Iterable +from clauseguard.events import ProgressCallback, notify from clauseguard.pipeline import ClauseGuardPipeline -from clauseguard.schemas import Clause +from clauseguard.schemas import ( + Clause, + ClauseDelta, + ComparisonReport, + ComparisonRiskSignal, + ComparisonSummary, +) SAFEGUARD_TERMS = { "governing law": ["governed by", "construed in accordance with", "laws of", "law of"], @@ -22,45 +30,110 @@ def compare_documents( modified_path: str | Path, *, output_dir: str | Path | None = None, + output_formats: Iterable[str] = ("json", "markdown"), mock_models: bool = True, + comparison_id: str | None = None, + progress_callback: ProgressCallback | None = None, ) -> dict[str, Any]: pipeline = ClauseGuardPipeline.from_env(mock_models=mock_models) + run_id = comparison_id or str(uuid.uuid4()) + formats = tuple(output_formats) + + notify(progress_callback, "loading", "started", 5, "Loading document versions") original = pipeline.loader.load(original_path) modified = pipeline.loader.load(modified_path) + notify( + progress_callback, + "loading", + "completed", + 15, + "Document versions loaded", + original_file_type=original.file_type, + modified_file_type=modified.file_type, + ) + + notify(progress_callback, "extracting", "started", 20, "Extracting comparable clauses") original_type, original_clauses, _ = pipeline.preprocessor.process( original.model_copy(update={"text": _normalize_layout(original.text)}) ) modified_type, modified_clauses, _ = pipeline.preprocessor.process( modified.model_copy(update={"text": _normalize_layout(modified.text)}) ) + notify( + progress_callback, + "extracting", + "completed", + 40, + "Comparable clauses extracted", + original_clause_count=len(original_clauses), + modified_clause_count=len(modified_clauses), + ) + notify(progress_callback, "comparing", "started", 50, "Matching contract clauses") matches = _match_clauses(original_clauses, modified_clauses) risk_signals = _risk_signals(matches) - report = { - "original_document": str(original_path), - "modified_document": str(modified_path), - "original_type": original_type, - "modified_type": modified_type, - "original_clause_count": len(original_clauses), - "modified_clause_count": len(modified_clauses), - "summary": _summary(matches), - "clause_deltas": [_public_delta(delta) for delta in matches], - "risk_signals": risk_signals, - "notes": [ + summary = _summary(matches) + notify( + progress_callback, + "comparing", + "completed", + 80, + "Contract comparison completed", + **summary.model_dump(), + risk_signal_count=len(risk_signals), + ) + + report_model = ComparisonReport( + comparison_id=run_id, + original_document=str(original_path), + modified_document=str(modified_path), + original_type=original_type, + modified_type=modified_type, + original_clause_count=len(original_clauses), + modified_clause_count=len(modified_clauses), + summary=summary, + clause_deltas=[_public_delta(delta) for delta in matches], + risk_signals=risk_signals, + notes=[ "Comparison mode uses both documents and is intentionally separate from standalone benchmark PRF evaluation.", "Signals are deterministic review hints for changed clauses, not broad legal accuracy metrics.", ], - } + ) + report = report_model.model_dump(mode="json") + + notify(progress_callback, "reporting", "started", 90, "Writing comparison report") if output_dir: - write_comparison_report(report, output_dir) + write_comparison_report(report, output_dir, formats) + notify( + progress_callback, + "reporting", + "completed", + 98, + "Comparison report written", + output_dir=str(output_dir) if output_dir else None, + ) return report -def write_comparison_report(report: dict[str, Any], output_dir: str | Path) -> None: +def write_comparison_report( + report: dict[str, Any], + output_dir: str | Path, + output_formats: Iterable[str] = ("json", "markdown"), +) -> None: target = Path(output_dir) target.mkdir(parents=True, exist_ok=True) - (target / "comparison_report.json").write_text(json.dumps(report, indent=2), encoding="utf-8") - (target / "comparison_report.md").write_text(comparison_to_markdown(report), encoding="utf-8") + formats = tuple(output_formats) + unknown = set(formats) - {"json", "markdown"} + if unknown: + raise ValueError(f"Unsupported comparison output format: {sorted(unknown)}") + if "json" in formats: + (target / "comparison_report.json").write_text( + json.dumps(report, indent=2), encoding="utf-8" + ) + if "markdown" in formats: + (target / "comparison_report.md").write_text( + comparison_to_markdown(report), encoding="utf-8" + ) def comparison_to_markdown(report: dict[str, Any]) -> str: @@ -182,17 +255,17 @@ def _delta( } -def _summary(matches: list[dict[str, Any]]) -> dict[str, int]: - return { - "matched": sum(1 for item in matches if item["status"] in {"changed", "unchanged"}), - "changed": sum(1 for item in matches if item["status"] == "changed"), - "added": sum(1 for item in matches if item["status"] == "added"), - "removed": sum(1 for item in matches if item["status"] == "removed"), - } +def _summary(matches: list[dict[str, Any]]) -> ComparisonSummary: + return ComparisonSummary( + matched=sum(1 for item in matches if item["status"] in {"changed", "unchanged"}), + changed=sum(1 for item in matches if item["status"] == "changed"), + added=sum(1 for item in matches if item["status"] == "added"), + removed=sum(1 for item in matches if item["status"] == "removed"), + ) -def _risk_signals(matches: list[dict[str, Any]]) -> list[dict[str, str]]: - signals: list[dict[str, str]] = [] +def _risk_signals(matches: list[dict[str, Any]]) -> list[ComparisonRiskSignal]: + signals: list[ComparisonRiskSignal] = [] for delta in matches: if delta["status"] == "unchanged": continue @@ -200,19 +273,21 @@ def _risk_signals(matches: list[dict[str, Any]]) -> list[dict[str, str]]: modified = f"{delta.get('modified_title') or ''}\n{delta.get('_modified_text') or ''}" original_lower = original.lower() modified_lower = modified.lower() - clause_name = delta.get("modified_title") or delta.get("original_title") or "Document-level" + clause_name = str( + delta.get("modified_title") or delta.get("original_title") or "Document-level" + ) added_risk_terms = sorted( set(delta["modified_risk_terms"]) - set(delta["original_risk_terms"]) ) if added_risk_terms: signals.append( - { - "type": "added_risk_terms", - "clause": clause_name, - "detail": ", ".join(added_risk_terms), - "severity": "MEDIUM", - } + ComparisonRiskSignal( + type="added_risk_terms", + clause=clause_name, + detail=", ".join(added_risk_terms), + severity="MEDIUM", + ) ) removed_safeguards = [ @@ -223,12 +298,12 @@ def _risk_signals(matches: list[dict[str, Any]]) -> list[dict[str, str]]: ] if removed_safeguards: signals.append( - { - "type": "removed_safeguards", - "clause": clause_name, - "detail": ", ".join(removed_safeguards), - "severity": "HIGH", - } + ComparisonRiskSignal( + type="removed_safeguards", + clause=clause_name, + detail=", ".join(removed_safeguards), + severity="HIGH", + ) ) if ( @@ -237,12 +312,15 @@ def _risk_signals(matches: list[dict[str, Any]]) -> list[dict[str, str]]: and not _has_governing_standard(modified_lower) ): signals.append( - { - "type": "incomplete_governing_law", - "clause": clause_name, - "detail": "Modified text keeps dispute/forum mechanics but lacks a governing-law standard.", - "severity": "HIGH", - } + ComparisonRiskSignal( + type="incomplete_governing_law", + clause=clause_name, + detail=( + "Modified text keeps dispute/forum mechanics but lacks a " + "governing-law standard." + ), + severity="HIGH", + ) ) return signals[:40] @@ -262,8 +340,10 @@ def _looks_like_governing_law(delta: dict[str, Any]) -> bool: return "governing law" in context -def _public_delta(delta: dict[str, Any]) -> dict[str, Any]: - return {key: value for key, value in delta.items() if not key.startswith("_")} +def _public_delta(delta: dict[str, Any]) -> ClauseDelta: + return ClauseDelta.model_validate( + {key: value for key, value in delta.items() if not key.startswith("_")} + ) def _has_dispute_text(text: str) -> bool: diff --git a/clauseguard/events.py b/clauseguard/events.py new file mode 100644 index 0000000..90cf982 --- /dev/null +++ b/clauseguard/events.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Callable, Dict, Literal, TextIO + +from pydantic import BaseModel, Field + +EVENT_SCHEMA_VERSION: Literal["1.0"] = "1.0" + +PipelineStage = Literal[ + "queued", + "loading", + "extracting", + "retrieving", + "checking", + "verifying", + "scoring", + "rewriting", + "comparing", + "reporting", + "completed", + "failed", +] +ProgressStatus = Literal["queued", "started", "completed", "failed"] +EventType = Literal["progress", "completed", "error"] + + +def utc_timestamp() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +class PipelineProgress(BaseModel): + stage: PipelineStage + status: ProgressStatus + progress: int = Field(ge=0, le=100) + message: str + details: Dict[str, Any] = Field(default_factory=dict) + + +class EventError(BaseModel): + code: str + message: str + + +class ProgressEvent(BaseModel): + schema_version: Literal["1.0"] = EVENT_SCHEMA_VERSION + run_id: str + sequence: int = Field(ge=1) + type: EventType + stage: PipelineStage + status: ProgressStatus + progress: int = Field(ge=0, le=100) + message: str + timestamp: str = Field(default_factory=utc_timestamp) + details: Dict[str, Any] = Field(default_factory=dict) + error: EventError | None = None + + +ProgressCallback = Callable[[PipelineProgress], None] + + +class JsonLineEventWriter: + """Writes a versioned, flush-on-write NDJSON event stream.""" + + def __init__(self, stream: TextIO, run_id: str): + self.stream = stream + self.run_id = run_id + self.sequence = 0 + + def progress(self, update: PipelineProgress) -> None: + event_type: EventType = "completed" if update.stage == "completed" else "progress" + self.emit( + event_type=event_type, + stage=update.stage, + status=update.status, + progress=update.progress, + message=update.message, + details=update.details, + ) + + def completed(self, message: str, details: Dict[str, Any] | None = None) -> ProgressEvent: + return self.emit( + event_type="completed", + stage="completed", + status="completed", + progress=100, + message=message, + details=details, + ) + + def failed( + self, + code: str, + message: str, + *, + details: Dict[str, Any] | None = None, + ) -> ProgressEvent: + return self.emit( + event_type="error", + stage="failed", + status="failed", + progress=100, + message=message, + details=details, + error=EventError(code=code, message=message), + ) + + def emit( + self, + *, + event_type: EventType, + stage: PipelineStage, + status: ProgressStatus, + progress: int, + message: str, + details: Dict[str, Any] | None = None, + error: EventError | None = None, + ) -> ProgressEvent: + self.sequence += 1 + event = ProgressEvent( + run_id=self.run_id, + sequence=self.sequence, + type=event_type, + stage=stage, + status=status, + progress=progress, + message=message, + details=details or {}, + error=error, + ) + self.stream.write(event.model_dump_json() + "\n") + self.stream.flush() + return event + + +def notify( + callback: ProgressCallback | None, + stage: PipelineStage, + status: ProgressStatus, + progress: int, + message: str, + **details: Any, +) -> None: + if callback is None: + return + callback( + PipelineProgress( + stage=stage, + status=status, + progress=progress, + message=message, + details=details, + ) + ) diff --git a/clauseguard/exit_codes.py b/clauseguard/exit_codes.py new file mode 100644 index 0000000..90e3836 --- /dev/null +++ b/clauseguard/exit_codes.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from enum import IntEnum + +from clauseguard.config import ConfigError +from clauseguard.dataset_benchmark import DatasetBenchmarkError +from clauseguard.document import DocumentLoadError +from clauseguard.evaluation import EvaluationError +from clauseguard.model_router import ModelCallError, ModelTimeoutError, UsageLimitError + + +class ExitCode(IntEnum): + SUCCESS = 0 + USAGE = 2 + CONFIGURATION = 10 + DOCUMENT = 11 + MODEL = 12 + USAGE_LIMIT = 13 + TIMEOUT = 14 + DATA = 15 + INTERNAL = 20 + CANCELLED = 130 + + +def classify_exception(exc: BaseException) -> tuple[ExitCode, str]: + if isinstance(exc, ConfigError): + return ExitCode.CONFIGURATION, "configuration_error" + if isinstance(exc, DocumentLoadError): + return ExitCode.DOCUMENT, "document_error" + if isinstance(exc, UsageLimitError): + return ExitCode.USAGE_LIMIT, "usage_limit" + if isinstance(exc, (ModelTimeoutError, TimeoutError)): + return ExitCode.TIMEOUT, "timeout" + if isinstance(exc, ModelCallError): + return ExitCode.MODEL, "model_error" + if isinstance(exc, (DatasetBenchmarkError, EvaluationError)): + return ExitCode.DATA, "data_error" + if isinstance(exc, KeyboardInterrupt): + return ExitCode.CANCELLED, "cancelled" + return ExitCode.INTERNAL, "internal_error" diff --git a/clauseguard/model_router.py b/clauseguard/model_router.py index 4df6c1f..a8510cf 100644 --- a/clauseguard/model_router.py +++ b/clauseguard/model_router.py @@ -23,6 +23,10 @@ class UsageLimitError(ModelCallError): pass +class ModelTimeoutError(ModelCallError): + pass + + class UsageLimiter: """Per-run request and estimated input-token guard for cloud model calls.""" @@ -124,11 +128,13 @@ def _groq_chat( json=payload, timeout=60, ) + except requests.Timeout as exc: + raise ModelTimeoutError(f"Groq call timed out for {model_name}.") from exc except requests.RequestException as exc: raise ModelCallError(f"Groq call failed for {model_name}: {exc}") from exc if response.status_code == 429: - raise ModelCallError( + raise UsageLimitError( f"Groq rate limit was reached for {model_name}. Retry later, lower local caps, " "or use --mock-models." ) diff --git a/clauseguard/pipeline.py b/clauseguard/pipeline.py index d9f5533..89c6894 100644 --- a/clauseguard/pipeline.py +++ b/clauseguard/pipeline.py @@ -11,6 +11,7 @@ from clauseguard.config import AppConfig from clauseguard.context import ContextBank from clauseguard.document import DocumentLoader +from clauseguard.events import ProgressCallback, notify from clauseguard.model_router import ModelRouter from clauseguard.postprocessor import Postprocessor from clauseguard.rag import KnowledgeAgent @@ -41,27 +42,113 @@ def analyze( output_dir: str | Path | None = None, output_formats: Iterable[str] = ("json", "markdown"), include_rewrites: bool = True, + run_id: str | None = None, + progress_callback: ProgressCallback | None = None, ) -> AnalysisReport: - context = ContextBank(document_id=str(uuid.uuid4())) + formats = tuple(output_formats) + context = ContextBank(document_id=run_id or str(uuid.uuid4())) + + notify(progress_callback, "loading", "started", 5, "Loading document") document = self.loader.load(file_path) + notify( + progress_callback, + "loading", + "completed", + 10, + "Document loaded", + file_type=document.file_type, + title=document.title, + ) + + notify(progress_callback, "extracting", "started", 15, "Extracting contract structure") document_type, clauses, entities = self.preprocessor.process(document) + notify( + progress_callback, + "extracting", + "completed", + 25, + "Contract structure extracted", + document_type=document_type, + clause_count=len(clauses), + entity_count=len(entities), + ) context.add_document(document, document_type) context.add_clauses(clauses) context.add_entities(entities) + + notify(progress_callback, "retrieving", "started", 30, "Retrieving review evidence") context.add_evidence(self.knowledge.retrieve_for_clauses(clauses)) + notify( + progress_callback, + "retrieving", + "completed", + 40, + "Review evidence retrieved", + evidence_count=len(context.evidence), + ) + notify(progress_callback, "checking", "started", 45, "Checking contract risks") candidates = self.compliance.analyze(context) context.merge_evidence(self.knowledge.retrieve_for_findings(candidates, clauses)) + notify( + progress_callback, + "checking", + "completed", + 57, + "Candidate findings generated", + candidate_count=len(candidates), + ) + + notify(progress_callback, "verifying", "started", 60, "Verifying candidate findings") verified = self.verifier.verify(candidates, context) + notify( + progress_callback, + "verifying", + "completed", + 70, + "Candidate findings verified", + candidate_count=len(verified), + ) + + notify(progress_callback, "scoring", "started", 73, "Scoring evidence") findings = self._score_findings(verified) context.add_findings(findings) + notify( + progress_callback, + "scoring", + "completed", + 80, + "Evidence scoring completed", + finding_count=len(findings), + accepted_count=sum(1 for finding in findings if finding.accepted), + ) + + notify(progress_callback, "rewriting", "started", 83, "Drafting clause rewrites") if include_rewrites: context.add_rewrites(self.rewriter.rewrite(context, findings)) + notify( + progress_callback, + "rewriting", + "completed", + 90, + "Clause rewrites completed", + rewrite_count=len(context.rewrites), + ) + notify(progress_callback, "reporting", "started", 93, "Building analysis report") report = self.postprocessor.build_report(context) if output_dir: - self.postprocessor.write_outputs(report, output_dir, output_formats) + self.postprocessor.write_outputs(report, output_dir, formats) + notify( + progress_callback, + "reporting", + "completed", + 98, + "Analysis report built", + output_dir=str(output_dir) if output_dir else None, + output_formats=list(formats), + ) return report def _score_findings(self, candidates: List[CandidateFinding]) -> List[Finding]: diff --git a/clauseguard/schemas.py b/clauseguard/schemas.py index d31dee6..0c352ce 100644 --- a/clauseguard/schemas.py +++ b/clauseguard/schemas.py @@ -7,6 +7,7 @@ Severity = Literal["LOW", "MEDIUM", "HIGH"] VerifierStatus = Literal["verified", "not_run", "unavailable"] +SCHEMA_VERSION: Literal["1.0"] = "1.0" class LoadedDocument(BaseModel): @@ -97,6 +98,7 @@ class Rewrite(BaseModel): class AnalysisReport(BaseModel): + schema_version: Literal["1.0"] = SCHEMA_VERSION document_id: str file_path: str title: str @@ -117,3 +119,47 @@ class AnalysisReport(BaseModel): "Coverage depends on the configured rule set, reference corpus, and document quality.", ] ) + + +class ComparisonSummary(BaseModel): + matched: int = Field(ge=0) + changed: int = Field(ge=0) + added: int = Field(ge=0) + removed: int = Field(ge=0) + + +class ClauseDelta(BaseModel): + status: Literal["unchanged", "changed", "added", "removed"] + similarity: float = Field(ge=0.0, le=1.0) + original_clause_id: Optional[str] = None + original_title: Optional[str] = None + original_category: Optional[str] = None + modified_clause_id: Optional[str] = None + modified_title: Optional[str] = None + modified_category: Optional[str] = None + original_risk_terms: List[str] = Field(default_factory=list) + modified_risk_terms: List[str] = Field(default_factory=list) + original_preview: str = "" + modified_preview: str = "" + + +class ComparisonRiskSignal(BaseModel): + type: str + clause: str + detail: str + severity: Severity + + +class ComparisonReport(BaseModel): + schema_version: Literal["1.0"] = SCHEMA_VERSION + comparison_id: str + original_document: str + modified_document: str + original_type: str + modified_type: str + original_clause_count: int = Field(ge=0) + modified_clause_count: int = Field(ge=0) + summary: ComparisonSummary + clause_deltas: List[ClauseDelta] + risk_signals: List[ComparisonRiskSignal] + notes: List[str] = Field(default_factory=list) diff --git a/contracts/schemas/analysis-report-v1.schema.json b/contracts/schemas/analysis-report-v1.schema.json new file mode 100644 index 0000000..6145da4 --- /dev/null +++ b/contracts/schemas/analysis-report-v1.schema.json @@ -0,0 +1,421 @@ +{ + "$defs": { + "Clause": { + "properties": { + "category": { + "default": "General", + "title": "Category", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "order": { + "title": "Order", + "type": "integer" + }, + "risk_terms": { + "items": { + "type": "string" + }, + "title": "Risk Terms", + "type": "array" + }, + "text": { + "title": "Text", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "id", + "order", + "title", + "text" + ], + "title": "Clause", + "type": "object" + }, + "ComponentScores": { + "properties": { + "clause_structure": { + "maximum": 1.0, + "minimum": 0.0, + "title": "Clause Structure", + "type": "number" + }, + "deterministic_rules": { + "maximum": 1.0, + "minimum": 0.0, + "title": "Deterministic Rules", + "type": "number" + }, + "final": { + "maximum": 1.0, + "minimum": 0.0, + "title": "Final", + "type": "number" + }, + "primary_reasoning": { + "maximum": 1.0, + "minimum": 0.0, + "title": "Primary Reasoning", + "type": "number" + }, + "rag_evidence": { + "maximum": 1.0, + "minimum": 0.0, + "title": "Rag Evidence", + "type": "number" + }, + "verifier_agreement": { + "maximum": 1.0, + "minimum": 0.0, + "title": "Verifier Agreement", + "type": "number" + }, + "weights": { + "additionalProperties": { + "type": "number" + }, + "title": "Weights", + "type": "object" + } + }, + "required": [ + "deterministic_rules", + "rag_evidence", + "primary_reasoning", + "verifier_agreement", + "clause_structure", + "final", + "weights" + ], + "title": "ComponentScores", + "type": "object" + }, + "Evidence": { + "properties": { + "clause_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Clause Id" + }, + "id": { + "title": "Id", + "type": "string" + }, + "relevance": { + "maximum": 1.0, + "minimum": 0.0, + "title": "Relevance", + "type": "number" + }, + "source": { + "title": "Source", + "type": "string" + }, + "text": { + "title": "Text", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "id", + "source", + "title", + "text", + "relevance" + ], + "title": "Evidence", + "type": "object" + }, + "Finding": { + "properties": { + "acceptance_threshold": { + "default": 0.55, + "maximum": 1.0, + "minimum": 0.0, + "title": "Acceptance Threshold", + "type": "number" + }, + "accepted": { + "title": "Accepted", + "type": "boolean" + }, + "clause_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Clause Id" + }, + "clause_title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Clause Title" + }, + "component_scores": { + "$ref": "#/$defs/ComponentScores" + }, + "evidence": { + "items": { + "$ref": "#/$defs/Evidence" + }, + "title": "Evidence", + "type": "array" + }, + "explanation": { + "title": "Explanation", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "issue_type": { + "title": "Issue Type", + "type": "string" + }, + "model_confidence": { + "maximum": 1.0, + "minimum": 0.0, + "title": "Model Confidence", + "type": "number" + }, + "rule_id": { + "default": "", + "title": "Rule Id", + "type": "string" + }, + "severity": { + "enum": [ + "LOW", + "MEDIUM", + "HIGH" + ], + "title": "Severity", + "type": "string" + }, + "signals": { + "items": { + "type": "string" + }, + "title": "Signals", + "type": "array" + }, + "suggested_rewrite": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Suggested Rewrite" + }, + "verifier_confidence": { + "maximum": 1.0, + "minimum": 0.0, + "title": "Verifier Confidence", + "type": "number" + }, + "verifier_rationale": { + "default": "", + "title": "Verifier Rationale", + "type": "string" + }, + "verifier_status": { + "default": "not_run", + "enum": [ + "verified", + "not_run", + "unavailable" + ], + "title": "Verifier Status", + "type": "string" + } + }, + "required": [ + "id", + "issue_type", + "severity", + "explanation", + "component_scores", + "accepted", + "model_confidence", + "verifier_confidence" + ], + "title": "Finding", + "type": "object" + }, + "LegalEntity": { + "properties": { + "label": { + "title": "Label", + "type": "string" + }, + "source": { + "default": "heuristic", + "title": "Source", + "type": "string" + }, + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "text", + "label" + ], + "title": "LegalEntity", + "type": "object" + }, + "Rewrite": { + "properties": { + "clause_id": { + "title": "Clause Id", + "type": "string" + }, + "original_text": { + "title": "Original Text", + "type": "string" + }, + "rationale": { + "title": "Rationale", + "type": "string" + }, + "rewritten_text": { + "title": "Rewritten Text", + "type": "string" + } + }, + "required": [ + "clause_id", + "original_text", + "rewritten_text", + "rationale" + ], + "title": "Rewrite", + "type": "object" + } + }, + "properties": { + "clauses": { + "items": { + "$ref": "#/$defs/Clause" + }, + "title": "Clauses", + "type": "array" + }, + "document_id": { + "title": "Document Id", + "type": "string" + }, + "document_type": { + "title": "Document Type", + "type": "string" + }, + "entities": { + "items": { + "$ref": "#/$defs/LegalEntity" + }, + "title": "Entities", + "type": "array" + }, + "evidence": { + "items": { + "$ref": "#/$defs/Evidence" + }, + "title": "Evidence", + "type": "array" + }, + "file_path": { + "title": "File Path", + "type": "string" + }, + "findings": { + "items": { + "$ref": "#/$defs/Finding" + }, + "title": "Findings", + "type": "array" + }, + "generated_at": { + "title": "Generated At", + "type": "string" + }, + "limitations": { + "items": { + "type": "string" + }, + "title": "Limitations", + "type": "array" + }, + "rewrites": { + "items": { + "$ref": "#/$defs/Rewrite" + }, + "title": "Rewrites", + "type": "array" + }, + "schema_version": { + "const": "1.0", + "default": "1.0", + "title": "Schema Version", + "type": "string" + }, + "summary": { + "title": "Summary", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "document_id", + "file_path", + "title", + "document_type", + "summary", + "clauses", + "entities", + "evidence", + "findings", + "rewrites" + ], + "title": "AnalysisReport", + "type": "object" +} diff --git a/contracts/schemas/comparison-report-v1.schema.json b/contracts/schemas/comparison-report-v1.schema.json new file mode 100644 index 0000000..eb03647 --- /dev/null +++ b/contracts/schemas/comparison-report-v1.schema.json @@ -0,0 +1,267 @@ +{ + "$defs": { + "ClauseDelta": { + "properties": { + "modified_category": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Modified Category" + }, + "modified_clause_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Modified Clause Id" + }, + "modified_preview": { + "default": "", + "title": "Modified Preview", + "type": "string" + }, + "modified_risk_terms": { + "items": { + "type": "string" + }, + "title": "Modified Risk Terms", + "type": "array" + }, + "modified_title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Modified Title" + }, + "original_category": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Original Category" + }, + "original_clause_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Original Clause Id" + }, + "original_preview": { + "default": "", + "title": "Original Preview", + "type": "string" + }, + "original_risk_terms": { + "items": { + "type": "string" + }, + "title": "Original Risk Terms", + "type": "array" + }, + "original_title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Original Title" + }, + "similarity": { + "maximum": 1.0, + "minimum": 0.0, + "title": "Similarity", + "type": "number" + }, + "status": { + "enum": [ + "unchanged", + "changed", + "added", + "removed" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "status", + "similarity" + ], + "title": "ClauseDelta", + "type": "object" + }, + "ComparisonRiskSignal": { + "properties": { + "clause": { + "title": "Clause", + "type": "string" + }, + "detail": { + "title": "Detail", + "type": "string" + }, + "severity": { + "enum": [ + "LOW", + "MEDIUM", + "HIGH" + ], + "title": "Severity", + "type": "string" + }, + "type": { + "title": "Type", + "type": "string" + } + }, + "required": [ + "type", + "clause", + "detail", + "severity" + ], + "title": "ComparisonRiskSignal", + "type": "object" + }, + "ComparisonSummary": { + "properties": { + "added": { + "minimum": 0, + "title": "Added", + "type": "integer" + }, + "changed": { + "minimum": 0, + "title": "Changed", + "type": "integer" + }, + "matched": { + "minimum": 0, + "title": "Matched", + "type": "integer" + }, + "removed": { + "minimum": 0, + "title": "Removed", + "type": "integer" + } + }, + "required": [ + "matched", + "changed", + "added", + "removed" + ], + "title": "ComparisonSummary", + "type": "object" + } + }, + "properties": { + "clause_deltas": { + "items": { + "$ref": "#/$defs/ClauseDelta" + }, + "title": "Clause Deltas", + "type": "array" + }, + "comparison_id": { + "title": "Comparison Id", + "type": "string" + }, + "modified_clause_count": { + "minimum": 0, + "title": "Modified Clause Count", + "type": "integer" + }, + "modified_document": { + "title": "Modified Document", + "type": "string" + }, + "modified_type": { + "title": "Modified Type", + "type": "string" + }, + "notes": { + "items": { + "type": "string" + }, + "title": "Notes", + "type": "array" + }, + "original_clause_count": { + "minimum": 0, + "title": "Original Clause Count", + "type": "integer" + }, + "original_document": { + "title": "Original Document", + "type": "string" + }, + "original_type": { + "title": "Original Type", + "type": "string" + }, + "risk_signals": { + "items": { + "$ref": "#/$defs/ComparisonRiskSignal" + }, + "title": "Risk Signals", + "type": "array" + }, + "schema_version": { + "const": "1.0", + "default": "1.0", + "title": "Schema Version", + "type": "string" + }, + "summary": { + "$ref": "#/$defs/ComparisonSummary" + } + }, + "required": [ + "comparison_id", + "original_document", + "modified_document", + "original_type", + "modified_type", + "original_clause_count", + "modified_clause_count", + "summary", + "clause_deltas", + "risk_signals" + ], + "title": "ComparisonReport", + "type": "object" +} diff --git a/contracts/schemas/progress-event-v1.schema.json b/contracts/schemas/progress-event-v1.schema.json new file mode 100644 index 0000000..39f3b61 --- /dev/null +++ b/contracts/schemas/progress-event-v1.schema.json @@ -0,0 +1,117 @@ +{ + "$defs": { + "EventError": { + "properties": { + "code": { + "title": "Code", + "type": "string" + }, + "message": { + "title": "Message", + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "title": "EventError", + "type": "object" + } + }, + "properties": { + "details": { + "additionalProperties": true, + "title": "Details", + "type": "object" + }, + "error": { + "anyOf": [ + { + "$ref": "#/$defs/EventError" + }, + { + "type": "null" + } + ], + "default": null + }, + "message": { + "title": "Message", + "type": "string" + }, + "progress": { + "maximum": 100, + "minimum": 0, + "title": "Progress", + "type": "integer" + }, + "run_id": { + "title": "Run Id", + "type": "string" + }, + "schema_version": { + "const": "1.0", + "default": "1.0", + "title": "Schema Version", + "type": "string" + }, + "sequence": { + "minimum": 1, + "title": "Sequence", + "type": "integer" + }, + "stage": { + "enum": [ + "queued", + "loading", + "extracting", + "retrieving", + "checking", + "verifying", + "scoring", + "rewriting", + "comparing", + "reporting", + "completed", + "failed" + ], + "title": "Stage", + "type": "string" + }, + "status": { + "enum": [ + "queued", + "started", + "completed", + "failed" + ], + "title": "Status", + "type": "string" + }, + "timestamp": { + "title": "Timestamp", + "type": "string" + }, + "type": { + "enum": [ + "progress", + "completed", + "error" + ], + "title": "Type", + "type": "string" + } + }, + "required": [ + "run_id", + "sequence", + "type", + "stage", + "status", + "progress", + "message" + ], + "title": "ProgressEvent", + "type": "object" +} diff --git a/tests/test_cli.py b/tests/test_cli.py index ad9a30f..6d32376 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,7 +1,10 @@ import json from pathlib import Path +import pytest + from clauseguard.cli import main +from clauseguard.exit_codes import ExitCode def test_models_command_emits_json(capsys): @@ -54,7 +57,76 @@ def test_analyze_command_returns_clean_error_for_missing_document(tmp_path: Path exit_code = main(["analyze", str(tmp_path / "missing.txt"), "--mock-models"]) captured = capsys.readouterr() - assert exit_code == 2 + assert exit_code == ExitCode.DOCUMENT + assert "Document not found" in captured.err + + +def test_analyze_command_emits_versioned_progress_events(tmp_path: Path, capsys): + document = tmp_path / "agreement.txt" + document.write_text( + "SERVICES AGREEMENT\n\n1. Payment. Customer shall pay within thirty days.", + encoding="utf-8", + ) + output_dir = tmp_path / "reports" + + exit_code = main( + [ + "analyze", + str(document), + "--mock-models", + "--events-jsonl", + "--format", + "json", + "--run-id", + "analysis-test-1", + "--output-dir", + str(output_dir), + ] + ) + + captured = capsys.readouterr() + events = [json.loads(line) for line in captured.out.splitlines()] + assert exit_code == ExitCode.SUCCESS + assert captured.err == "" + assert events[0]["stage"] == "queued" + assert events[-1]["type"] == "completed" + assert events[-1]["run_id"] == "analysis-test-1" + assert [event["sequence"] for event in events] == list(range(1, len(events) + 1)) + assert {event["stage"] for event in events} >= { + "loading", + "extracting", + "retrieving", + "checking", + "verifying", + "scoring", + "rewriting", + "reporting", + "completed", + } + assert all(event["schema_version"] == "1.0" for event in events) + report = json.loads((output_dir / "analysis_report.json").read_text(encoding="utf-8")) + assert report["schema_version"] == "1.0" + assert report["document_id"] == "analysis-test-1" + + +def test_analyze_event_stream_reports_typed_document_error(tmp_path: Path, capsys): + exit_code = main( + [ + "analyze", + str(tmp_path / "missing.txt"), + "--mock-models", + "--events-jsonl", + "--run-id", + "missing-document", + ] + ) + + captured = capsys.readouterr() + events = [json.loads(line) for line in captured.out.splitlines()] + assert exit_code == ExitCode.DOCUMENT + assert events[-1]["type"] == "error" + assert events[-1]["error"]["code"] == "document_error" + assert events[-1]["details"]["exit_code"] == ExitCode.DOCUMENT assert "Document not found" in captured.err @@ -85,6 +157,63 @@ def test_compare_command_writes_comparison_report(tmp_path: Path, capsys): assert "Comparison complete" in capsys.readouterr().out +def test_compare_command_emits_machine_events(tmp_path: Path, capsys): + original = tmp_path / "original.txt" + modified = tmp_path / "modified.txt" + original.write_text( + "SUPPLY AGREEMENT\n\n1. Assignment. Neither party may assign without consent.", + encoding="utf-8", + ) + modified.write_text( + "SUPPLY AGREEMENT\n\n1. Assignment. Supplier may assign without consent.", + encoding="utf-8", + ) + + exit_code = main( + [ + "compare", + str(original), + str(modified), + "--events-jsonl", + "--format", + "json", + "--run-id", + "comparison-test-1", + "--output-dir", + str(tmp_path / "comparison"), + ] + ) + + events = [json.loads(line) for line in capsys.readouterr().out.splitlines()] + assert exit_code == ExitCode.SUCCESS + assert events[-1]["type"] == "completed" + assert events[-1]["details"]["comparison_id"] == "comparison-test-1" + assert "comparing" in {event["stage"] for event in events} + assert (tmp_path / "comparison" / "comparison_report.json").exists() + assert not (tmp_path / "comparison" / "comparison_report.md").exists() + + +@pytest.mark.parametrize( + ("schema_name", "expected_title"), + [ + ("analysis-report", "AnalysisReport"), + ("comparison-report", "ComparisonReport"), + ("progress-event", "ProgressEvent"), + ], +) +def test_schema_command_exports_versioned_contract( + tmp_path: Path, schema_name: str, expected_title: str +): + output = tmp_path / f"{schema_name}.schema.json" + + exit_code = main(["schema", schema_name, "--output", str(output)]) + + schema = json.loads(output.read_text(encoding="utf-8")) + assert exit_code == ExitCode.SUCCESS + assert schema["title"] == expected_title + assert schema["properties"]["schema_version"]["const"] == "1.0" + + def test_evaluate_command_runs_local_benchmark(tmp_path: Path, capsys): document = tmp_path / "contract.txt" document.write_text( diff --git a/tests/test_exit_codes.py b/tests/test_exit_codes.py new file mode 100644 index 0000000..1eab9fd --- /dev/null +++ b/tests/test_exit_codes.py @@ -0,0 +1,20 @@ +from clauseguard.config import ConfigError +from clauseguard.document import DocumentLoadError +from clauseguard.exit_codes import ExitCode, classify_exception +from clauseguard.model_router import ModelCallError, ModelTimeoutError, UsageLimitError + + +def test_operational_errors_have_stable_exit_codes(): + cases = [ + (ConfigError("bad config"), ExitCode.CONFIGURATION, "configuration_error"), + (DocumentLoadError("bad document"), ExitCode.DOCUMENT, "document_error"), + (ModelCallError("provider failed"), ExitCode.MODEL, "model_error"), + (UsageLimitError("cap reached"), ExitCode.USAGE_LIMIT, "usage_limit"), + (ModelTimeoutError("timed out"), ExitCode.TIMEOUT, "timeout"), + (RuntimeError("unexpected"), ExitCode.INTERNAL, "internal_error"), + ] + + for error, expected_exit, expected_code in cases: + exit_code, error_code = classify_exception(error) + assert exit_code == expected_exit + assert error_code == expected_code diff --git a/tests/test_model_router.py b/tests/test_model_router.py index 788cfe5..7cda70f 100644 --- a/tests/test_model_router.py +++ b/tests/test_model_router.py @@ -1,7 +1,13 @@ import pytest +import requests from clauseguard.config import AppConfig -from clauseguard.model_router import ModelResponseError, ModelRouter +from clauseguard.model_router import ( + ModelResponseError, + ModelRouter, + ModelTimeoutError, + UsageLimitError, +) def test_generate_json_raises_clear_error_for_malformed_model_output(monkeypatch): @@ -52,3 +58,30 @@ def test_groq_non_json_http_response_has_clear_error(monkeypatch): with pytest.raises(ModelResponseError, match="non-JSON HTTP response"): router.generate_text("reasoning", "system", "prompt") + + +def test_groq_timeout_has_distinct_error(monkeypatch): + router = ModelRouter(AppConfig(groq_api_key="groq-key", mock_models=False)) + + def raise_timeout(*_args, **_kwargs): + raise requests.Timeout("request timed out") + + monkeypatch.setattr("clauseguard.model_router.requests.post", raise_timeout) + + with pytest.raises(ModelTimeoutError, match="timed out"): + router.generate_text("reasoning", "system", "prompt") + + +def test_groq_rate_limit_has_usage_limit_error(monkeypatch): + router = ModelRouter(AppConfig(groq_api_key="groq-key", mock_models=False)) + + class FakeResponse: + status_code = 429 + text = "rate limited" + + monkeypatch.setattr( + "clauseguard.model_router.requests.post", lambda *_args, **_kwargs: FakeResponse() + ) + + with pytest.raises(UsageLimitError, match="rate limit"): + router.generate_text("reasoning", "system", "prompt") From a5cd2e781e963424461dc8967085986677be0405 Mon Sep 17 00:00:00 2001 From: arpitJ-dev Date: Tue, 11 Aug 2026 08:13:40 -0700 Subject: [PATCH 2/7] feat(api): add Go analysis control plane --- .env.example | 11 + .github/workflows/ci.yml | 34 ++ .gitignore | 5 + apps/server/cmd/clauseguard-server/main.go | 98 ++++ apps/server/go.mod | 20 + apps/server/go.sum | 52 ++ apps/server/internal/api/integration_test.go | 196 +++++++ apps/server/internal/api/server.go | 495 ++++++++++++++++++ apps/server/internal/api/server_test.go | 378 ++++++++++++++ apps/server/internal/config/config.go | 196 +++++++ apps/server/internal/config/config_test.go | 145 ++++++ apps/server/internal/domain/job.go | 137 +++++ apps/server/internal/domain/job_test.go | 52 ++ apps/server/internal/engine/runner.go | 453 ++++++++++++++++ apps/server/internal/engine/runner_test.go | 247 +++++++++ apps/server/internal/jobs/manager.go | 518 +++++++++++++++++++ apps/server/internal/jobs/manager_test.go | 499 ++++++++++++++++++ apps/server/internal/store/sqlite.go | 384 ++++++++++++++ apps/server/internal/store/sqlite_test.go | 145 ++++++ contracts/openapi-v1.yaml | 416 +++++++++++++++ 20 files changed, 4481 insertions(+) create mode 100644 apps/server/cmd/clauseguard-server/main.go create mode 100644 apps/server/go.mod create mode 100644 apps/server/go.sum create mode 100644 apps/server/internal/api/integration_test.go create mode 100644 apps/server/internal/api/server.go create mode 100644 apps/server/internal/api/server_test.go create mode 100644 apps/server/internal/config/config.go create mode 100644 apps/server/internal/config/config_test.go create mode 100644 apps/server/internal/domain/job.go create mode 100644 apps/server/internal/domain/job_test.go create mode 100644 apps/server/internal/engine/runner.go create mode 100644 apps/server/internal/engine/runner_test.go create mode 100644 apps/server/internal/jobs/manager.go create mode 100644 apps/server/internal/jobs/manager_test.go create mode 100644 apps/server/internal/store/sqlite.go create mode 100644 apps/server/internal/store/sqlite_test.go create mode 100644 contracts/openapi-v1.yaml diff --git a/.env.example b/.env.example index 9508c67..99838ba 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,14 @@ CLAUSEGUARD_MAX_VERIFIER_INPUT_TOKENS=7000 # Test/demo mode only. Leave false for real agentic cloud runs. CLAUSEGUARD_MOCK_MODELS=false + +# Go control plane. The workspace is auto-discovered when this is left blank. +CLAUSEGUARD_SERVER_ADDR=127.0.0.1:8080 +CLAUSEGUARD_PYTHON=python +CLAUSEGUARD_WORKSPACE= +CLAUSEGUARD_DATA_DIR=.clauseguard +CLAUSEGUARD_MAX_UPLOAD_BYTES=26214400 +CLAUSEGUARD_JOB_TIMEOUT=10m +CLAUSEGUARD_MAX_CONCURRENT_JOBS=2 +CLAUSEGUARD_MAX_ACTIVE_JOBS=20 +CLAUSEGUARD_ALLOWED_ORIGIN=http://localhost:5173 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45fd1b8..aae15e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,37 @@ jobs: run: python -m mypy clauseguard - name: Test run: python -m pytest --cov=clauseguard --cov-report=term-missing --cov-fail-under=80 + + control-plane: + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/server + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: apps/server/go.mod + cache-dependency-path: apps/server/go.sum + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - name: Install Python analysis engine + working-directory: . + run: python -m pip install -e . + - name: Check formatting + run: test -z "$(gofmt -l .)" + - name: Vet + run: go vet ./... + - name: Test control plane and Python bridge + env: + CLAUSEGUARD_INTEGRATION_PYTHON: python + CLAUSEGUARD_INTEGRATION_WORKSPACE: ${{ github.workspace }} + run: | + go test -race -coverpkg=./... -coverprofile=coverage.out ./... + total="$(go tool cover -func=coverage.out | awk '/^total:/ {gsub(/%/, "", $3); print $3}')" + echo "Go statement coverage: ${total}%" + awk -v total="$total" 'BEGIN { if (total < 70) exit 1 }' + - name: Build + run: go build ./... diff --git a/.gitignore b/.gitignore index 4ba8083..1f96e90 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,8 @@ wheels/ *.zip process.ipynb /[0-9]* + +# Go control-plane binaries +/apps/server/clauseguard-server +/apps/server/clauseguard-server.exe +/apps/server/coverage.out diff --git a/apps/server/cmd/clauseguard-server/main.go b/apps/server/cmd/clauseguard-server/main.go new file mode 100644 index 0000000..10a969f --- /dev/null +++ b/apps/server/cmd/clauseguard-server/main.go @@ -0,0 +1,98 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/api" + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/config" + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/engine" + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/jobs" + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/store" +) + +func main() { + if err := run(); err != nil { + slog.Error("ClauseGuard server stopped", "error", err) + os.Exit(1) + } +} + +func run() error { + settings, err := config.Load() + if err != nil { + return err + } + repository, err := store.Open(filepath.Join(settings.DataDir, "jobs.db")) + if err != nil { + return err + } + defer repository.Close() + + runner := &engine.CLIRunner{ + PythonExecutable: settings.PythonExecutable, + Workspace: settings.Workspace, + MockModels: settings.MockModels, + } + manager, err := jobs.New(repository, runner, jobs.Options{ + Timeout: settings.JobTimeout, + MaxConcurrentJobs: settings.MaxConcurrentJobs, + MaxActiveJobs: settings.MaxActiveJobs, + DataDir: settings.DataDir, + }) + if err != nil { + return err + } + handler, err := api.New(manager, api.Config{ + DataDir: settings.DataDir, + MaxUploadBytes: settings.MaxUploadBytes, + AllowedOrigin: settings.AllowedOrigin, + }) + if err != nil { + return err + } + + httpServer := &http.Server{ + Addr: settings.Address, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 2 * time.Minute, + IdleTimeout: 60 * time.Second, + } + serverErrors := make(chan error, 1) + go func() { + slog.Info( + "ClauseGuard control plane listening", + "address", settings.Address, + "mock_models", settings.MockModels, + ) + serverErrors <- httpServer.ListenAndServe() + }() + + signalContext, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + select { + case <-signalContext.Done(): + case err := <-serverErrors: + if !errors.Is(err, http.ErrServerClosed) { + return err + } + } + + shutdownContext, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + serverShutdown := make(chan error, 1) + go func() { + serverShutdown <- httpServer.Shutdown(shutdownContext) + }() + managerError := manager.Shutdown(shutdownContext) + serverError := <-serverShutdown + return errors.Join(serverError, managerError) +} diff --git a/apps/server/go.mod b/apps/server/go.mod new file mode 100644 index 0000000..65eb294 --- /dev/null +++ b/apps/server/go.mod @@ -0,0 +1,20 @@ +module github.com/arpitJ-dev/ClauseGuard-Agent/apps/server + +go 1.25.0 + +require ( + github.com/joho/godotenv v1.5.1 + modernc.org/sqlite v1.56.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/apps/server/go.sum b/apps/server/go.sum new file mode 100644 index 0000000..fa0c230 --- /dev/null +++ b/apps/server/go.sum @@ -0,0 +1,52 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0= +modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/apps/server/internal/api/integration_test.go b/apps/server/internal/api/integration_test.go new file mode 100644 index 0000000..472d269 --- /dev/null +++ b/apps/server/internal/api/integration_test.go @@ -0,0 +1,196 @@ +package api_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/api" + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/domain" + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/engine" + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/jobs" + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/store" +) + +func TestHTTPControlPlaneWithRealPythonBridge(t *testing.T) { + python := os.Getenv("CLAUSEGUARD_INTEGRATION_PYTHON") + workspace := os.Getenv("CLAUSEGUARD_INTEGRATION_WORKSPACE") + if python == "" || workspace == "" { + t.Skip("set CLAUSEGUARD_INTEGRATION_PYTHON and CLAUSEGUARD_INTEGRATION_WORKSPACE") + } + + dataDir := t.TempDir() + repository, err := store.Open(filepath.Join(dataDir, "jobs.db")) + if err != nil { + t.Fatal(err) + } + runner := &engine.CLIRunner{PythonExecutable: python, Workspace: workspace, MockModels: true} + manager, err := jobs.New(repository, runner, jobs.Options{ + Timeout: 30 * time.Second, + MaxConcurrentJobs: 1, + MaxActiveJobs: 4, + DataDir: dataDir, + }) + if err != nil { + t.Fatal(err) + } + handler, err := api.New(manager, api.Config{DataDir: dataDir, MaxUploadBytes: 1 << 20}) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + t.Cleanup(func() { + server.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = manager.Shutdown(ctx) + _ = repository.Close() + }) + + healthResponse, err := http.Get(server.URL + "/api/v1/health") + if err != nil { + t.Fatal(err) + } + _ = healthResponse.Body.Close() + if healthResponse.StatusCode != http.StatusOK { + t.Fatalf("health check returned %d", healthResponse.StatusCode) + } + + analysisID := submitJob(t, server.URL+"/api/v1/analyses", map[string]testUpload{ + "document": { + name: "agreement.txt", + content: "SERVICE AGREEMENT\n1. Payment is due within 30 days.\n" + + "2. Either party may terminate with written notice.", + }, + }) + analysis := awaitHTTPJob(t, server.URL, analysisID) + assertReport(t, server.URL, analysis, map[string]string{ + "schema_version": "1.0", + "file_path": "agreement.txt", + }) + + comparisonID := submitJob(t, server.URL+"/api/v1/comparisons", map[string]testUpload{ + "original": {name: "original.txt", content: "SERVICE AGREEMENT\n1. Payment is due within 30 days."}, + "modified": {name: "modified.txt", content: "SERVICE AGREEMENT\n1. Payment is due within 10 days."}, + }) + comparison := awaitHTTPJob(t, server.URL, comparisonID) + assertReport(t, server.URL, comparison, map[string]string{ + "schema_version": "1.0", + "original_document": "original.txt", + "modified_document": "modified.txt", + }) + if _, err := os.Stat(filepath.Join(dataDir, "jobs", comparisonID, "output", "comparison_report.md")); !os.IsNotExist(err) { + t.Fatalf("control-plane comparison wrote an unexpected Markdown artifact: %v", err) + } +} + +type testUpload struct { + name string + content string +} + +func submitJob(t *testing.T, endpoint string, uploads map[string]testUpload) string { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + for field, upload := range uploads { + part, err := writer.CreateFormFile(field, upload.name) + if err != nil { + t.Fatal(err) + } + if _, err := part.Write([]byte(upload.content)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + request, err := http.NewRequest(http.MethodPost, endpoint, &body) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", writer.FormDataContentType()) + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + payload, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusAccepted { + t.Fatalf("submit returned %d: %s", response.StatusCode, payload) + } + var envelope struct { + Job domain.Job `json:"job"` + } + if err := json.Unmarshal(payload, &envelope); err != nil { + t.Fatal(err) + } + if envelope.Job.ID == "" { + t.Fatal("submit response did not include a job ID") + } + return envelope.Job.ID +} + +func awaitHTTPJob(t *testing.T, serverURL, id string) domain.Job { + t.Helper() + deadline := time.Now().Add(20 * time.Second) + for time.Now().Before(deadline) { + response, err := http.Get(serverURL + "/api/v1/jobs/" + id) + if err != nil { + t.Fatal(err) + } + var envelope struct { + Job domain.Job `json:"job"` + } + decodeError := json.NewDecoder(response.Body).Decode(&envelope) + _ = response.Body.Close() + if response.StatusCode != http.StatusOK || decodeError != nil { + t.Fatalf("job status failed: HTTP %d, decode=%v", response.StatusCode, decodeError) + } + if envelope.Job.Status.Terminal() { + if envelope.Job.Status != domain.JobCompleted { + t.Fatalf("job failed: %+v", envelope.Job) + } + return envelope.Job + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("job %s did not complete", id) + return domain.Job{} +} + +func assertReport(t *testing.T, serverURL string, job domain.Job, expected map[string]string) { + t.Helper() + if job.ReportURL == "" { + t.Fatalf("completed job has no report URL: %+v", job) + } + response, err := http.Get(serverURL + job.ReportURL) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("report returned %d", response.StatusCode) + } + var report map[string]any + if err := json.NewDecoder(response.Body).Decode(&report); err != nil { + t.Fatal(err) + } + for field, want := range expected { + if got := fmt.Sprint(report[field]); got != want { + t.Fatalf("report field %s is %q, want %q", field, got, want) + } + } +} diff --git a/apps/server/internal/api/server.go b/apps/server/internal/api/server.go new file mode 100644 index 0000000..7e1a53d --- /dev/null +++ b/apps/server/internal/api/server.go @@ -0,0 +1,495 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/domain" + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/jobs" + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/store" +) + +const healthTimeout = 5 * time.Second + +var allowedExtensions = map[string]struct{}{ + ".docx": {}, + ".pdf": {}, + ".txt": {}, +} + +var jobIDPattern = regexp.MustCompile(`^[a-f0-9]{32}$`) + +type JobService interface { + Submit(context.Context, domain.JobSpec) (domain.Job, error) + Get(context.Context, string) (domain.Job, error) + List(context.Context, int) ([]domain.Job, error) + Cancel(context.Context, string) (domain.Job, error) + Delete(context.Context, string) (domain.Job, bool, error) + Subscribe(context.Context, string) (<-chan domain.Job, func(), error) + Health(context.Context) (domain.Health, error) +} + +type Config struct { + DataDir string + MaxUploadBytes int64 + AllowedOrigin string +} + +type Server struct { + service JobService + dataDir string + maxUploadBytes int64 + allowedOrigin string +} + +type errorPayload struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type errorResponse struct { + Error errorPayload `json:"error"` +} + +func New(service JobService, config Config) (http.Handler, error) { + if service == nil { + return nil, errors.New("job service is required") + } + if config.MaxUploadBytes <= 0 { + return nil, errors.New("maximum upload size must be positive") + } + dataDir, err := filepath.Abs(config.DataDir) + if err != nil { + return nil, fmt.Errorf("resolve API data directory: %w", err) + } + server := &Server{ + service: service, + dataDir: dataDir, + maxUploadBytes: config.MaxUploadBytes, + allowedOrigin: strings.TrimSpace(config.AllowedOrigin), + } + + mux := http.NewServeMux() + mux.HandleFunc("POST /api/v1/analyses", server.handleAnalysis) + mux.HandleFunc("POST /api/v1/comparisons", server.handleComparison) + mux.HandleFunc("GET /api/v1/jobs", server.handleListJobs) + mux.HandleFunc("GET /api/v1/jobs/{id}", server.handleGetJob) + mux.HandleFunc("GET /api/v1/jobs/{id}/events", server.handleJobEvents) + mux.HandleFunc("GET /api/v1/jobs/{id}/report", server.handleReport) + mux.HandleFunc("DELETE /api/v1/jobs/{id}", server.handleDeleteJob) + mux.HandleFunc("GET /api/v1/health", server.handleHealth) + return server.middleware(mux), nil +} + +func (server *Server) handleAnalysis(writer http.ResponseWriter, request *http.Request) { + server.createJob(writer, request, domain.JobTypeAnalysis, []string{"document"}) +} + +func (server *Server) handleComparison(writer http.ResponseWriter, request *http.Request) { + server.createJob(writer, request, domain.JobTypeComparison, []string{"original", "modified"}) +} + +func (server *Server) createJob( + writer http.ResponseWriter, + request *http.Request, + jobType domain.JobType, + fields []string, +) { + jobID, err := domain.NewID() + if err != nil { + writeError(writer, http.StatusInternalServerError, "id_generation_failed", "Could not create a job identifier.") + return + } + jobDir := filepath.Join(server.dataDir, "jobs", jobID) + inputDir := filepath.Join(jobDir, "inputs") + outputDir := filepath.Join(jobDir, "output") + if err := os.MkdirAll(inputDir, 0o750); err != nil { + writeError(writer, http.StatusInternalServerError, "storage_error", "Could not prepare job storage.") + return + } + removeJobFiles := true + defer func() { + if removeJobFiles { + _ = os.RemoveAll(jobDir) + } + }() + + request.Body = http.MaxBytesReader(writer, request.Body, server.maxUploadBytes) + reader, err := request.MultipartReader() + if err != nil { + writeError(writer, http.StatusUnsupportedMediaType, "multipart_required", "Use multipart/form-data for document uploads.") + return + } + inputs, err := server.readUploads(reader, inputDir, fields) + if err != nil { + server.writeUploadError(writer, err) + return + } + if err := os.MkdirAll(outputDir, 0o750); err != nil { + writeError(writer, http.StatusInternalServerError, "storage_error", "Could not prepare report storage.") + return + } + + job, err := server.service.Submit(request.Context(), domain.JobSpec{ + ID: jobID, + Type: jobType, + Inputs: inputs, + OutputDir: outputDir, + }) + if err != nil { + server.writeServiceError(writer, err) + return + } + removeJobFiles = false + writeJSON(writer, http.StatusAccepted, map[string]any{ + "job": job, + "links": map[string]string{ + "self": "/api/v1/jobs/" + job.ID, + "events": "/api/v1/jobs/" + job.ID + "/events", + }, + }) +} + +func (server *Server) readUploads( + reader *multipart.Reader, + inputDir string, + fields []string, +) ([]domain.InputFile, error) { + expected := make(map[string]bool, len(fields)) + for _, field := range fields { + expected[field] = false + } + loaded := make(map[string]domain.InputFile, len(fields)) + for { + part, err := reader.NextPart() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, fmt.Errorf("read multipart upload: %w", err) + } + field := part.FormName() + filename := part.FileName() + if _, exists := expected[field]; !exists || filename == "" { + _ = part.Close() + return nil, fmt.Errorf("unexpected multipart field %q", field) + } + if expected[field] { + _ = part.Close() + return nil, fmt.Errorf("duplicate multipart field %q", field) + } + + extension := strings.ToLower(filepath.Ext(filename)) + if _, allowed := allowedExtensions[extension]; !allowed { + _ = part.Close() + return nil, fmt.Errorf("unsupported file type %q", extension) + } + destination := filepath.Join(inputDir, field+extension) + file, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640) + if err != nil { + _ = part.Close() + return nil, fmt.Errorf("create uploaded file: %w", err) + } + bytesWritten, copyError := io.Copy(file, part) + closeError := file.Close() + _ = part.Close() + if copyError != nil { + return nil, fmt.Errorf("store uploaded file: %w", copyError) + } + if closeError != nil { + return nil, fmt.Errorf("close uploaded file: %w", closeError) + } + if bytesWritten == 0 { + return nil, fmt.Errorf("uploaded file %q is empty", field) + } + expected[field] = true + loaded[field] = domain.InputFile{Name: displayName(filename), Path: destination} + } + + inputs := make([]domain.InputFile, 0, len(fields)) + for _, field := range fields { + if !expected[field] { + return nil, fmt.Errorf("missing multipart field %q", field) + } + inputs = append(inputs, loaded[field]) + } + return inputs, nil +} + +func (server *Server) handleListJobs(writer http.ResponseWriter, request *http.Request) { + limit := 50 + if raw := request.URL.Query().Get("limit"); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil || parsed <= 0 || parsed > 100 { + writeError(writer, http.StatusBadRequest, "invalid_limit", "limit must be between 1 and 100.") + return + } + limit = parsed + } + listed, err := server.service.List(request.Context(), limit) + if err != nil { + server.writeServiceError(writer, err) + return + } + writeJSON(writer, http.StatusOK, map[string]any{"jobs": listed}) +} + +func (server *Server) handleGetJob(writer http.ResponseWriter, request *http.Request) { + id, ok := pathJobID(writer, request) + if !ok { + return + } + job, err := server.service.Get(request.Context(), id) + if err != nil { + server.writeServiceError(writer, err) + return + } + writeJSON(writer, http.StatusOK, map[string]any{"job": job}) +} + +func (server *Server) handleDeleteJob(writer http.ResponseWriter, request *http.Request) { + id, ok := pathJobID(writer, request) + if !ok { + return + } + job, deleted, err := server.service.Delete(request.Context(), id) + if err != nil { + server.writeServiceError(writer, err) + return + } + status := http.StatusOK + if !deleted { + status = http.StatusAccepted + } + writeJSON(writer, status, map[string]any{"job": job, "deleted": deleted}) +} + +func (server *Server) handleJobEvents(writer http.ResponseWriter, request *http.Request) { + id, valid := pathJobID(writer, request) + if !valid { + return + } + flusher, ok := writer.(http.Flusher) + if !ok { + writeError(writer, http.StatusInternalServerError, "streaming_unavailable", "Streaming is unavailable.") + return + } + updates, unsubscribe, err := server.service.Subscribe(request.Context(), id) + if err != nil { + server.writeServiceError(writer, err) + return + } + defer unsubscribe() + writer.Header().Set("Content-Type", "text/event-stream") + writer.Header().Set("Cache-Control", "no-cache") + writer.Header().Set("Connection", "keep-alive") + writer.Header().Set("X-Accel-Buffering", "no") + _, _ = io.WriteString(writer, "retry: 2000\n\n") + flusher.Flush() + + keepAlive := time.NewTicker(15 * time.Second) + defer keepAlive.Stop() + for { + select { + case job, open := <-updates: + if !open { + return + } + payload, err := json.Marshal(job) + if err != nil { + return + } + _, _ = fmt.Fprintf(writer, "id: %d\nevent: job\ndata: %s\n\n", job.UpdatedAt.UnixNano(), payload) + flusher.Flush() + if job.Status.Terminal() { + return + } + case <-keepAlive.C: + _, _ = io.WriteString(writer, ": keep-alive\n\n") + flusher.Flush() + case <-request.Context().Done(): + return + } + } +} + +func (server *Server) handleReport(writer http.ResponseWriter, request *http.Request) { + id, ok := pathJobID(writer, request) + if !ok { + return + } + job, err := server.service.Get(request.Context(), id) + if err != nil { + server.writeServiceError(writer, err) + return + } + if job.Status != domain.JobCompleted || job.ReportPath == "" { + writeError(writer, http.StatusConflict, "report_unavailable", "The report is not available for this job.") + return + } + if !within(server.dataDir, job.ReportPath) { + writeError(writer, http.StatusInternalServerError, "unsafe_report_path", "The stored report path is invalid.") + return + } + file, err := os.Open(job.ReportPath) + if err != nil { + writeError(writer, http.StatusNotFound, "report_missing", "The report file could not be found.") + return + } + defer file.Close() + info, err := file.Stat() + if err != nil || info.IsDir() { + writeError(writer, http.StatusNotFound, "report_missing", "The report file could not be found.") + return + } + writer.Header().Set("Content-Type", "application/json; charset=utf-8") + writer.Header().Set("Content-Disposition", `inline; filename="clauseguard-report.json"`) + writer.Header().Set("Cache-Control", "no-store") + http.ServeContent(writer, request, info.Name(), info.ModTime(), file) +} + +func (server *Server) handleHealth(writer http.ResponseWriter, request *http.Request) { + ctx, cancel := context.WithTimeout(request.Context(), healthTimeout) + defer cancel() + health, err := server.service.Health(ctx) + status := http.StatusOK + state := "ok" + if err != nil || !health.Ready { + status = http.StatusServiceUnavailable + state = "unavailable" + } + writeJSON(writer, status, map[string]any{"status": state, "engine": health}) +} + +func (server *Server) middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("X-Content-Type-Options", "nosniff") + writer.Header().Set("Referrer-Policy", "no-referrer") + writer.Header().Set("X-Frame-Options", "DENY") + origin := strings.TrimSpace(request.Header.Get("Origin")) + if origin != "" { + if !server.originAllowed(request, origin) { + writeError(writer, http.StatusForbidden, "origin_not_allowed", "The request origin is not allowed.") + return + } + writer.Header().Set("Access-Control-Allow-Origin", origin) + writer.Header().Set("Vary", "Origin") + writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") + writer.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if request.Method == http.MethodOptions { + writer.WriteHeader(http.StatusNoContent) + return + } + } + defer func() { + if recover() != nil { + writeError(writer, http.StatusInternalServerError, "internal_error", "An unexpected server error occurred.") + } + }() + next.ServeHTTP(writer, request) + }) +} + +func (server *Server) originAllowed(request *http.Request, origin string) bool { + if server.allowedOrigin != "" && origin == server.allowedOrigin { + return true + } + scheme := "http" + if request.TLS != nil { + scheme = "https" + } + return origin == scheme+"://"+request.Host +} + +func (server *Server) writeUploadError(writer http.ResponseWriter, err error) { + var maxBytesError *http.MaxBytesError + if errors.As(err, &maxBytesError) { + writeError(writer, http.StatusRequestEntityTooLarge, "upload_too_large", "The upload exceeds the configured size limit.") + return + } + message := err.Error() + if strings.Contains(message, "unsupported file type") { + writeError(writer, http.StatusUnsupportedMediaType, "unsupported_file_type", "Supported document types are .txt, .docx, and .pdf.") + return + } + if strings.Contains(message, "create uploaded file") || strings.Contains(message, "store uploaded file") || strings.Contains(message, "close uploaded file") { + writeError(writer, http.StatusInternalServerError, "storage_error", "Could not store the uploaded document.") + return + } + writeError(writer, http.StatusBadRequest, "invalid_upload", message) +} + +func (server *Server) writeServiceError(writer http.ResponseWriter, err error) { + switch { + case errors.Is(err, store.ErrNotFound): + writeError(writer, http.StatusNotFound, "job_not_found", "The requested job does not exist.") + case errors.Is(err, jobs.ErrConflict): + writeError(writer, http.StatusConflict, "invalid_job_state", "The job state does not allow this operation.") + case errors.Is(err, jobs.ErrUnsafePath): + writeError(writer, http.StatusInternalServerError, "unsafe_job_path", "The job storage path is invalid.") + case errors.Is(err, jobs.ErrCapacity): + writer.Header().Set("Retry-After", "5") + writeError(writer, http.StatusTooManyRequests, "queue_full", "The analysis queue is full. Retry shortly.") + case errors.Is(err, jobs.ErrShuttingDown): + writer.Header().Set("Retry-After", "5") + writeError(writer, http.StatusServiceUnavailable, "server_shutting_down", "The analysis service is shutting down.") + default: + writeError(writer, http.StatusInternalServerError, "service_error", "The job operation could not be completed.") + } +} + +func writeError(writer http.ResponseWriter, status int, code, message string) { + writeJSON(writer, status, errorResponse{Error: errorPayload{Code: code, Message: message}}) +} + +func writeJSON(writer http.ResponseWriter, status int, payload any) { + writer.Header().Set("Content-Type", "application/json; charset=utf-8") + writer.WriteHeader(status) + _ = json.NewEncoder(writer).Encode(payload) +} + +func pathJobID(writer http.ResponseWriter, request *http.Request) (string, bool) { + id := request.PathValue("id") + if !jobIDPattern.MatchString(id) { + writeError(writer, http.StatusBadRequest, "invalid_job_id", "The job ID is invalid.") + return "", false + } + return id, true +} + +func displayName(name string) string { + name = path.Base(strings.ReplaceAll(strings.TrimSpace(name), "\\", "/")) + runes := []rune(name) + if len(runes) > 255 { + name = string(runes[:255]) + } + return name +} + +func within(root, target string) bool { + rootPath, err := filepath.Abs(root) + if err != nil { + return false + } + targetPath, err := filepath.Abs(target) + if err != nil { + return false + } + relative, err := filepath.Rel(rootPath, targetPath) + if err != nil { + return false + } + return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) +} diff --git a/apps/server/internal/api/server_test.go b/apps/server/internal/api/server_test.go new file mode 100644 index 0000000..0242754 --- /dev/null +++ b/apps/server/internal/api/server_test.go @@ -0,0 +1,378 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/domain" + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/jobs" + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/store" +) + +const validJobID = "11111111111111111111111111111111" + +type stubService struct { + submitted domain.JobSpec + job domain.Job + health domain.Health + submitErr error + getError error + listError error + deleteErr error +} + +func (service *stubService) Submit(_ context.Context, spec domain.JobSpec) (domain.Job, error) { + if service.submitErr != nil { + return domain.Job{}, service.submitErr + } + service.submitted = spec + now := time.Now().UTC() + service.job = domain.Job{ + ID: spec.ID, + Type: spec.Type, + Status: domain.JobQueued, + Stage: "queued", + Message: "Job accepted", + Inputs: spec.Inputs, + OutputDir: spec.OutputDir, + CreatedAt: now, + UpdatedAt: now, + } + return service.job, nil +} + +func (service *stubService) Get(context.Context, string) (domain.Job, error) { + return service.job, service.getError +} + +func (service *stubService) List(context.Context, int) ([]domain.Job, error) { + return []domain.Job{service.job}, service.listError +} + +func (service *stubService) Cancel(context.Context, string) (domain.Job, error) { + service.job.Status = domain.JobCancelled + return service.job, nil +} + +func (service *stubService) Delete(context.Context, string) (domain.Job, bool, error) { + return service.job, service.job.Status.Terminal(), service.deleteErr +} + +func (service *stubService) Subscribe(context.Context, string) (<-chan domain.Job, func(), error) { + updates := make(chan domain.Job, 1) + updates <- service.job + return updates, func() { close(updates) }, nil +} + +func (service *stubService) Health(context.Context) (domain.Health, error) { + return service.health, nil +} + +func TestAnalysisUploadIsContainedAndPathIsPrivate(t *testing.T) { + dataDir := t.TempDir() + service := &stubService{health: domain.Health{Ready: true}} + handler := testHandler(t, service, dataDir, 1<<20) + request := multipartRequest(t, "/api/v1/analyses", map[string]upload{ + "document": {name: "client-contract.txt", content: "SERVICE AGREEMENT"}, + }) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusAccepted { + t.Fatalf("unexpected status %d: %s", recorder.Code, recorder.Body.String()) + } + if len(service.submitted.Inputs) != 1 { + t.Fatalf("unexpected submitted spec: %+v", service.submitted) + } + input := service.submitted.Inputs[0] + if filepath.Base(input.Path) != "document.txt" || !within(dataDir, input.Path) { + t.Fatalf("upload was not safely contained: %+v", input) + } + payload, err := os.ReadFile(input.Path) + if err != nil || string(payload) != "SERVICE AGREEMENT" { + t.Fatalf("stored upload mismatch: %q, %v", payload, err) + } + if strings.Contains(recorder.Body.String(), dataDir) || strings.Contains(recorder.Body.String(), input.Path) { + t.Fatal("response leaked an internal filesystem path") + } +} + +func TestComparisonUploadPreservesSemanticOrder(t *testing.T) { + dataDir := t.TempDir() + service := &stubService{health: domain.Health{Ready: true}} + handler := testHandler(t, service, dataDir, 1<<20) + request := multipartRequest(t, "/api/v1/comparisons", map[string]upload{ + "modified": {name: "new.txt", content: "new"}, + "original": {name: "old.txt", content: "old"}, + }) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusAccepted { + t.Fatalf("unexpected status %d: %s", recorder.Code, recorder.Body.String()) + } + if service.submitted.Inputs[0].Name != "old.txt" || service.submitted.Inputs[1].Name != "new.txt" { + t.Fatalf("comparison order changed: %+v", service.submitted.Inputs) + } +} + +func TestUploadRejectsUnsupportedAndOversizedFiles(t *testing.T) { + for _, test := range []struct { + name string + filename string + content string + limit int64 + wantStatus int + }{ + {name: "unsupported", filename: "contract.exe", content: "x", limit: 1 << 20, wantStatus: http.StatusUnsupportedMediaType}, + {name: "oversized", filename: "contract.txt", content: strings.Repeat("x", 1024), limit: 256, wantStatus: http.StatusRequestEntityTooLarge}, + } { + t.Run(test.name, func(t *testing.T) { + service := &stubService{health: domain.Health{Ready: true}} + handler := testHandler(t, service, t.TempDir(), test.limit) + request := multipartRequest(t, "/api/v1/analyses", map[string]upload{ + "document": {name: test.filename, content: test.content}, + }) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code != test.wantStatus { + t.Fatalf("unexpected status %d: %s", recorder.Code, recorder.Body.String()) + } + }) + } +} + +func TestReportEndpointServesOnlyManagedCompletedReports(t *testing.T) { + dataDir := t.TempDir() + reportPath := filepath.Join(dataDir, "jobs", validJobID, "output", "analysis_report.json") + if err := os.MkdirAll(filepath.Dir(reportPath), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(reportPath, []byte(`{"schema_version":"1.0"}`), 0o600); err != nil { + t.Fatal(err) + } + service := &stubService{ + health: domain.Health{Ready: true}, + job: domain.Job{ + ID: validJobID, + Status: domain.JobCompleted, + ReportPath: reportPath, + UpdatedAt: time.Now().UTC(), + }, + } + handler := testHandler(t, service, dataDir, 1<<20) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/jobs/"+validJobID+"/report", nil)) + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "schema_version") { + t.Fatalf("report not served: %d %s", recorder.Code, recorder.Body.String()) + } + + service.job.ReportPath = filepath.Join(t.TempDir(), "outside.json") + recorder = httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/jobs/"+validJobID+"/report", nil)) + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("unsafe report path accepted: %d", recorder.Code) + } +} + +func TestSSEDeliversTerminalSnapshot(t *testing.T) { + service := &stubService{ + health: domain.Health{Ready: true}, + job: domain.Job{ + ID: validJobID, + Status: domain.JobCompleted, + Stage: "completed", + Progress: 100, + UpdatedAt: time.Now().UTC(), + }, + } + handler := testHandler(t, service, t.TempDir(), 1<<20) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/jobs/"+validJobID+"/events", nil)) + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "event: job") || !strings.Contains(recorder.Body.String(), `"status":"completed"`) { + t.Fatalf("unexpected SSE response: %d %s", recorder.Code, recorder.Body.String()) + } +} + +func TestHealthReflectsEngineReadiness(t *testing.T) { + service := &stubService{health: domain.Health{Ready: true}} + handler := testHandler(t, service, t.TempDir(), 1<<20) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("unexpected health status: %d", recorder.Code) + } + var payload map[string]any + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil || payload["status"] != "ok" { + t.Fatalf("unexpected health payload: %s, %v", recorder.Body.String(), err) + } +} + +func TestDisplayNameRemovesClientPathFragments(t *testing.T) { + if got := displayName(`C:\Users\candidate\contract.txt`); got != "contract.txt" { + t.Fatalf("unexpected display name %q", got) + } +} + +func TestJobCollectionAndDeleteRoutes(t *testing.T) { + now := time.Now().UTC() + service := &stubService{ + health: domain.Health{Ready: true}, + job: domain.Job{ + ID: validJobID, Type: domain.JobTypeAnalysis, Status: domain.JobQueued, + Stage: "queued", Inputs: []domain.InputFile{{Name: "contract.txt"}}, + CreatedAt: now, UpdatedAt: now, + }, + } + handler := testHandler(t, service, t.TempDir(), 1<<20) + + for _, route := range []string{"/api/v1/jobs", "/api/v1/jobs/" + validJobID} { + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, route, nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("GET %s returned %d: %s", route, recorder.Code, recorder.Body.String()) + } + } + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/jobs?limit=0", nil)) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("invalid list limit returned %d", recorder.Code) + } + + recorder = httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodDelete, "/api/v1/jobs/"+validJobID, nil)) + if recorder.Code != http.StatusAccepted { + t.Fatalf("active delete returned %d", recorder.Code) + } + service.job.Status = domain.JobCompleted + recorder = httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodDelete, "/api/v1/jobs/"+validJobID, nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("terminal delete returned %d", recorder.Code) + } +} + +func TestNotFoundAndUnavailableRoutesUseTypedErrors(t *testing.T) { + service := &stubService{ + health: domain.Health{Ready: false, Error: "engine unavailable"}, + getError: store.ErrNotFound, + } + handler := testHandler(t, service, t.TempDir(), 1<<20) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/jobs/22222222222222222222222222222222", nil)) + if recorder.Code != http.StatusNotFound || !strings.Contains(recorder.Body.String(), "job_not_found") { + t.Fatalf("unexpected not-found response: %d %s", recorder.Code, recorder.Body.String()) + } + recorder = httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)) + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("unready health returned %d", recorder.Code) + } +} + +func TestInvalidJobIDIsRejectedBeforeLookup(t *testing.T) { + service := &stubService{health: domain.Health{Ready: true}} + handler := testHandler(t, service, t.TempDir(), 1<<20) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/jobs/not-a-job-id", nil)) + if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "invalid_job_id") { + t.Fatalf("unexpected invalid-ID response: %d %s", recorder.Code, recorder.Body.String()) + } +} + +func TestConfiguredCORSPreflight(t *testing.T) { + service := &stubService{health: domain.Health{Ready: true}} + handler, err := New(service, Config{ + DataDir: t.TempDir(), MaxUploadBytes: 1 << 20, AllowedOrigin: "http://localhost:5173", + }) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodOptions, "/api/v1/analyses", nil) + request.Header.Set("Origin", "http://localhost:5173") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusNoContent || recorder.Header().Get("Access-Control-Allow-Origin") == "" { + t.Fatalf("unexpected preflight response: %d %+v", recorder.Code, recorder.Header()) + } +} + +func TestCrossOriginRequestIsRejected(t *testing.T) { + service := &stubService{health: domain.Health{Ready: true}} + handler := testHandler(t, service, t.TempDir(), 1<<20) + request := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) + request.Header.Set("Origin", "https://untrusted.example") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), "origin_not_allowed") { + t.Fatalf("cross-origin request was not rejected: %d %s", recorder.Code, recorder.Body.String()) + } +} + +func TestFullQueueReturnsRetryableStatus(t *testing.T) { + service := &stubService{health: domain.Health{Ready: true}, submitErr: jobs.ErrCapacity} + handler := testHandler(t, service, t.TempDir(), 1<<20) + request := multipartRequest(t, "/api/v1/analyses", map[string]upload{ + "document": {name: "contract.txt", content: "Agreement"}, + }) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusTooManyRequests || recorder.Header().Get("Retry-After") == "" { + t.Fatalf("unexpected capacity response: %d %s", recorder.Code, recorder.Body.String()) + } +} + +func TestShuttingDownReturnsUnavailable(t *testing.T) { + service := &stubService{health: domain.Health{Ready: true}, submitErr: jobs.ErrShuttingDown} + handler := testHandler(t, service, t.TempDir(), 1<<20) + request := multipartRequest(t, "/api/v1/analyses", map[string]upload{ + "document": {name: "contract.txt", content: "Agreement"}, + }) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code != http.StatusServiceUnavailable || !strings.Contains(recorder.Body.String(), "server_shutting_down") { + t.Fatalf("unexpected shutdown response: %d %s", recorder.Code, recorder.Body.String()) + } +} + +type upload struct { + name string + content string +} + +func multipartRequest(t *testing.T, path string, files map[string]upload) *http.Request { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + for field, file := range files { + part, err := writer.CreateFormFile(field, file.name) + if err != nil { + t.Fatal(err) + } + if _, err := part.Write([]byte(file.content)); err != nil { + t.Fatal(err) + } + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, path, &body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + return request +} + +func testHandler(t *testing.T, service JobService, dataDir string, maxUploadBytes int64) http.Handler { + t.Helper() + handler, err := New(service, Config{DataDir: dataDir, MaxUploadBytes: maxUploadBytes}) + if err != nil { + t.Fatal(err) + } + return handler +} diff --git a/apps/server/internal/config/config.go b/apps/server/internal/config/config.go new file mode 100644 index 0000000..f171b05 --- /dev/null +++ b/apps/server/internal/config/config.go @@ -0,0 +1,196 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/joho/godotenv" +) + +const ( + defaultAddress = "127.0.0.1:8080" + defaultMaxUploadBytes = int64(25 << 20) + defaultJobTimeout = 10 * time.Minute + defaultConcurrentJobs = 2 + defaultActiveJobs = 20 + workspaceEnvironmentName = "CLAUSEGUARD_WORKSPACE" +) + +type Config struct { + Address string + PythonExecutable string + Workspace string + DataDir string + MaxUploadBytes int64 + JobTimeout time.Duration + MaxConcurrentJobs int + MaxActiveJobs int + MockModels bool + AllowedOrigin string +} + +func Load() (Config, error) { + workspace, err := workspacePath() + if err != nil { + return Config{}, err + } + if err := loadEnvironment(workspace); err != nil { + return Config{}, err + } + + dataDir := strings.TrimSpace(os.Getenv("CLAUSEGUARD_DATA_DIR")) + if dataDir == "" { + dataDir = filepath.Join(workspace, ".clauseguard") + } else if !filepath.IsAbs(dataDir) { + dataDir = filepath.Join(workspace, dataDir) + } + dataDir, err = filepath.Abs(dataDir) + if err != nil { + return Config{}, fmt.Errorf("resolve data directory: %w", err) + } + + maxUploadBytes, err := positiveInt64("CLAUSEGUARD_MAX_UPLOAD_BYTES", defaultMaxUploadBytes) + if err != nil { + return Config{}, err + } + jobTimeout, err := positiveDuration("CLAUSEGUARD_JOB_TIMEOUT", defaultJobTimeout) + if err != nil { + return Config{}, err + } + maxConcurrentJobs, err := positiveInt("CLAUSEGUARD_MAX_CONCURRENT_JOBS", defaultConcurrentJobs) + if err != nil { + return Config{}, err + } + maxActiveJobs, err := positiveInt("CLAUSEGUARD_MAX_ACTIVE_JOBS", defaultActiveJobs) + if err != nil { + return Config{}, err + } + if maxActiveJobs < maxConcurrentJobs { + return Config{}, errors.New("CLAUSEGUARD_MAX_ACTIVE_JOBS cannot be lower than CLAUSEGUARD_MAX_CONCURRENT_JOBS") + } + mockModels, err := boolean("CLAUSEGUARD_MOCK_MODELS", true) + if err != nil { + return Config{}, err + } + + python := strings.TrimSpace(os.Getenv("CLAUSEGUARD_PYTHON")) + if python == "" { + python = "python" + } + address := strings.TrimSpace(os.Getenv("CLAUSEGUARD_SERVER_ADDR")) + if address == "" { + address = defaultAddress + } + + return Config{ + Address: address, + PythonExecutable: python, + Workspace: workspace, + DataDir: dataDir, + MaxUploadBytes: maxUploadBytes, + JobTimeout: jobTimeout, + MaxConcurrentJobs: maxConcurrentJobs, + MaxActiveJobs: maxActiveJobs, + MockModels: mockModels, + AllowedOrigin: strings.TrimSpace(os.Getenv("CLAUSEGUARD_ALLOWED_ORIGIN")), + }, nil +} + +func loadEnvironment(workspace string) error { + err := godotenv.Load(filepath.Join(workspace, ".env")) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("load workspace .env: %w", err) + } + return nil +} + +func workspacePath() (string, error) { + configured := strings.TrimSpace(os.Getenv(workspaceEnvironmentName)) + if configured != "" { + absolute, err := filepath.Abs(configured) + if err != nil { + return "", fmt.Errorf("resolve %s: %w", workspaceEnvironmentName, err) + } + if !isWorkspace(absolute) { + return "", fmt.Errorf("%s does not contain pyproject.toml and clauseguard", workspaceEnvironmentName) + } + return absolute, nil + } + + current, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("get working directory: %w", err) + } + for { + if isWorkspace(current) { + return current, nil + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return "", errors.New("ClauseGuard workspace not found; set CLAUSEGUARD_WORKSPACE") +} + +func isWorkspace(path string) bool { + if _, err := os.Stat(filepath.Join(path, "pyproject.toml")); err != nil { + return false + } + info, err := os.Stat(filepath.Join(path, "clauseguard")) + return err == nil && info.IsDir() +} + +func positiveInt(name string, fallback int) (int, error) { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return fallback, nil + } + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + return 0, fmt.Errorf("%s must be a positive integer", name) + } + return value, nil +} + +func positiveInt64(name string, fallback int64) (int64, error) { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return fallback, nil + } + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil || value <= 0 { + return 0, fmt.Errorf("%s must be a positive integer", name) + } + return value, nil +} + +func positiveDuration(name string, fallback time.Duration) (time.Duration, error) { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return fallback, nil + } + value, err := time.ParseDuration(raw) + if err != nil || value <= 0 { + return 0, fmt.Errorf("%s must be a positive duration", name) + } + return value, nil +} + +func boolean(name string, fallback bool) (bool, error) { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return fallback, nil + } + value, err := strconv.ParseBool(raw) + if err != nil { + return false, fmt.Errorf("%s must be true or false", name) + } + return value, nil +} diff --git a/apps/server/internal/config/config_test.go b/apps/server/internal/config/config_test.go new file mode 100644 index 0000000..f056dfb --- /dev/null +++ b/apps/server/internal/config/config_test.go @@ -0,0 +1,145 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestLoadUsesValidatedEnvironment(t *testing.T) { + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "pyproject.toml"), []byte("[project]\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(workspace, "clauseguard"), 0o750); err != nil { + t.Fatal(err) + } + dataDir := filepath.Join(t.TempDir(), "runtime") + t.Setenv("CLAUSEGUARD_WORKSPACE", workspace) + t.Setenv("CLAUSEGUARD_DATA_DIR", dataDir) + t.Setenv("CLAUSEGUARD_PYTHON", "python-test") + t.Setenv("CLAUSEGUARD_SERVER_ADDR", "127.0.0.1:9000") + t.Setenv("CLAUSEGUARD_MAX_UPLOAD_BYTES", "4096") + t.Setenv("CLAUSEGUARD_JOB_TIMEOUT", "45s") + t.Setenv("CLAUSEGUARD_MAX_CONCURRENT_JOBS", "3") + t.Setenv("CLAUSEGUARD_MAX_ACTIVE_JOBS", "8") + t.Setenv("CLAUSEGUARD_MOCK_MODELS", "false") + t.Setenv("CLAUSEGUARD_ALLOWED_ORIGIN", "http://localhost:5173") + + loaded, err := Load() + if err != nil { + t.Fatal(err) + } + if loaded.Workspace != workspace || loaded.DataDir != dataDir { + t.Fatalf("unexpected paths: %+v", loaded) + } + if loaded.PythonExecutable != "python-test" || loaded.Address != "127.0.0.1:9000" { + t.Fatalf("unexpected process configuration: %+v", loaded) + } + if loaded.MaxUploadBytes != 4096 || loaded.JobTimeout != 45*time.Second || loaded.MaxConcurrentJobs != 3 || loaded.MaxActiveJobs != 8 { + t.Fatalf("unexpected limits: %+v", loaded) + } + if loaded.MockModels || loaded.AllowedOrigin != "http://localhost:5173" { + t.Fatalf("unexpected runtime mode: %+v", loaded) + } +} + +func TestLoadRejectsActiveLimitBelowConcurrency(t *testing.T) { + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "pyproject.toml"), nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(workspace, "clauseguard"), 0o750); err != nil { + t.Fatal(err) + } + t.Setenv("CLAUSEGUARD_WORKSPACE", workspace) + t.Setenv("CLAUSEGUARD_MAX_CONCURRENT_JOBS", "3") + t.Setenv("CLAUSEGUARD_MAX_ACTIVE_JOBS", "2") + if _, err := Load(); err == nil { + t.Fatal("active limit below concurrency was accepted") + } +} + +func TestLoadRejectsInvalidLimits(t *testing.T) { + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "pyproject.toml"), nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(workspace, "clauseguard"), 0o750); err != nil { + t.Fatal(err) + } + t.Setenv("CLAUSEGUARD_WORKSPACE", workspace) + t.Setenv("CLAUSEGUARD_MAX_CONCURRENT_JOBS", "0") + if _, err := Load(); err == nil { + t.Fatal("invalid concurrency limit was accepted") + } +} + +func TestLoadResolvesRelativeDataDirectoryFromWorkspace(t *testing.T) { + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "pyproject.toml"), nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(workspace, "clauseguard"), 0o750); err != nil { + t.Fatal(err) + } + t.Setenv("CLAUSEGUARD_WORKSPACE", workspace) + t.Setenv("CLAUSEGUARD_DATA_DIR", ".runtime") + loaded, err := Load() + if err != nil { + t.Fatal(err) + } + if loaded.DataDir != filepath.Join(workspace, ".runtime") { + t.Fatalf("relative data directory resolved to %q", loaded.DataDir) + } +} + +func TestLoadReadsWorkspaceDotEnvWithoutOverridingProcessEnvironment(t *testing.T) { + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "pyproject.toml"), nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(workspace, "clauseguard"), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(workspace, ".env"), + []byte("CLAUSEGUARD_SERVER_ADDR=127.0.0.1:9123\nCLAUSEGUARD_MAX_CONCURRENT_JOBS=1\nCLAUSEGUARD_MAX_ACTIVE_JOBS=4\n"), + 0o600, + ); err != nil { + t.Fatal(err) + } + for _, name := range []string{ + "CLAUSEGUARD_SERVER_ADDR", "CLAUSEGUARD_MAX_CONCURRENT_JOBS", "CLAUSEGUARD_MAX_ACTIVE_JOBS", + } { + unsetForTest(t, name) + } + t.Setenv("CLAUSEGUARD_WORKSPACE", workspace) + t.Setenv("CLAUSEGUARD_SERVER_ADDR", "127.0.0.1:9222") + loaded, err := Load() + if err != nil { + t.Fatal(err) + } + if loaded.Address != "127.0.0.1:9222" { + t.Fatalf(".env overrode process environment: %q", loaded.Address) + } + if loaded.MaxConcurrentJobs != 1 || loaded.MaxActiveJobs != 4 { + t.Fatalf(".env limits were not loaded: %+v", loaded) + } +} + +func unsetForTest(t *testing.T, name string) { + t.Helper() + original, existed := os.LookupEnv(name) + if err := os.Unsetenv(name); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if existed { + _ = os.Setenv(name, original) + } else { + _ = os.Unsetenv(name) + } + }) +} diff --git a/apps/server/internal/domain/job.go b/apps/server/internal/domain/job.go new file mode 100644 index 0000000..a334a7d --- /dev/null +++ b/apps/server/internal/domain/job.go @@ -0,0 +1,137 @@ +package domain + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "time" +) + +type JobType string + +const ( + JobTypeAnalysis JobType = "analysis" + JobTypeComparison JobType = "comparison" +) + +func (kind JobType) Valid() bool { + return kind == JobTypeAnalysis || kind == JobTypeComparison +} + +type JobStatus string + +const ( + JobQueued JobStatus = "queued" + JobRunning JobStatus = "running" + JobCompleted JobStatus = "completed" + JobFailed JobStatus = "failed" + JobCancelled JobStatus = "cancelled" + JobTimedOut JobStatus = "timed_out" +) + +func (status JobStatus) Terminal() bool { + switch status { + case JobCompleted, JobFailed, JobCancelled, JobTimedOut: + return true + default: + return false + } +} + +type InputFile struct { + Name string `json:"name"` + Path string `json:"-"` +} + +type JobError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type Job struct { + ID string `json:"id"` + Type JobType `json:"type"` + Status JobStatus `json:"status"` + Stage string `json:"stage"` + Progress int `json:"progress"` + Message string `json:"message"` + Inputs []InputFile `json:"inputs"` + OutputDir string `json:"-"` + ReportPath string `json:"-"` + ReportURL string `json:"report_url,omitempty"` + Error *JobError `json:"error,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` +} + +type JobSpec struct { + ID string + Type JobType + Inputs []InputFile + OutputDir string +} + +type EngineError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type EngineEvent struct { + SchemaVersion string `json:"schema_version"` + RunID string `json:"run_id"` + Sequence int `json:"sequence"` + Type string `json:"type"` + Stage string `json:"stage"` + Status string `json:"status"` + Progress int `json:"progress"` + Message string `json:"message"` + Timestamp string `json:"timestamp"` + Details map[string]any `json:"details"` + Error *EngineError `json:"error"` +} + +type RunResult struct { + ReportPath string +} + +type Health struct { + Ready bool `json:"ready"` + Models []map[string]any `json:"models,omitempty"` + Error string `json:"error,omitempty"` +} + +func NewID() (string, error) { + buffer := make([]byte, 16) + if _, err := rand.Read(buffer); err != nil { + return "", err + } + return hex.EncodeToString(buffer), nil +} + +func ValidateSpec(spec JobSpec) error { + if spec.ID == "" { + return errors.New("job ID is required") + } + if !spec.Type.Valid() { + return errors.New("unsupported job type") + } + expectedInputs := 1 + if spec.Type == JobTypeComparison { + expectedInputs = 2 + } + if len(spec.Inputs) != expectedInputs { + return errors.New("unexpected input count") + } + if spec.OutputDir == "" { + return errors.New("output directory is required") + } + for _, input := range spec.Inputs { + if input.Name == "" || input.Path == "" { + return errors.New("input name and path are required") + } + } + return nil +} diff --git a/apps/server/internal/domain/job_test.go b/apps/server/internal/domain/job_test.go new file mode 100644 index 0000000..6a9e05c --- /dev/null +++ b/apps/server/internal/domain/job_test.go @@ -0,0 +1,52 @@ +package domain + +import ( + "path/filepath" + "testing" +) + +func TestNewIDIsOpaqueAndUnique(t *testing.T) { + first, err := NewID() + if err != nil { + t.Fatal(err) + } + second, err := NewID() + if err != nil { + t.Fatal(err) + } + if len(first) != 32 || len(second) != 32 { + t.Fatalf("expected 32-character identifiers, got %q and %q", first, second) + } + if first == second { + t.Fatal("generated duplicate identifiers") + } +} + +func TestValidateSpecEnforcesInputCount(t *testing.T) { + analysis := JobSpec{ + ID: "analysis-id", + Type: JobTypeAnalysis, + Inputs: []InputFile{{Name: "contract.txt", Path: filepath.Join("tmp", "contract.txt")}}, + OutputDir: filepath.Join("tmp", "output"), + } + if err := ValidateSpec(analysis); err != nil { + t.Fatalf("valid analysis rejected: %v", err) + } + analysis.Inputs = append(analysis.Inputs, InputFile{Name: "extra.txt", Path: "extra.txt"}) + if err := ValidateSpec(analysis); err == nil { + t.Fatal("analysis with two inputs was accepted") + } +} + +func TestTerminalStatuses(t *testing.T) { + for _, status := range []JobStatus{JobCompleted, JobFailed, JobCancelled, JobTimedOut} { + if !status.Terminal() { + t.Fatalf("expected %q to be terminal", status) + } + } + for _, status := range []JobStatus{JobQueued, JobRunning} { + if status.Terminal() { + t.Fatalf("expected %q to be active", status) + } + } +} diff --git a/apps/server/internal/engine/runner.go b/apps/server/internal/engine/runner.go new file mode 100644 index 0000000..1e357ac --- /dev/null +++ b/apps/server/internal/engine/runner.go @@ -0,0 +1,453 @@ +package engine + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/domain" +) + +const ( + eventSchemaVersion = "1.0" + maxEventBytes = 1 << 20 + maxDiagnosticBytes = 64 << 10 + defaultHealthTTL = 10 * time.Second +) + +type EventSink func(domain.EngineEvent) + +type Runner interface { + Run(context.Context, domain.JobSpec, EventSink) (domain.RunResult, error) + Health(context.Context) domain.Health +} + +type CLIRunner struct { + PythonExecutable string + Workspace string + MockModels bool + HealthCacheTTL time.Duration + healthMu sync.Mutex + healthAt time.Time + healthValue domain.Health +} + +type RunError struct { + ExitCode int + Message string + Stderr string + Cause error +} + +func (err *RunError) Error() string { + if err.Message != "" { + return err.Message + } + if err.Cause != nil { + return err.Cause.Error() + } + return "ClauseGuard engine failed" +} + +func (err *RunError) Unwrap() error { + return err.Cause +} + +func (runner *CLIRunner) Run( + ctx context.Context, + spec domain.JobSpec, + sink EventSink, +) (domain.RunResult, error) { + if err := domain.ValidateSpec(spec); err != nil { + return domain.RunResult{}, &RunError{ExitCode: 2, Message: err.Error(), Cause: err} + } + if err := os.MkdirAll(spec.OutputDir, 0o750); err != nil { + return domain.RunResult{}, &RunError{ + ExitCode: 20, + Message: "create job output directory", + Cause: err, + } + } + + command := exec.CommandContext(ctx, runner.PythonExecutable, runner.arguments(spec)...) + command.Dir = runner.Workspace + command.Env = append(os.Environ(), "PYTHONUNBUFFERED=1") + stdout, err := command.StdoutPipe() + if err != nil { + return domain.RunResult{}, &RunError{ExitCode: 20, Message: "open engine output", Cause: err} + } + stderr, err := command.StderrPipe() + if err != nil { + return domain.RunResult{}, &RunError{ExitCode: 20, Message: "open engine diagnostics", Cause: err} + } + + if err := command.Start(); err != nil { + return domain.RunResult{}, &RunError{ + ExitCode: 20, + Message: "start ClauseGuard Python engine", + Cause: err, + } + } + + diagnostics := newLimitedBuffer(maxDiagnosticBytes) + var diagnosticsWait sync.WaitGroup + diagnosticsWait.Add(1) + go func() { + defer diagnosticsWait.Done() + _, _ = io.Copy(diagnostics, stderr) + }() + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 64<<10), maxEventBytes) + expectedSequence := 1 + lastProgress := -1 + terminalSeen := false + completedSeen := false + reportPath := "" + var protocolError error + for scanner.Scan() { + if protocolError != nil { + continue + } + var event domain.EngineEvent + if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { + protocolError = fmt.Errorf("decode engine event: %w", err) + continue + } + if err := validateEvent(event, spec.ID, expectedSequence); err != nil { + protocolError = err + continue + } + if terminalSeen { + protocolError = errors.New("engine emitted an event after a terminal event") + continue + } + if event.Progress < lastProgress { + protocolError = errors.New("engine event progress moved backwards") + continue + } + expectedSequence++ + lastProgress = event.Progress + if event.Type == "completed" { + terminalSeen = true + completedSeen = true + } else if event.Type == "error" { + terminalSeen = true + } + if path := jsonReportPath(event.Details); path != "" { + reportPath = path + } + if sink != nil { + sink(event) + } + } + if err := scanner.Err(); err != nil { + if protocolError == nil { + protocolError = fmt.Errorf("read engine event stream: %w", err) + } + _, _ = io.Copy(io.Discard, stdout) + } + + waitError := command.Wait() + diagnosticsWait.Wait() + stderrText := strings.TrimSpace(diagnostics.String()) + if ctx.Err() != nil { + exitCode := 130 + message := "analysis cancelled" + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + exitCode = 14 + message = "analysis timed out" + } + return domain.RunResult{}, &RunError{ + ExitCode: exitCode, + Message: message, + Stderr: stderrText, + Cause: ctx.Err(), + } + } + if waitError != nil { + exitCode := 20 + var exitError *exec.ExitError + if errors.As(waitError, &exitError) { + exitCode = exitError.ExitCode() + } + message := "ClauseGuard engine exited with an error" + if stderrText != "" { + message = stderrText + } + return domain.RunResult{}, &RunError{ + ExitCode: exitCode, + Message: message, + Stderr: stderrText, + Cause: waitError, + } + } + if !completedSeen && protocolError == nil { + protocolError = errors.New("engine exited successfully without a completed event") + } + if protocolError != nil { + return domain.RunResult{}, &RunError{ + ExitCode: 20, + Message: "invalid event stream from ClauseGuard engine", + Stderr: stderrText, + Cause: protocolError, + } + } + + if reportPath == "" { + reportPath = expectedReportPath(spec) + } + reportPath, err = filepath.Abs(reportPath) + if err != nil { + return domain.RunResult{}, &RunError{ExitCode: 20, Message: "resolve report path", Cause: err} + } + if !within(spec.OutputDir, reportPath) { + return domain.RunResult{}, &RunError{ExitCode: 20, Message: "engine returned a report outside the job directory"} + } + info, err := os.Stat(reportPath) + if err != nil || info.IsDir() { + return domain.RunResult{}, &RunError{ExitCode: 20, Message: "engine did not produce the JSON report", Cause: err} + } + if err := sanitizeReportMetadata(reportPath, spec); err != nil { + return domain.RunResult{}, &RunError{ + ExitCode: 20, + Message: "sanitize report metadata", + Cause: err, + } + } + return domain.RunResult{ReportPath: reportPath}, nil +} + +func (runner *CLIRunner) Health(ctx context.Context) domain.Health { + runner.healthMu.Lock() + defer runner.healthMu.Unlock() + ttl := runner.HealthCacheTTL + if ttl <= 0 { + ttl = defaultHealthTTL + } + if !runner.healthAt.IsZero() && time.Since(runner.healthAt) < ttl { + return runner.healthValue + } + health := runner.checkHealth(ctx) + runner.healthAt = time.Now() + runner.healthValue = health + return health +} + +func (runner *CLIRunner) checkHealth(ctx context.Context) domain.Health { + command := exec.CommandContext(ctx, runner.PythonExecutable, "-m", "clauseguard", "models", "--json") + command.Dir = runner.Workspace + output, err := command.Output() + if err != nil { + return domain.Health{Ready: false, Error: err.Error()} + } + var models []map[string]any + if err := json.Unmarshal(output, &models); err != nil { + return domain.Health{Ready: false, Error: "invalid model inventory from Python engine"} + } + return domain.Health{Ready: true, Models: models} +} + +func (runner *CLIRunner) arguments(spec domain.JobSpec) []string { + arguments := []string{"-m", "clauseguard"} + if spec.Type == domain.JobTypeAnalysis { + arguments = append( + arguments, + "analyze", + spec.Inputs[0].Path, + "--output-dir", spec.OutputDir, + "--format", "json", + "--events-jsonl", + "--run-id", spec.ID, + ) + if runner.MockModels { + arguments = append(arguments, "--mock-models") + } + return arguments + } + + arguments = append( + arguments, + "compare", + spec.Inputs[0].Path, + spec.Inputs[1].Path, + "--output-dir", spec.OutputDir, + "--format", "json", + "--events-jsonl", + "--run-id", spec.ID, + ) + if !runner.MockModels { + arguments = append(arguments, "--real-models") + } + return arguments +} + +func validateEvent(event domain.EngineEvent, runID string, expectedSequence int) error { + if event.SchemaVersion != eventSchemaVersion { + return fmt.Errorf("unsupported event schema %q", event.SchemaVersion) + } + if event.RunID != runID { + return errors.New("engine event run ID does not match job ID") + } + if event.Sequence != expectedSequence { + return fmt.Errorf("engine event sequence %d; expected %d", event.Sequence, expectedSequence) + } + if event.Progress < 0 || event.Progress > 100 { + return errors.New("engine event progress is outside 0-100") + } + if event.Stage == "" || event.Status == "" || event.Type == "" { + return errors.New("engine event is missing required fields") + } + if _, err := time.Parse(time.RFC3339Nano, event.Timestamp); err != nil { + return errors.New("engine event has an invalid timestamp") + } + validStages := map[string]bool{ + "queued": true, "loading": true, "extracting": true, "retrieving": true, + "checking": true, "verifying": true, "scoring": true, "rewriting": true, + "comparing": true, "reporting": true, "completed": true, "failed": true, + } + if !validStages[event.Stage] { + return fmt.Errorf("unsupported engine stage %q", event.Stage) + } + validStatuses := map[string]bool{"queued": true, "started": true, "completed": true, "failed": true} + if !validStatuses[event.Status] { + return fmt.Errorf("unsupported engine status %q", event.Status) + } + switch event.Type { + case "progress": + if event.Stage == "completed" || event.Stage == "failed" || event.Status == "failed" || event.Error != nil { + return errors.New("progress event uses terminal fields") + } + if (event.Stage == "queued") != (event.Status == "queued") { + return errors.New("queued stage and status must be used together") + } + case "completed": + if event.Stage != "completed" || event.Status != "completed" || event.Progress != 100 || event.Error != nil { + return errors.New("completed event has inconsistent terminal fields") + } + case "error": + if event.Stage != "failed" || event.Status != "failed" || event.Progress != 100 || event.Error == nil { + return errors.New("error event has inconsistent terminal fields") + } + default: + return fmt.Errorf("unsupported engine event type %q", event.Type) + } + return nil +} + +func jsonReportPath(details map[string]any) string { + raw, ok := details["report_files"] + if !ok { + return "" + } + files, ok := raw.([]any) + if !ok { + return "" + } + for _, value := range files { + path, ok := value.(string) + if ok && strings.EqualFold(filepath.Ext(path), ".json") { + return path + } + } + return "" +} + +func expectedReportPath(spec domain.JobSpec) string { + name := "analysis_report.json" + if spec.Type == domain.JobTypeComparison { + name = "comparison_report.json" + } + return filepath.Join(spec.OutputDir, name) +} + +func sanitizeReportMetadata(reportPath string, spec domain.JobSpec) error { + payload, err := os.ReadFile(reportPath) + if err != nil { + return err + } + var report map[string]json.RawMessage + if err := json.Unmarshal(payload, &report); err != nil { + return fmt.Errorf("decode report: %w", err) + } + replacements := map[string]string{} + if spec.Type == domain.JobTypeAnalysis { + replacements["file_path"] = spec.Inputs[0].Name + } else { + replacements["original_document"] = spec.Inputs[0].Name + replacements["modified_document"] = spec.Inputs[1].Name + } + for key, value := range replacements { + encoded, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("encode %s: %w", key, err) + } + report[key] = encoded + } + sanitized, err := json.MarshalIndent(report, "", " ") + if err != nil { + return fmt.Errorf("encode sanitized report: %w", err) + } + sanitized = append(sanitized, '\n') + if err := os.WriteFile(reportPath, sanitized, 0o640); err != nil { + return fmt.Errorf("write sanitized report: %w", err) + } + return nil +} + +func within(root, target string) bool { + rootPath, err := filepath.Abs(root) + if err != nil { + return false + } + targetPath, err := filepath.Abs(target) + if err != nil { + return false + } + relative, err := filepath.Rel(rootPath, targetPath) + if err != nil { + return false + } + return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) +} + +type limitedBuffer struct { + mu sync.Mutex + buffer bytes.Buffer + remaining int +} + +func newLimitedBuffer(limit int) *limitedBuffer { + return &limitedBuffer{remaining: limit} +} + +func (buffer *limitedBuffer) Write(payload []byte) (int, error) { + buffer.mu.Lock() + defer buffer.mu.Unlock() + written := len(payload) + if buffer.remaining > 0 { + keep := len(payload) + if keep > buffer.remaining { + keep = buffer.remaining + } + _, _ = buffer.buffer.Write(payload[:keep]) + buffer.remaining -= keep + } + return written, nil +} + +func (buffer *limitedBuffer) String() string { + buffer.mu.Lock() + defer buffer.mu.Unlock() + return buffer.buffer.String() +} diff --git a/apps/server/internal/engine/runner_test.go b/apps/server/internal/engine/runner_test.go new file mode 100644 index 0000000..f5bf3c9 --- /dev/null +++ b/apps/server/internal/engine/runner_test.go @@ -0,0 +1,247 @@ +package engine + +import ( + "bytes" + "context" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/domain" +) + +func TestArgumentsKeepAnalysisInMockMode(t *testing.T) { + runner := CLIRunner{MockModels: true} + spec := domain.JobSpec{ + ID: "job-1", + Type: domain.JobTypeAnalysis, + Inputs: []domain.InputFile{{Name: "contract.txt", Path: "contract.txt"}}, + OutputDir: "output", + } + want := []string{ + "-m", "clauseguard", "analyze", "contract.txt", "--output-dir", "output", + "--format", "json", "--events-jsonl", "--run-id", "job-1", "--mock-models", + } + if got := runner.arguments(spec); !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected arguments:\n got: %#v\nwant: %#v", got, want) + } +} + +func TestArgumentsEnableRealComparisonOnlyWhenConfigured(t *testing.T) { + runner := CLIRunner{MockModels: false} + spec := domain.JobSpec{ + ID: "job-2", + Type: domain.JobTypeComparison, + Inputs: []domain.InputFile{ + {Name: "original.txt", Path: "original.txt"}, + {Name: "modified.txt", Path: "modified.txt"}, + }, + OutputDir: "output", + } + arguments := runner.arguments(spec) + if arguments[len(arguments)-1] != "--real-models" { + t.Fatalf("real comparison flag missing: %#v", arguments) + } +} + +func TestValidateEventRejectsCorrelationAndSequenceErrors(t *testing.T) { + valid := domain.EngineEvent{ + SchemaVersion: "1.0", + RunID: "job-1", + Sequence: 1, + Type: "progress", + Stage: "loading", + Status: "started", + Progress: 5, + Timestamp: "2026-08-11T12:00:00Z", + } + if err := validateEvent(valid, "job-1", 1); err != nil { + t.Fatalf("valid event rejected: %v", err) + } + valid.RunID = "another-job" + if err := validateEvent(valid, "job-1", 1); err == nil { + t.Fatal("mismatched run ID accepted") + } + valid.RunID = "job-1" + valid.Sequence = 3 + if err := validateEvent(valid, "job-1", 1); err == nil { + t.Fatal("out-of-order event accepted") + } +} + +func TestValidateEventEnforcesTerminalShape(t *testing.T) { + completed := domain.EngineEvent{ + SchemaVersion: "1.0", RunID: "job-1", Sequence: 2, Type: "completed", + Stage: "completed", Status: "completed", Progress: 100, + Timestamp: "2026-08-11T12:00:00Z", + } + if err := validateEvent(completed, "job-1", 2); err != nil { + t.Fatalf("valid terminal event rejected: %v", err) + } + completed.Progress = 99 + if err := validateEvent(completed, "job-1", 2); err == nil { + t.Fatal("incomplete terminal progress was accepted") + } + completed.Progress = 100 + completed.Type = "unexpected" + if err := validateEvent(completed, "job-1", 2); err == nil { + t.Fatal("unknown event type was accepted") + } +} + +func TestJSONReportPathSelectsJSONArtifact(t *testing.T) { + details := map[string]any{ + "report_files": []any{"report.md", filepath.Join("output", "analysis_report.json")}, + } + if got := jsonReportPath(details); filepath.Ext(got) != ".json" { + t.Fatalf("JSON report not selected: %q", got) + } +} + +func TestLimitedBufferDrainsWithoutGrowingPastLimit(t *testing.T) { + buffer := newLimitedBuffer(4) + payload := []byte("abcdefgh") + written, err := buffer.Write(payload) + if err != nil || written != len(payload) { + t.Fatalf("unexpected write result: %d, %v", written, err) + } + if got := buffer.String(); got != "abcd" { + t.Fatalf("unexpected retained diagnostics: %q", got) + } +} + +func TestRunErrorPreservesMessageAndCause(t *testing.T) { + cause := context.Canceled + runError := &RunError{ExitCode: 130, Message: "cancelled by caller", Cause: cause} + if runError.Error() != "cancelled by caller" || !reflect.DeepEqual(runError.Unwrap(), cause) { + t.Fatalf("unexpected run error behavior: %+v", runError) + } + if fallback := (&RunError{Cause: cause}).Error(); fallback != cause.Error() { + t.Fatalf("cause fallback was %q", fallback) + } + if fallback := (&RunError{}).Error(); fallback == "" { + t.Fatal("empty run error has no fallback message") + } +} + +func TestExpectedReportPathUsesJobType(t *testing.T) { + analysis := domain.JobSpec{Type: domain.JobTypeAnalysis, OutputDir: "output"} + comparison := domain.JobSpec{Type: domain.JobTypeComparison, OutputDir: "output"} + if got := filepath.Base(expectedReportPath(analysis)); got != "analysis_report.json" { + t.Fatalf("unexpected analysis report name %q", got) + } + if got := filepath.Base(expectedReportPath(comparison)); got != "comparison_report.json" { + t.Fatalf("unexpected comparison report name %q", got) + } +} + +func TestHealthUsesFreshCachedResult(t *testing.T) { + runner := CLIRunner{ + PythonExecutable: "executable-that-must-not-run", + HealthCacheTTL: time.Hour, + healthAt: time.Now(), + healthValue: domain.Health{Ready: true}, + } + if health := runner.Health(context.Background()); !health.Ready { + t.Fatalf("fresh cached health was ignored: %+v", health) + } +} + +func TestSanitizeReportMetadataRemovesInternalPaths(t *testing.T) { + root := t.TempDir() + tests := []struct { + name string + spec domain.JobSpec + report string + expectation map[string]string + }{ + { + name: "analysis", + spec: domain.JobSpec{ + Type: domain.JobTypeAnalysis, + Inputs: []domain.InputFile{{ + Name: "agreement.txt", Path: filepath.Join(root, "private", "document.txt"), + }}, + }, + report: `{"schema_version":"1.0","file_path":"C:\\private\\document.txt"}`, + expectation: map[string]string{"file_path": "agreement.txt"}, + }, + { + name: "comparison", + spec: domain.JobSpec{ + Type: domain.JobTypeComparison, + Inputs: []domain.InputFile{ + {Name: "before.txt", Path: filepath.Join(root, "private", "original.txt")}, + {Name: "after.txt", Path: filepath.Join(root, "private", "modified.txt")}, + }, + }, + report: `{"schema_version":"1.0","original_document":"private-original",` + + `"modified_document":"private-modified"}`, + expectation: map[string]string{ + "original_document": "before.txt", "modified_document": "after.txt", + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(root, test.name+".json") + if err := os.WriteFile(path, []byte(test.report), 0o600); err != nil { + t.Fatal(err) + } + if err := sanitizeReportMetadata(path, test.spec); err != nil { + t.Fatal(err) + } + payload, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for key, value := range test.expectation { + fragment := `"` + key + `": "` + value + `"` + if !bytes.Contains(payload, []byte(fragment)) { + t.Fatalf("sanitized report missing %q: %s", fragment, payload) + } + } + if bytes.Contains(payload, []byte("private")) { + t.Fatalf("sanitized report retained an internal path: %s", payload) + } + }) + } +} + +func TestCLIRunnerWithRealPythonBridge(t *testing.T) { + python := os.Getenv("CLAUSEGUARD_INTEGRATION_PYTHON") + workspace := os.Getenv("CLAUSEGUARD_INTEGRATION_WORKSPACE") + if python == "" || workspace == "" { + t.Skip("set CLAUSEGUARD_INTEGRATION_PYTHON and CLAUSEGUARD_INTEGRATION_WORKSPACE") + } + root := t.TempDir() + document := filepath.Join(root, "contract.txt") + if err := os.WriteFile(document, []byte("SERVICE AGREEMENT\n1. Payment is due within 30 days.\n2. Either party may terminate with notice."), 0o600); err != nil { + t.Fatal(err) + } + spec := domain.JobSpec{ + ID: "integration-job", + Type: domain.JobTypeAnalysis, + Inputs: []domain.InputFile{{Name: "contract.txt", Path: document}}, + OutputDir: filepath.Join(root, "output"), + } + runner := CLIRunner{PythonExecutable: python, Workspace: workspace, MockModels: true} + if health := runner.Health(context.Background()); !health.Ready || len(health.Models) == 0 { + t.Fatalf("Python bridge is not healthy: %+v", health) + } + var events []domain.EngineEvent + result, err := runner.Run(context.Background(), spec, func(event domain.EngineEvent) { + events = append(events, event) + }) + if err != nil { + t.Fatal(err) + } + if len(events) < 3 || events[len(events)-1].Type != "completed" { + t.Fatalf("unexpected event stream: %#v", events) + } + if _, err := os.Stat(result.ReportPath); err != nil { + t.Fatalf("report missing: %v", err) + } +} diff --git a/apps/server/internal/jobs/manager.go b/apps/server/internal/jobs/manager.go new file mode 100644 index 0000000..d3cc972 --- /dev/null +++ b/apps/server/internal/jobs/manager.go @@ -0,0 +1,518 @@ +package jobs + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/domain" + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/engine" + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/store" +) + +var ( + ErrConflict = errors.New("job state does not allow this operation") + ErrUnsafePath = errors.New("job path is outside the managed data directory") + ErrCapacity = errors.New("active job capacity reached") + ErrShuttingDown = errors.New("job manager is shutting down") +) + +type Options struct { + Timeout time.Duration + MaxConcurrentJobs int + MaxActiveJobs int + DataDir string +} + +type Manager struct { + repository store.Repository + runner engine.Runner + timeout time.Duration + semaphore chan struct{} + dataDir string + maxActive int + stateMu sync.Mutex + mu sync.Mutex + shuttingDown bool + cancels map[string]context.CancelFunc + subscribers map[string]map[chan domain.Job]struct{} + wait sync.WaitGroup +} + +func New(repository store.Repository, runner engine.Runner, options Options) (*Manager, error) { + if repository == nil || runner == nil { + return nil, errors.New("job repository and runner are required") + } + if options.Timeout <= 0 { + return nil, errors.New("job timeout must be positive") + } + if options.MaxConcurrentJobs <= 0 { + return nil, errors.New("maximum concurrent jobs must be positive") + } + if options.MaxActiveJobs < options.MaxConcurrentJobs { + return nil, errors.New("maximum active jobs cannot be lower than maximum concurrent jobs") + } + dataDir, err := filepath.Abs(options.DataDir) + if err != nil { + return nil, fmt.Errorf("resolve managed data directory: %w", err) + } + manager := &Manager{ + repository: repository, + runner: runner, + timeout: options.Timeout, + semaphore: make(chan struct{}, options.MaxConcurrentJobs), + dataDir: dataDir, + maxActive: options.MaxActiveJobs, + cancels: make(map[string]context.CancelFunc), + subscribers: make(map[string]map[chan domain.Job]struct{}), + } + if _, err := repository.RecoverInterrupted(context.Background(), time.Now().UTC()); err != nil { + return nil, err + } + return manager, nil +} + +func (manager *Manager) Submit(ctx context.Context, spec domain.JobSpec) (domain.Job, error) { + if err := domain.ValidateSpec(spec); err != nil { + return domain.Job{}, err + } + if err := manager.validateSpecPaths(spec); err != nil { + return domain.Job{}, err + } + now := time.Now().UTC() + job := domain.Job{ + ID: spec.ID, + Type: spec.Type, + Status: domain.JobQueued, + Stage: "queued", + Progress: 0, + Message: "Job accepted", + Inputs: spec.Inputs, + OutputDir: spec.OutputDir, + CreatedAt: now, + UpdatedAt: now, + } + runContext, cancel := context.WithCancel(context.Background()) + manager.mu.Lock() + if manager.shuttingDown { + manager.mu.Unlock() + cancel() + return domain.Job{}, ErrShuttingDown + } + if _, exists := manager.cancels[job.ID]; exists { + manager.mu.Unlock() + cancel() + return domain.Job{}, ErrConflict + } + if len(manager.cancels) >= manager.maxActive { + manager.mu.Unlock() + cancel() + return domain.Job{}, ErrCapacity + } + manager.cancels[job.ID] = cancel + manager.wait.Add(1) + manager.mu.Unlock() + if err := manager.repository.Create(ctx, job); err != nil { + manager.mu.Lock() + delete(manager.cancels, job.ID) + manager.mu.Unlock() + cancel() + manager.wait.Done() + return domain.Job{}, err + } + manager.publish(job) + go manager.execute(runContext, spec) + return manager.decorate(job), nil +} + +func (manager *Manager) Get(ctx context.Context, id string) (domain.Job, error) { + job, err := manager.repository.Get(ctx, id) + if err != nil { + return domain.Job{}, err + } + return manager.decorate(job), nil +} + +func (manager *Manager) List(ctx context.Context, limit int) ([]domain.Job, error) { + jobs, err := manager.repository.List(ctx, limit) + if err != nil { + return nil, err + } + for index := range jobs { + jobs[index] = manager.decorate(jobs[index]) + } + return jobs, nil +} + +func (manager *Manager) Cancel(ctx context.Context, id string) (domain.Job, error) { + manager.stateMu.Lock() + defer manager.stateMu.Unlock() + job, err := manager.repository.Get(ctx, id) + if err != nil { + return domain.Job{}, err + } + if job.Status.Terminal() { + return domain.Job{}, ErrConflict + } + + manager.mu.Lock() + cancel, exists := manager.cancels[id] + manager.mu.Unlock() + if !exists { + return domain.Job{}, ErrConflict + } + job.Message = "Cancellation requested" + job.UpdatedAt = time.Now().UTC() + if err := manager.repository.Update(ctx, job); err != nil { + return domain.Job{}, err + } + manager.publish(job) + cancel() + return manager.decorate(job), nil +} + +// Delete removes a terminal job. Active jobs are cancelled and retained until +// the engine process has stopped and the final state has been persisted. +func (manager *Manager) Delete(ctx context.Context, id string) (domain.Job, bool, error) { + job, err := manager.repository.Get(ctx, id) + if err != nil { + return domain.Job{}, false, err + } + if !job.Status.Terminal() { + cancelled, err := manager.Cancel(ctx, id) + return cancelled, false, err + } + + jobDir := filepath.Join(manager.dataDir, "jobs", job.ID) + if err := manager.validateJobDirectory(job, jobDir); err != nil { + return domain.Job{}, false, err + } + if err := os.RemoveAll(jobDir); err != nil { + return domain.Job{}, false, fmt.Errorf("remove job files: %w", err) + } + if err := manager.repository.Delete(ctx, id); err != nil { + return domain.Job{}, false, err + } + job.ReportPath = "" + job.ReportURL = "" + return job, true, nil +} + +func (manager *Manager) Subscribe( + ctx context.Context, + id string, +) (<-chan domain.Job, func(), error) { + updates := make(chan domain.Job, 1) + manager.mu.Lock() + if manager.subscribers[id] == nil { + manager.subscribers[id] = make(map[chan domain.Job]struct{}) + } + manager.subscribers[id][updates] = struct{}{} + manager.mu.Unlock() + + job, err := manager.repository.Get(ctx, id) + if err != nil { + manager.unsubscribe(id, updates) + return nil, nil, err + } + manager.mu.Lock() + if _, subscribed := manager.subscribers[id][updates]; subscribed { + select { + case updates <- manager.decorate(job): + default: + } + } + manager.mu.Unlock() + return updates, func() { manager.unsubscribe(id, updates) }, nil +} + +func (manager *Manager) Health(ctx context.Context) (domain.Health, error) { + if err := manager.repository.Ping(ctx); err != nil { + return domain.Health{Ready: false, Error: "job store unavailable"}, err + } + health := manager.runner.Health(ctx) + if !health.Ready { + slog.Debug("analysis engine health check failed", "error", health.Error) + publicHealth := domain.Health{Ready: false, Error: "Python analysis engine unavailable"} + return publicHealth, errors.New(publicHealth.Error) + } + return health, nil +} + +func (manager *Manager) Shutdown(ctx context.Context) error { + manager.mu.Lock() + manager.shuttingDown = true + for _, cancel := range manager.cancels { + cancel() + } + manager.mu.Unlock() + + done := make(chan struct{}) + go func() { + manager.wait.Wait() + close(done) + }() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (manager *Manager) execute(ctx context.Context, spec domain.JobSpec) { + defer manager.wait.Done() + defer func() { + manager.mu.Lock() + delete(manager.cancels, spec.ID) + manager.mu.Unlock() + }() + + select { + case manager.semaphore <- struct{}{}: + defer func() { <-manager.semaphore }() + case <-ctx.Done(): + manager.finish(spec.ID, domain.RunResult{}, ctx.Err(), ctx.Err()) + return + } + + manager.stateMu.Lock() + job, err := manager.repository.Get(context.Background(), spec.ID) + if err != nil { + manager.stateMu.Unlock() + return + } + now := time.Now().UTC() + job.Status = domain.JobRunning + job.Stage = "loading" + job.Message = "Starting analysis engine" + job.StartedAt = &now + job.UpdatedAt = now + if err := manager.repository.Update(context.Background(), job); err != nil { + manager.stateMu.Unlock() + return + } + manager.stateMu.Unlock() + manager.publish(job) + + runContext, cancel := context.WithTimeout(ctx, manager.timeout) + defer cancel() + result, runError := manager.runner.Run(runContext, spec, func(event domain.EngineEvent) { + manager.applyEvent(spec.ID, event) + }) + manager.finish(spec.ID, result, runError, runContext.Err()) +} + +func (manager *Manager) applyEvent(id string, event domain.EngineEvent) { + manager.stateMu.Lock() + defer manager.stateMu.Unlock() + job, err := manager.repository.Get(context.Background(), id) + if err != nil || job.Status.Terminal() { + return + } + job.Stage = event.Stage + job.Progress = event.Progress + job.Message = event.Message + job.UpdatedAt = time.Now().UTC() + if event.Error != nil { + job.Message = "Analysis engine reported an error" + job.Error = &domain.JobError{Code: event.Error.Code, Message: job.Message} + } + if err := manager.repository.Update(context.Background(), job); err != nil { + return + } + manager.publish(job) +} + +func (manager *Manager) finish( + id string, + result domain.RunResult, + runError error, + contextError error, +) { + manager.stateMu.Lock() + defer manager.stateMu.Unlock() + job, err := manager.repository.Get(context.Background(), id) + if err != nil { + return + } + now := time.Now().UTC() + job.UpdatedAt = now + job.CompletedAt = &now + job.Progress = 100 + job.ReportPath = result.ReportPath + + switch { + case errors.Is(contextError, context.DeadlineExceeded): + exitCode := 14 + job.Status = domain.JobTimedOut + job.Stage = "failed" + job.Message = "Analysis timed out" + job.ExitCode = &exitCode + job.Error = &domain.JobError{Code: "timeout", Message: job.Message} + case errors.Is(contextError, context.Canceled): + exitCode := 130 + job.Status = domain.JobCancelled + job.Stage = "failed" + job.Message = "Analysis cancelled" + job.ExitCode = &exitCode + job.Error = &domain.JobError{Code: "cancelled", Message: job.Message} + case runError != nil: + exitCode := 20 + var typedError *engine.RunError + if errors.As(runError, &typedError) { + exitCode = typedError.ExitCode + } + publicMessage := errorMessageForExit(exitCode) + job.Status = domain.JobFailed + job.Stage = "failed" + job.Message = publicMessage + job.Error = &domain.JobError{Code: errorCodeForExit(exitCode), Message: publicMessage} + job.ExitCode = &exitCode + slog.Error("analysis engine failed", "job_id", id, "exit_code", exitCode) + default: + job.Status = domain.JobCompleted + job.Stage = "completed" + job.Message = "Analysis completed" + job.Error = nil + job.ExitCode = nil + } + if err := manager.repository.Update(context.Background(), job); err != nil { + return + } + manager.publish(job) +} + +func (manager *Manager) publish(job domain.Job) { + job = manager.decorate(job) + manager.mu.Lock() + defer manager.mu.Unlock() + for subscriber := range manager.subscribers[job.ID] { + select { + case subscriber <- job: + default: + select { + case <-subscriber: + default: + } + select { + case subscriber <- job: + default: + } + } + } +} + +func (manager *Manager) unsubscribe(id string, updates chan domain.Job) { + manager.mu.Lock() + defer manager.mu.Unlock() + subscribers := manager.subscribers[id] + if _, exists := subscribers[updates]; !exists { + return + } + delete(subscribers, updates) + close(updates) + if len(subscribers) == 0 { + delete(manager.subscribers, id) + } +} + +func (manager *Manager) decorate(job domain.Job) domain.Job { + if job.Status == domain.JobCompleted && job.ReportPath != "" { + job.ReportURL = "/api/v1/jobs/" + job.ID + "/report" + } + return job +} + +func (manager *Manager) validateSpecPaths(spec domain.JobSpec) error { + if !within(manager.dataDir, spec.OutputDir) { + return ErrUnsafePath + } + for _, input := range spec.Inputs { + if !within(manager.dataDir, input.Path) { + return ErrUnsafePath + } + } + return nil +} + +func (manager *Manager) validateJobDirectory(job domain.Job, expected string) error { + jobDirectory := filepath.Dir(filepath.Clean(job.OutputDir)) + expectedPath, err := filepath.Abs(expected) + if err != nil { + return ErrUnsafePath + } + jobPath, err := filepath.Abs(jobDirectory) + if err != nil || jobPath != expectedPath || !within(manager.dataDir, jobPath) { + return ErrUnsafePath + } + return nil +} + +func within(root, target string) bool { + rootPath, err := filepath.Abs(root) + if err != nil { + return false + } + targetPath, err := filepath.Abs(target) + if err != nil { + return false + } + relative, err := filepath.Rel(rootPath, targetPath) + if err != nil { + return false + } + return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) +} + +func errorCodeForExit(exitCode int) string { + switch exitCode { + case 2: + return "usage_error" + case 10: + return "configuration_error" + case 11: + return "document_error" + case 12: + return "model_error" + case 13: + return "usage_limit" + case 14: + return "timeout" + case 15: + return "data_error" + case 130: + return "cancelled" + default: + return "engine_error" + } +} + +func errorMessageForExit(exitCode int) string { + switch exitCode { + case 2: + return "The analysis request was invalid" + case 10: + return "The analysis engine configuration is invalid" + case 11: + return "The uploaded document could not be processed" + case 12: + return "A configured analysis model was unavailable" + case 13: + return "A configured model usage limit was reached" + case 14: + return "Analysis timed out" + case 15: + return "Required analysis data was invalid" + case 130: + return "Analysis cancelled" + default: + return "The analysis engine encountered an internal error" + } +} diff --git a/apps/server/internal/jobs/manager_test.go b/apps/server/internal/jobs/manager_test.go new file mode 100644 index 0000000..0f1fb59 --- /dev/null +++ b/apps/server/internal/jobs/manager_test.go @@ -0,0 +1,499 @@ +package jobs + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/domain" + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/engine" + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/store" +) + +type fakeRunner struct { + run func(context.Context, domain.JobSpec, engine.EventSink) (domain.RunResult, error) + health domain.Health +} + +type delayedGetRepository struct { + store.Repository + entered chan struct{} + release chan struct{} + once sync.Once +} + +func (repository *delayedGetRepository) Get(ctx context.Context, id string) (domain.Job, error) { + repository.once.Do(func() { + close(repository.entered) + select { + case <-repository.release: + case <-ctx.Done(): + } + }) + return repository.Repository.Get(ctx, id) +} + +func (runner *fakeRunner) Run( + ctx context.Context, + spec domain.JobSpec, + sink engine.EventSink, +) (domain.RunResult, error) { + return runner.run(ctx, spec, sink) +} + +func (runner *fakeRunner) Health(context.Context) domain.Health { + return runner.health +} + +func TestManagerCompletesStreamsAndDeletesJob(t *testing.T) { + runner := &fakeRunner{health: domain.Health{Ready: true}} + runner.run = func(_ context.Context, spec domain.JobSpec, sink engine.EventSink) (domain.RunResult, error) { + sink(domain.EngineEvent{ + SchemaVersion: "1.0", + RunID: spec.ID, + Sequence: 1, + Type: "progress", + Stage: "checking", + Status: "started", + Progress: 50, + Message: "Checking clauses", + }) + report := filepath.Join(spec.OutputDir, "analysis_report.json") + if err := os.MkdirAll(spec.OutputDir, 0o750); err != nil { + return domain.RunResult{}, err + } + if err := os.WriteFile(report, []byte(`{"schema_version":"1.0"}`), 0o600); err != nil { + return domain.RunResult{}, err + } + return domain.RunResult{ReportPath: report}, nil + } + manager, repository, root := testManager(t, runner, time.Second) + spec := testSpec(t, root, "complete-job") + accepted, err := manager.Submit(context.Background(), spec) + if err != nil { + t.Fatal(err) + } + updates, unsubscribe, err := manager.Subscribe(context.Background(), accepted.ID) + if err != nil { + t.Fatal(err) + } + defer unsubscribe() + + final := awaitTerminal(t, updates) + if final.Status != domain.JobCompleted || final.ReportURL == "" || final.Progress != 100 { + t.Fatalf("unexpected final job: %+v", final) + } + stored, err := repository.Get(context.Background(), final.ID) + if err != nil || stored.ReportPath == "" { + t.Fatalf("report path not persisted: %+v, %v", stored, err) + } + deletedJob, deleted, err := manager.Delete(context.Background(), final.ID) + if err != nil || !deleted { + t.Fatalf("terminal job was not deleted: deleted=%v err=%v", deleted, err) + } + if deletedJob.ReportURL != "" || deletedJob.ReportPath != "" { + t.Fatalf("deleted job retained a report link: %+v", deletedJob) + } + if _, err := os.Stat(filepath.Join(root, "jobs", final.ID)); !os.IsNotExist(err) { + t.Fatalf("job directory still exists: %v", err) + } +} + +func TestManagerCancelsRunningJob(t *testing.T) { + started := make(chan struct{}) + runner := &fakeRunner{health: domain.Health{Ready: true}} + runner.run = func(ctx context.Context, _ domain.JobSpec, _ engine.EventSink) (domain.RunResult, error) { + close(started) + <-ctx.Done() + return domain.RunResult{}, &engine.RunError{ExitCode: 130, Message: "cancelled", Cause: ctx.Err()} + } + manager, _, root := testManager(t, runner, time.Second) + spec := testSpec(t, root, "cancel-job") + if _, err := manager.Submit(context.Background(), spec); err != nil { + t.Fatal(err) + } + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("runner did not start") + } + if _, err := manager.Cancel(context.Background(), spec.ID); err != nil { + t.Fatal(err) + } + final := awaitStoredTerminal(t, manager, spec.ID) + if final.Status != domain.JobCancelled || final.ExitCode == nil || *final.ExitCode != 130 { + t.Fatalf("unexpected cancelled state: %+v", final) + } +} + +func TestManagerTimesOutJob(t *testing.T) { + runner := &fakeRunner{health: domain.Health{Ready: true}} + runner.run = func(ctx context.Context, _ domain.JobSpec, _ engine.EventSink) (domain.RunResult, error) { + <-ctx.Done() + return domain.RunResult{}, &engine.RunError{ExitCode: 14, Message: "timed out", Cause: ctx.Err()} + } + manager, _, root := testManager(t, runner, 20*time.Millisecond) + spec := testSpec(t, root, "timeout-job") + if _, err := manager.Submit(context.Background(), spec); err != nil { + t.Fatal(err) + } + final := awaitStoredTerminal(t, manager, spec.ID) + if final.Status != domain.JobTimedOut || final.Error == nil || final.Error.Code != "timeout" { + t.Fatalf("unexpected timeout state: %+v", final) + } +} + +func TestManagerMapsEngineExitCode(t *testing.T) { + runner := &fakeRunner{health: domain.Health{Ready: true}} + runner.run = func(context.Context, domain.JobSpec, engine.EventSink) (domain.RunResult, error) { + return domain.RunResult{}, &engine.RunError{ + ExitCode: 12, + Message: `model unavailable for C:\private\contract.txt`, + } + } + manager, _, root := testManager(t, runner, time.Second) + spec := testSpec(t, root, "failed-job") + if _, err := manager.Submit(context.Background(), spec); err != nil { + t.Fatal(err) + } + final := awaitStoredTerminal(t, manager, spec.ID) + if final.Status != domain.JobFailed || final.Error == nil || final.Error.Code != "model_error" { + t.Fatalf("unexpected failed state: %+v", final) + } + if strings.Contains(final.Message, "private") || strings.Contains(final.Error.Message, "private") { + t.Fatalf("job state leaked engine diagnostics: %+v", final) + } +} + +func TestManagerRejectsPathsOutsideDataDirectory(t *testing.T) { + runner := &fakeRunner{ + health: domain.Health{Ready: true}, + run: func(context.Context, domain.JobSpec, engine.EventSink) (domain.RunResult, error) { + return domain.RunResult{}, nil + }, + } + manager, _, root := testManager(t, runner, time.Second) + spec := testSpec(t, root, "unsafe-job") + spec.Inputs[0].Path = filepath.Join(t.TempDir(), "outside.txt") + if _, err := manager.Submit(context.Background(), spec); !errors.Is(err, ErrUnsafePath) { + t.Fatalf("expected ErrUnsafePath, got %v", err) + } +} + +func TestManagerEnforcesConcurrencyLimit(t *testing.T) { + started := make(chan string, 2) + release := make(chan struct{}, 2) + var active atomic.Int32 + var maximum atomic.Int32 + runner := &fakeRunner{health: domain.Health{Ready: true}} + runner.run = func(_ context.Context, spec domain.JobSpec, _ engine.EventSink) (domain.RunResult, error) { + current := active.Add(1) + defer active.Add(-1) + for { + observed := maximum.Load() + if current <= observed || maximum.CompareAndSwap(observed, current) { + break + } + } + started <- spec.ID + <-release + report := filepath.Join(spec.OutputDir, "analysis_report.json") + if err := os.MkdirAll(spec.OutputDir, 0o750); err != nil { + return domain.RunResult{}, err + } + if err := os.WriteFile(report, []byte("{}"), 0o600); err != nil { + return domain.RunResult{}, err + } + return domain.RunResult{ReportPath: report}, nil + } + manager, _, root := testManager(t, runner, time.Second) + first := testSpec(t, root, "first-job") + second := testSpec(t, root, "second-job") + if _, err := manager.Submit(context.Background(), first); err != nil { + t.Fatal(err) + } + if _, err := manager.Submit(context.Background(), second); err != nil { + t.Fatal(err) + } + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("first job did not start") + } + select { + case id := <-started: + t.Fatalf("second job %q started before capacity was released", id) + case <-time.After(50 * time.Millisecond): + } + release <- struct{}{} + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("queued job did not start after capacity was released") + } + release <- struct{}{} + awaitStoredTerminal(t, manager, first.ID) + awaitStoredTerminal(t, manager, second.ID) + if maximum.Load() != 1 { + t.Fatalf("observed %d concurrent runners", maximum.Load()) + } +} + +func TestManagerRejectsJobsBeyondActiveCapacity(t *testing.T) { + runner := &fakeRunner{health: domain.Health{Ready: true}} + runner.run = func(ctx context.Context, _ domain.JobSpec, _ engine.EventSink) (domain.RunResult, error) { + <-ctx.Done() + return domain.RunResult{}, &engine.RunError{ExitCode: 130, Message: "cancelled", Cause: ctx.Err()} + } + root := t.TempDir() + repository, err := store.Open(filepath.Join(root, "jobs.db")) + if err != nil { + t.Fatal(err) + } + manager, err := New(repository, runner, Options{ + Timeout: time.Second, MaxConcurrentJobs: 1, MaxActiveJobs: 1, DataDir: root, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = manager.Shutdown(ctx) + _ = repository.Close() + }) + first := testSpec(t, root, "capacity-first") + second := testSpec(t, root, "capacity-second") + if _, err := manager.Submit(context.Background(), first); err != nil { + t.Fatal(err) + } + if _, err := manager.Submit(context.Background(), first); !errors.Is(err, ErrConflict) { + t.Fatalf("expected duplicate job conflict, got %v", err) + } + if _, err := manager.Submit(context.Background(), second); !errors.Is(err, ErrCapacity) { + t.Fatalf("expected ErrCapacity, got %v", err) + } + if _, err := manager.Cancel(context.Background(), first.ID); err != nil { + t.Fatal(err) + } + awaitStoredTerminal(t, manager, first.ID) +} + +func TestShutdownRejectsLateSubmissions(t *testing.T) { + runner := &fakeRunner{ + health: domain.Health{Ready: true}, + run: func(context.Context, domain.JobSpec, engine.EventSink) (domain.RunResult, error) { + return domain.RunResult{}, nil + }, + } + manager, _, root := testManager(t, runner, time.Second) + if err := manager.Shutdown(context.Background()); err != nil { + t.Fatal(err) + } + spec := testSpec(t, root, "late-job") + if _, err := manager.Submit(context.Background(), spec); !errors.Is(err, ErrShuttingDown) { + t.Fatalf("expected ErrShuttingDown, got %v", err) + } +} + +func TestSubscribeDoesNotBlockWhenUpdateArrivesDuringInitialRead(t *testing.T) { + root := t.TempDir() + base, err := store.Open(filepath.Join(root, "jobs.db")) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + job := domain.Job{ + ID: "subscribe-race", Type: domain.JobTypeAnalysis, Status: domain.JobCompleted, + Stage: "completed", Progress: 100, Message: "done", + Inputs: []domain.InputFile{{Name: "contract.txt", Path: filepath.Join(root, "jobs", "subscribe-race", "inputs", "document.txt")}}, + OutputDir: filepath.Join(root, "jobs", "subscribe-race", "output"), + CreatedAt: now, UpdatedAt: now, CompletedAt: &now, + } + if err := base.Create(context.Background(), job); err != nil { + t.Fatal(err) + } + repository := &delayedGetRepository{ + Repository: base, entered: make(chan struct{}), release: make(chan struct{}), + } + runner := &fakeRunner{ + health: domain.Health{Ready: true}, + run: func(context.Context, domain.JobSpec, engine.EventSink) (domain.RunResult, error) { + return domain.RunResult{}, nil + }, + } + manager, err := New(repository, runner, Options{ + Timeout: time.Second, MaxConcurrentJobs: 1, MaxActiveJobs: 2, DataDir: root, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = manager.Shutdown(context.Background()) + _ = base.Close() + }) + type subscription struct { + updates <-chan domain.Job + unsubscribe func() + err error + } + result := make(chan subscription, 1) + go func() { + updates, unsubscribe, err := manager.Subscribe(context.Background(), job.ID) + result <- subscription{updates: updates, unsubscribe: unsubscribe, err: err} + }() + select { + case <-repository.entered: + case <-time.After(time.Second): + t.Fatal("subscription did not begin its initial read") + } + manager.publish(job) + close(repository.release) + select { + case subscribed := <-result: + if subscribed.err != nil { + t.Fatal(subscribed.err) + } + defer subscribed.unsubscribe() + select { + case snapshot := <-subscribed.updates: + if snapshot.Status != domain.JobCompleted { + t.Fatalf("unexpected snapshot: %+v", snapshot) + } + case <-time.After(time.Second): + t.Fatal("subscription returned without a snapshot") + } + case <-time.After(time.Second): + t.Fatal("subscription blocked on a full initial-update channel") + } +} + +func TestManagerListAndHealth(t *testing.T) { + runner := &fakeRunner{health: domain.Health{Ready: true}} + runner.run = func(_ context.Context, spec domain.JobSpec, _ engine.EventSink) (domain.RunResult, error) { + report := filepath.Join(spec.OutputDir, "analysis_report.json") + if err := os.MkdirAll(spec.OutputDir, 0o750); err != nil { + return domain.RunResult{}, err + } + if err := os.WriteFile(report, []byte("{}"), 0o600); err != nil { + return domain.RunResult{}, err + } + return domain.RunResult{ReportPath: report}, nil + } + manager, _, root := testManager(t, runner, time.Second) + spec := testSpec(t, root, "listed-job") + if _, err := manager.Submit(context.Background(), spec); err != nil { + t.Fatal(err) + } + awaitStoredTerminal(t, manager, spec.ID) + listed, err := manager.List(context.Background(), 10) + if err != nil || len(listed) != 1 || listed[0].ReportURL == "" { + t.Fatalf("unexpected list result: %+v, %v", listed, err) + } + health, err := manager.Health(context.Background()) + if err != nil || !health.Ready { + t.Fatalf("unexpected health result: %+v, %v", health, err) + } +} + +func TestExitCodeMappingIsStable(t *testing.T) { + tests := map[int]string{ + 2: "usage_error", 10: "configuration_error", 11: "document_error", + 12: "model_error", 13: "usage_limit", 14: "timeout", + 15: "data_error", 130: "cancelled", 99: "engine_error", + } + for exitCode, want := range tests { + if got := errorCodeForExit(exitCode); got != want { + t.Fatalf("exit code %d mapped to %q, want %q", exitCode, got, want) + } + } + for _, exitCode := range []int{2, 10, 11, 12, 13, 14, 15, 20, 130} { + if message := errorMessageForExit(exitCode); message == "" { + t.Fatalf("exit code %d has no public message", exitCode) + } + } +} + +func testManager( + t *testing.T, + runner engine.Runner, + timeout time.Duration, +) (*Manager, *store.SQLite, string) { + t.Helper() + root := t.TempDir() + repository, err := store.Open(filepath.Join(root, "jobs.db")) + if err != nil { + t.Fatal(err) + } + manager, err := New(repository, runner, Options{ + Timeout: timeout, + MaxConcurrentJobs: 1, + MaxActiveJobs: 10, + DataDir: root, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = manager.Shutdown(ctx) + _ = repository.Close() + }) + return manager, repository, root +} + +func testSpec(t *testing.T, root, id string) domain.JobSpec { + t.Helper() + inputDir := filepath.Join(root, "jobs", id, "inputs") + outputDir := filepath.Join(root, "jobs", id, "output") + if err := os.MkdirAll(inputDir, 0o750); err != nil { + t.Fatal(err) + } + input := filepath.Join(inputDir, "document.txt") + if err := os.WriteFile(input, []byte("Agreement"), 0o600); err != nil { + t.Fatal(err) + } + return domain.JobSpec{ + ID: id, + Type: domain.JobTypeAnalysis, + Inputs: []domain.InputFile{{Name: "document.txt", Path: input}}, + OutputDir: outputDir, + } +} + +func awaitTerminal(t *testing.T, updates <-chan domain.Job) domain.Job { + t.Helper() + timer := time.NewTimer(2 * time.Second) + defer timer.Stop() + for { + select { + case job := <-updates: + if job.Status.Terminal() { + return job + } + case <-timer.C: + t.Fatal("timed out waiting for terminal job update") + } + } +} + +func awaitStoredTerminal(t *testing.T, manager *Manager, id string) domain.Job { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + job, err := manager.Get(context.Background(), id) + if err == nil && job.Status.Terminal() { + return job + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("timed out waiting for stored terminal job") + return domain.Job{} +} diff --git a/apps/server/internal/store/sqlite.go b/apps/server/internal/store/sqlite.go new file mode 100644 index 0000000..3efb6d6 --- /dev/null +++ b/apps/server/internal/store/sqlite.go @@ -0,0 +1,384 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/domain" + _ "modernc.org/sqlite" +) + +var ErrNotFound = errors.New("job not found") + +const databaseSchemaVersion = 1 + +type Repository interface { + Create(context.Context, domain.Job) error + Update(context.Context, domain.Job) error + Get(context.Context, string) (domain.Job, error) + List(context.Context, int) ([]domain.Job, error) + Delete(context.Context, string) error + RecoverInterrupted(context.Context, time.Time) (int64, error) + Ping(context.Context) error + Close() error +} + +type SQLite struct { + database *sql.DB +} + +func Open(path string) (*SQLite, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return nil, fmt.Errorf("create database directory: %w", err) + } + database, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("open SQLite database: %w", err) + } + database.SetMaxOpenConns(1) + for _, statement := range []string{ + "PRAGMA busy_timeout = 5000", + "PRAGMA journal_mode = WAL", + "PRAGMA foreign_keys = ON", + } { + if _, err := database.Exec(statement); err != nil { + _ = database.Close() + return nil, fmt.Errorf("configure SQLite database: %w", err) + } + } + if err := initializeSchema(database); err != nil { + _ = database.Close() + return nil, err + } + return &SQLite{database: database}, nil +} + +func initializeSchema(database *sql.DB) error { + var version int + if err := database.QueryRow("PRAGMA user_version").Scan(&version); err != nil { + return fmt.Errorf("read SQLite schema version: %w", err) + } + if version > databaseSchemaVersion { + return fmt.Errorf( + "SQLite schema version %d is newer than supported version %d", + version, + databaseSchemaVersion, + ) + } + if version == databaseSchemaVersion { + return nil + } + + transaction, err := database.Begin() + if err != nil { + return fmt.Errorf("begin SQLite schema migration: %w", err) + } + defer transaction.Rollback() + if _, err := transaction.Exec(` + CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + status TEXT NOT NULL, + stage TEXT NOT NULL, + progress INTEGER NOT NULL, + message TEXT NOT NULL, + input_names TEXT NOT NULL, + input_paths TEXT NOT NULL, + output_dir TEXT NOT NULL, + report_path TEXT, + error_code TEXT, + error_message TEXT, + exit_code INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + started_at TEXT, + completed_at TEXT + ) + `); err != nil { + return fmt.Errorf("create jobs table: %w", err) + } + if _, err := transaction.Exec(fmt.Sprintf("PRAGMA user_version = %d", databaseSchemaVersion)); err != nil { + return fmt.Errorf("record SQLite schema version: %w", err) + } + if err := transaction.Commit(); err != nil { + return fmt.Errorf("commit SQLite schema migration: %w", err) + } + return nil +} + +func (store *SQLite) Create(ctx context.Context, job domain.Job) error { + names, paths, err := encodeInputs(job.Inputs) + if err != nil { + return err + } + errorCode, errorMessage := errorValues(job.Error) + _, err = store.database.ExecContext(ctx, ` + INSERT INTO jobs ( + id, type, status, stage, progress, message, input_names, input_paths, + output_dir, report_path, error_code, error_message, exit_code, + created_at, updated_at, started_at, completed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + job.ID, job.Type, job.Status, job.Stage, job.Progress, job.Message, names, paths, + job.OutputDir, nullableString(job.ReportPath), errorCode, errorMessage, nullableInt(job.ExitCode), + formatTime(job.CreatedAt), formatTime(job.UpdatedAt), nullableTime(job.StartedAt), nullableTime(job.CompletedAt), + ) + if err != nil { + return fmt.Errorf("create job: %w", err) + } + return nil +} + +func (store *SQLite) Update(ctx context.Context, job domain.Job) error { + names, paths, err := encodeInputs(job.Inputs) + if err != nil { + return err + } + errorCode, errorMessage := errorValues(job.Error) + result, err := store.database.ExecContext(ctx, ` + UPDATE jobs SET + type = ?, status = ?, stage = ?, progress = ?, message = ?, + input_names = ?, input_paths = ?, output_dir = ?, report_path = ?, + error_code = ?, error_message = ?, exit_code = ?, updated_at = ?, + started_at = ?, completed_at = ? + WHERE id = ? + `, + job.Type, job.Status, job.Stage, job.Progress, job.Message, + names, paths, job.OutputDir, nullableString(job.ReportPath), + errorCode, errorMessage, nullableInt(job.ExitCode), formatTime(job.UpdatedAt), + nullableTime(job.StartedAt), nullableTime(job.CompletedAt), job.ID, + ) + if err != nil { + return fmt.Errorf("update job: %w", err) + } + count, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("count updated jobs: %w", err) + } + if count == 0 { + return ErrNotFound + } + return nil +} + +func (store *SQLite) Get(ctx context.Context, id string) (domain.Job, error) { + row := store.database.QueryRowContext(ctx, selectJob+" WHERE id = ?", id) + job, err := scanJob(row) + if errors.Is(err, sql.ErrNoRows) { + return domain.Job{}, ErrNotFound + } + if err != nil { + return domain.Job{}, fmt.Errorf("get job: %w", err) + } + return job, nil +} + +func (store *SQLite) List(ctx context.Context, limit int) ([]domain.Job, error) { + if limit <= 0 { + limit = 50 + } + rows, err := store.database.QueryContext(ctx, selectJob+" ORDER BY created_at DESC LIMIT ?", limit) + if err != nil { + return nil, fmt.Errorf("list jobs: %w", err) + } + defer rows.Close() + + jobs := make([]domain.Job, 0) + for rows.Next() { + job, err := scanJob(rows) + if err != nil { + return nil, fmt.Errorf("scan listed job: %w", err) + } + jobs = append(jobs, job) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate jobs: %w", err) + } + return jobs, nil +} + +func (store *SQLite) Delete(ctx context.Context, id string) error { + result, err := store.database.ExecContext(ctx, "DELETE FROM jobs WHERE id = ?", id) + if err != nil { + return fmt.Errorf("delete job: %w", err) + } + count, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("count deleted jobs: %w", err) + } + if count == 0 { + return ErrNotFound + } + return nil +} + +func (store *SQLite) RecoverInterrupted(ctx context.Context, recoveredAt time.Time) (int64, error) { + timestamp := formatTime(recoveredAt) + result, err := store.database.ExecContext(ctx, ` + UPDATE jobs SET + status = ?, stage = ?, progress = 100, + message = ?, error_code = ?, error_message = ?, + exit_code = 20, updated_at = ?, completed_at = ? + WHERE status IN (?, ?) + `, + domain.JobFailed, "failed", "Job interrupted by server restart", + "server_restarted", "The server restarted before the job completed.", + timestamp, timestamp, domain.JobQueued, domain.JobRunning, + ) + if err != nil { + return 0, fmt.Errorf("recover interrupted jobs: %w", err) + } + return result.RowsAffected() +} + +func (store *SQLite) Ping(ctx context.Context) error { + return store.database.PingContext(ctx) +} + +func (store *SQLite) Close() error { + return store.database.Close() +} + +const selectJob = ` + SELECT id, type, status, stage, progress, message, input_names, input_paths, + output_dir, report_path, error_code, error_message, exit_code, + created_at, updated_at, started_at, completed_at + FROM jobs` + +type rowScanner interface { + Scan(...any) error +} + +func scanJob(scanner rowScanner) (domain.Job, error) { + var job domain.Job + var jobType, status string + var names, paths string + var reportPath, errorCode, errorMessage sql.NullString + var exitCode sql.NullInt64 + var createdAt, updatedAt string + var startedAt, completedAt sql.NullString + if err := scanner.Scan( + &job.ID, &jobType, &status, &job.Stage, &job.Progress, &job.Message, + &names, &paths, &job.OutputDir, &reportPath, &errorCode, &errorMessage, + &exitCode, &createdAt, &updatedAt, &startedAt, &completedAt, + ); err != nil { + return domain.Job{}, err + } + job.Type = domain.JobType(jobType) + job.Status = domain.JobStatus(status) + job.ReportPath = reportPath.String + if errorCode.Valid || errorMessage.Valid { + job.Error = &domain.JobError{Code: errorCode.String, Message: errorMessage.String} + } + if exitCode.Valid { + value := int(exitCode.Int64) + job.ExitCode = &value + } + var err error + job.Inputs, err = decodeInputs(names, paths) + if err != nil { + return domain.Job{}, err + } + job.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt) + if err != nil { + return domain.Job{}, fmt.Errorf("parse created_at: %w", err) + } + job.UpdatedAt, err = time.Parse(time.RFC3339Nano, updatedAt) + if err != nil { + return domain.Job{}, fmt.Errorf("parse updated_at: %w", err) + } + job.StartedAt, err = parseNullableTime(startedAt) + if err != nil { + return domain.Job{}, fmt.Errorf("parse started_at: %w", err) + } + job.CompletedAt, err = parseNullableTime(completedAt) + if err != nil { + return domain.Job{}, fmt.Errorf("parse completed_at: %w", err) + } + return job, nil +} + +func encodeInputs(inputs []domain.InputFile) (string, string, error) { + names := make([]string, 0, len(inputs)) + paths := make([]string, 0, len(inputs)) + for _, input := range inputs { + names = append(names, input.Name) + paths = append(paths, input.Path) + } + encodedNames, err := json.Marshal(names) + if err != nil { + return "", "", fmt.Errorf("encode input names: %w", err) + } + encodedPaths, err := json.Marshal(paths) + if err != nil { + return "", "", fmt.Errorf("encode input paths: %w", err) + } + return string(encodedNames), string(encodedPaths), nil +} + +func decodeInputs(encodedNames, encodedPaths string) ([]domain.InputFile, error) { + var names, paths []string + if err := json.Unmarshal([]byte(encodedNames), &names); err != nil { + return nil, fmt.Errorf("decode input names: %w", err) + } + if err := json.Unmarshal([]byte(encodedPaths), &paths); err != nil { + return nil, fmt.Errorf("decode input paths: %w", err) + } + if len(names) != len(paths) { + return nil, errors.New("stored input names and paths are inconsistent") + } + inputs := make([]domain.InputFile, 0, len(names)) + for index := range names { + inputs = append(inputs, domain.InputFile{Name: names[index], Path: paths[index]}) + } + return inputs, nil +} + +func errorValues(jobError *domain.JobError) (any, any) { + if jobError == nil { + return nil, nil + } + return jobError.Code, jobError.Message +} + +func nullableString(value string) any { + if value == "" { + return nil + } + return value +} + +func nullableInt(value *int) any { + if value == nil { + return nil + } + return *value +} + +func nullableTime(value *time.Time) any { + if value == nil { + return nil + } + return formatTime(*value) +} + +func parseNullableTime(value sql.NullString) (*time.Time, error) { + if !value.Valid { + return nil, nil + } + parsed, err := time.Parse(time.RFC3339Nano, value.String) + if err != nil { + return nil, err + } + return &parsed, nil +} + +func formatTime(value time.Time) string { + return value.UTC().Format(time.RFC3339Nano) +} diff --git a/apps/server/internal/store/sqlite_test.go b/apps/server/internal/store/sqlite_test.go new file mode 100644 index 0000000..6e1760b --- /dev/null +++ b/apps/server/internal/store/sqlite_test.go @@ -0,0 +1,145 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/arpitJ-dev/ClauseGuard-Agent/apps/server/internal/domain" +) + +func TestSQLiteJobLifecycle(t *testing.T) { + repository, err := Open(filepath.Join(t.TempDir(), "jobs.db")) + if err != nil { + t.Fatal(err) + } + defer repository.Close() + var schemaVersion int + if err := repository.database.QueryRow("PRAGMA user_version").Scan(&schemaVersion); err != nil { + t.Fatal(err) + } + if schemaVersion != databaseSchemaVersion { + t.Fatalf("schema version is %d", schemaVersion) + } + + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Microsecond) + job := domain.Job{ + ID: "job-1", + Type: domain.JobTypeAnalysis, + Status: domain.JobQueued, + Stage: "queued", + Message: "accepted", + Inputs: []domain.InputFile{{Name: "contract.txt", Path: filepath.Join("jobs", "job-1", "contract.txt")}}, + OutputDir: filepath.Join("jobs", "job-1", "output"), + CreatedAt: now, + UpdatedAt: now, + } + if err := repository.Create(ctx, job); err != nil { + t.Fatal(err) + } + if err := repository.Ping(ctx); err != nil { + t.Fatalf("database ping failed: %v", err) + } + loaded, err := repository.Get(ctx, job.ID) + if err != nil { + t.Fatal(err) + } + if loaded.Inputs[0].Path != job.Inputs[0].Path || loaded.Status != domain.JobQueued { + t.Fatalf("round trip changed job: %+v", loaded) + } + + exitCode := 0 + completed := now.Add(time.Second) + loaded.Status = domain.JobCompleted + loaded.Stage = "completed" + loaded.Progress = 100 + loaded.ReportPath = filepath.Join(loaded.OutputDir, "analysis_report.json") + loaded.ExitCode = &exitCode + loaded.CompletedAt = &completed + loaded.UpdatedAt = completed + if err := repository.Update(ctx, loaded); err != nil { + t.Fatal(err) + } + listed, err := repository.List(ctx, 10) + if err != nil { + t.Fatal(err) + } + if len(listed) != 1 || listed[0].ReportPath != loaded.ReportPath { + t.Fatalf("unexpected job list: %+v", listed) + } + if err := repository.Delete(ctx, job.ID); err != nil { + t.Fatal(err) + } + if _, err := repository.Get(ctx, job.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestSQLiteRejectsNewerSchema(t *testing.T) { + path := filepath.Join(t.TempDir(), "jobs.db") + repository, err := Open(path) + if err != nil { + t.Fatal(err) + } + if err := repository.Close(); err != nil { + t.Fatal(err) + } + database, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + if _, err := database.Exec("PRAGMA user_version = 99"); err != nil { + t.Fatal(err) + } + if err := database.Close(); err != nil { + t.Fatal(err) + } + if _, err := Open(path); err == nil || !strings.Contains(err.Error(), "newer than supported") { + t.Fatalf("newer schema was not rejected: %v", err) + } +} + +func TestSQLiteRecoversInterruptedJobs(t *testing.T) { + repository, err := Open(filepath.Join(t.TempDir(), "jobs.db")) + if err != nil { + t.Fatal(err) + } + defer repository.Close() + + now := time.Now().UTC() + job := domain.Job{ + ID: "interrupted", + Type: domain.JobTypeAnalysis, + Status: domain.JobRunning, + Stage: "checking", + Progress: 50, + Message: "running", + Inputs: []domain.InputFile{{Name: "contract.txt", Path: "contract.txt"}}, + OutputDir: "output", + CreatedAt: now, + UpdatedAt: now, + } + if err := repository.Create(context.Background(), job); err != nil { + t.Fatal(err) + } + recoveredAt := now.Add(time.Minute) + count, err := repository.RecoverInterrupted(context.Background(), recoveredAt) + if err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("expected one recovered job, got %d", count) + } + loaded, err := repository.Get(context.Background(), job.ID) + if err != nil { + t.Fatal(err) + } + if loaded.Status != domain.JobFailed || loaded.Error == nil || loaded.Error.Code != "server_restarted" { + t.Fatalf("unexpected recovered state: %+v", loaded) + } +} diff --git a/contracts/openapi-v1.yaml b/contracts/openapi-v1.yaml new file mode 100644 index 0000000..dd50031 --- /dev/null +++ b/contracts/openapi-v1.yaml @@ -0,0 +1,416 @@ +openapi: 3.1.0 +info: + title: ClauseGuard Agent Control Plane + version: 1.0.0 + description: Job-oriented API for contract analysis and document comparison. + license: + name: MIT License + identifier: MIT +servers: + - url: http://127.0.0.1:8080 +security: [] +tags: + - name: Analysis + description: Submit standalone analysis and version-comparison jobs. + - name: Jobs + description: Inspect, stream, cancel, delete, and retrieve job results. + - name: System + description: Inspect control-plane dependency readiness. +paths: + /api/v1/analyses: + post: + tags: [Analysis] + summary: Submit a contract for analysis + operationId: submitAnalysis + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [document] + properties: + document: + type: string + format: binary + responses: + "202": + description: Analysis accepted + content: + application/json: + schema: + $ref: "#/components/schemas/SubmissionResponse" + "400": + $ref: "#/components/responses/BadRequest" + "403": + $ref: "#/components/responses/Forbidden" + "413": + $ref: "#/components/responses/UploadTooLarge" + "415": + $ref: "#/components/responses/UnsupportedMediaType" + "429": + $ref: "#/components/responses/QueueFull" + "503": + $ref: "#/components/responses/ShuttingDown" + /api/v1/comparisons: + post: + tags: [Analysis] + summary: Submit an original and modified document for comparison + operationId: submitComparison + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [original, modified] + properties: + original: + type: string + format: binary + modified: + type: string + format: binary + responses: + "202": + description: Comparison accepted + content: + application/json: + schema: + $ref: "#/components/schemas/SubmissionResponse" + "400": + $ref: "#/components/responses/BadRequest" + "403": + $ref: "#/components/responses/Forbidden" + "413": + $ref: "#/components/responses/UploadTooLarge" + "415": + $ref: "#/components/responses/UnsupportedMediaType" + "429": + $ref: "#/components/responses/QueueFull" + "503": + $ref: "#/components/responses/ShuttingDown" + /api/v1/jobs: + get: + tags: [Jobs] + summary: List recent jobs + operationId: listJobs + parameters: + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + responses: + "200": + description: Recent jobs + content: + application/json: + schema: + type: object + required: [jobs] + properties: + jobs: + type: array + items: + $ref: "#/components/schemas/Job" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/jobs/{id}: + parameters: + - $ref: "#/components/parameters/JobID" + get: + tags: [Jobs] + summary: Get the latest job state + operationId: getJob + responses: + "200": + description: Job state + content: + application/json: + schema: + $ref: "#/components/schemas/JobResponse" + "400": + $ref: "#/components/responses/BadRequest" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [Jobs] + summary: Cancel an active job or delete a terminal job + operationId: deleteJob + responses: + "200": + description: Terminal job deleted + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteResponse" + "202": + description: Active job cancellation requested + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteResponse" + "400": + $ref: "#/components/responses/BadRequest" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + /api/v1/jobs/{id}/events: + parameters: + - $ref: "#/components/parameters/JobID" + get: + tags: [Jobs] + summary: Stream current and future job snapshots + operationId: streamJob + responses: + "200": + description: Server-sent events named `job`; each data field is a Job JSON object. + content: + text/event-stream: + schema: + type: string + "400": + $ref: "#/components/responses/BadRequest" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /api/v1/jobs/{id}/report: + parameters: + - $ref: "#/components/parameters/JobID" + get: + tags: [Jobs] + summary: Download a completed JSON report + operationId: getReport + responses: + "200": + description: Versioned analysis or comparison report + content: + application/json: + schema: + oneOf: + - $ref: "./schemas/analysis-report-v1.schema.json" + - $ref: "./schemas/comparison-report-v1.schema.json" + "400": + $ref: "#/components/responses/BadRequest" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + /api/v1/health: + get: + tags: [System] + summary: Check the job store and Python engine bridge + operationId: getHealth + responses: + "200": + description: Dependencies are ready + content: + application/json: + schema: + $ref: "#/components/schemas/HealthResponse" + "403": + $ref: "#/components/responses/Forbidden" + "503": + description: A required dependency is unavailable + content: + application/json: + schema: + $ref: "#/components/schemas/HealthResponse" +components: + parameters: + JobID: + name: id + in: path + required: true + schema: + type: string + pattern: "^[a-f0-9]{32}$" + schemas: + InputFile: + type: object + required: [name] + properties: + name: + type: string + JobError: + type: object + required: [code, message] + properties: + code: + type: string + message: + type: string + Job: + type: object + required: [id, type, status, stage, progress, message, inputs, created_at, updated_at] + properties: + id: + type: string + type: + type: string + enum: [analysis, comparison] + status: + type: string + enum: [queued, running, completed, failed, cancelled, timed_out] + stage: + type: string + progress: + type: integer + minimum: 0 + maximum: 100 + message: + type: string + inputs: + type: array + items: + $ref: "#/components/schemas/InputFile" + report_url: + type: string + error: + $ref: "#/components/schemas/JobError" + exit_code: + type: integer + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + started_at: + type: string + format: date-time + completed_at: + type: string + format: date-time + JobResponse: + type: object + required: [job] + properties: + job: + $ref: "#/components/schemas/Job" + SubmissionResponse: + allOf: + - $ref: "#/components/schemas/JobResponse" + - type: object + required: [links] + properties: + links: + type: object + required: [self, events] + properties: + self: + type: string + events: + type: string + DeleteResponse: + allOf: + - $ref: "#/components/schemas/JobResponse" + - type: object + required: [deleted] + properties: + deleted: + type: boolean + EngineHealth: + type: object + required: [ready] + properties: + ready: + type: boolean + models: + type: array + items: + type: object + additionalProperties: true + error: + type: string + HealthResponse: + type: object + required: [status, engine] + properties: + status: + type: string + enum: [ok, unavailable] + engine: + $ref: "#/components/schemas/EngineHealth" + Error: + type: object + required: [code, message] + properties: + code: + type: string + message: + type: string + ErrorResponse: + type: object + required: [error] + properties: + error: + $ref: "#/components/schemas/Error" + responses: + BadRequest: + description: Invalid request + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + NotFound: + description: Job or report not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + Conflict: + description: The current job state does not allow the operation + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + Forbidden: + description: Browser origin is not the same origin or explicitly allowed + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + UploadTooLarge: + description: Multipart request exceeds the configured limit + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + QueueFull: + description: Active job capacity has been reached; Retry-After is provided + headers: + Retry-After: + schema: + type: integer + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + ShuttingDown: + description: The server is draining and is not accepting new jobs + headers: + Retry-After: + schema: + type: integer + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + UnsupportedMediaType: + description: Upload is not multipart or uses an unsupported file type + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" From cf710c932698f8b572a50c1f00c176cf3ff1f0b1 Mon Sep 17 00:00:00 2001 From: arpitJ-dev Date: Tue, 11 Aug 2026 14:21:29 -0700 Subject: [PATCH 3/7] feat(web): add contract review workbench --- .gitignore | 7 + apps/web/eslint.config.js | 30 + apps/web/index.html | 17 + apps/web/package-lock.json | 4176 +++++++++++++++++ apps/web/package.json | 36 + apps/web/src/App.test.tsx | 90 + apps/web/src/App.tsx | 325 ++ apps/web/src/api/client.test.ts | 117 + apps/web/src/api/client.ts | 112 + apps/web/src/api/types.ts | 197 + .../components/AnalysisReportView.test.tsx | 58 + .../web/src/components/AnalysisReportView.tsx | 329 ++ .../components/ComparisonReportView.test.tsx | 23 + .../src/components/ComparisonReportView.tsx | 115 + apps/web/src/components/FileDropzone.test.tsx | 53 + apps/web/src/components/FileDropzone.tsx | 109 + apps/web/src/components/JobHistory.tsx | 84 + apps/web/src/components/JobProgress.tsx | 43 + .../src/components/SubmissionPanel.test.tsx | 94 + apps/web/src/components/SubmissionPanel.tsx | 118 + apps/web/src/hooks/useJobStream.test.ts | 152 + apps/web/src/hooks/useJobStream.ts | 88 + apps/web/src/main.tsx | 11 + apps/web/src/styles.css | 1818 +++++++ apps/web/src/test/fixtures.ts | 156 + apps/web/src/test/setup.ts | 5 + apps/web/src/vite-env.d.ts | 1 + apps/web/tsconfig.app.json | 24 + apps/web/tsconfig.json | 7 + apps/web/tsconfig.node.json | 16 + apps/web/vite.config.ts | 39 + 31 files changed, 8450 insertions(+) create mode 100644 apps/web/eslint.config.js create mode 100644 apps/web/index.html create mode 100644 apps/web/package-lock.json create mode 100644 apps/web/package.json create mode 100644 apps/web/src/App.test.tsx create mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/api/client.test.ts create mode 100644 apps/web/src/api/client.ts create mode 100644 apps/web/src/api/types.ts create mode 100644 apps/web/src/components/AnalysisReportView.test.tsx create mode 100644 apps/web/src/components/AnalysisReportView.tsx create mode 100644 apps/web/src/components/ComparisonReportView.test.tsx create mode 100644 apps/web/src/components/ComparisonReportView.tsx create mode 100644 apps/web/src/components/FileDropzone.test.tsx create mode 100644 apps/web/src/components/FileDropzone.tsx create mode 100644 apps/web/src/components/JobHistory.tsx create mode 100644 apps/web/src/components/JobProgress.tsx create mode 100644 apps/web/src/components/SubmissionPanel.test.tsx create mode 100644 apps/web/src/components/SubmissionPanel.tsx create mode 100644 apps/web/src/hooks/useJobStream.test.ts create mode 100644 apps/web/src/hooks/useJobStream.ts create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/styles.css create mode 100644 apps/web/src/test/fixtures.ts create mode 100644 apps/web/src/test/setup.ts create mode 100644 apps/web/src/vite-env.d.ts create mode 100644 apps/web/tsconfig.app.json create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/tsconfig.node.json create mode 100644 apps/web/vite.config.ts diff --git a/.gitignore b/.gitignore index 1f96e90..677989d 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,10 @@ process.ipynb /apps/server/clauseguard-server /apps/server/clauseguard-server.exe /apps/server/coverage.out + +# Web workbench +/apps/web/node_modules/ +/apps/web/dist/ +/apps/web/coverage/ +/apps/web/playwright-report/ +/apps/web/test-results/ diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js new file mode 100644 index 0000000..7141de7 --- /dev/null +++ b/apps/web/eslint.config.js @@ -0,0 +1,30 @@ +import js from "@eslint/js"; +import reactHooks from "eslint-plugin-react-hooks"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist", "coverage"] }, + js.configs.recommended, + ...tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: 2023, + globals: { + document: "readonly", + window: "readonly", + fetch: "readonly", + FormData: "readonly", + File: "readonly", + EventSource: "readonly", + URL: "readonly", + Blob: "readonly", + setInterval: "readonly", + clearInterval: "readonly", + setTimeout: "readonly", + clearTimeout: "readonly", + }, + }, + }, +); diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..d321617 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,17 @@ + + + + + + + + ClauseGuard Agent + + +
+ + + diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json new file mode 100644 index 0000000..1cab415 --- /dev/null +++ b/apps/web/package-lock.json @@ -0,0 +1,4176 @@ +{ + "name": "@clauseguard/web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@clauseguard/web", + "version": "1.0.0", + "dependencies": { + "lucide-react": "1.31.0", + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@eslint/js": "9.39.2", + "@testing-library/jest-dom": "7.0.1", + "@testing-library/react": "16.3.2", + "@testing-library/user-event": "14.6.3", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.4", + "@vitejs/plugin-react": "6.0.5", + "@vitest/coverage-v8": "4.1.10", + "eslint": "9.39.2", + "eslint-plugin-react-hooks": "7.1.1", + "jsdom": "29.1.1", + "typescript": "6.0.3", + "typescript-eslint": "8.67.0", + "vite": "8.2.1", + "vitest": "4.1.10" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.3.tgz", + "integrity": "sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.67.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.404", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.404.tgz", + "integrity": "sha512-3WJtd7/lVq2Jnuz6wed1l9+1ZD2u2Tet1/1NBc4Iedkmgbu+I7YuAqdAQ8T+VZtnwysMsAf3IqSq9D1gyZjA2g==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz", + "integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..f6e5ed2 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,36 @@ +{ + "name": "@clauseguard/web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint . --max-warnings 0", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "1.31.0", + "react": "19.2.8", + "react-dom": "19.2.8" + }, + "devDependencies": { + "@eslint/js": "9.39.2", + "@testing-library/jest-dom": "7.0.1", + "@testing-library/react": "16.3.2", + "@testing-library/user-event": "14.6.3", + "@types/react": "19.2.18", + "@types/react-dom": "19.2.4", + "@vitejs/plugin-react": "6.0.5", + "@vitest/coverage-v8": "4.1.10", + "eslint": "9.39.2", + "eslint-plugin-react-hooks": "7.1.1", + "jsdom": "29.1.1", + "typescript": "6.0.3", + "typescript-eslint": "8.67.0", + "vite": "8.2.1", + "vitest": "4.1.10" + } +} diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx new file mode 100644 index 0000000..0c7e92d --- /dev/null +++ b/apps/web/src/App.test.tsx @@ -0,0 +1,90 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { analysisReport, completedAnalysisJob } from "./test/fixtures"; + +const apiMocks = vi.hoisted(() => ({ + getHealth: vi.fn(), + listJobs: vi.fn(), + getReport: vi.fn(), + submitAnalysis: vi.fn(), + submitComparison: vi.fn(), + deleteJob: vi.fn(), +})); + +vi.mock("./api/client", async () => { + const actual = await vi.importActual("./api/client"); + return { ...actual, ...apiMocks }; +}); + +vi.mock("./hooks/useJobStream", () => ({ useJobStream: vi.fn() })); + +import App from "./App"; + +beforeEach(() => { + vi.clearAllMocks(); + apiMocks.getHealth.mockResolvedValue({ + status: "ok", + engine: { ready: true, models: [{ role: "extraction" }, { role: "reasoning" }] }, + }); + apiMocks.listJobs.mockResolvedValue([completedAnalysisJob]); + apiMocks.getReport.mockResolvedValue(analysisReport); +}); + +describe("App", () => { + it("loads persisted jobs and opens a completed report", async () => { + const user = userEvent.setup(); + render(); + + expect(await screen.findByText("Engine ready")).toBeVisible(); + const jobLabel = await screen.findByText("services-agreement.txt"); + await user.click(jobLabel.closest("button")!); + + expect(await screen.findByRole("heading", { name: "SERVICES AGREEMENT" })).toBeVisible(); + expect(apiMocks.getReport).toHaveBeenCalledWith(completedAnalysisJob.id); + }); + + it("submits a document and displays active job progress", async () => { + const user = userEvent.setup(); + const runningJob = { + ...completedAnalysisJob, + id: "11111111111111111111111111111111", + status: "running" as const, + stage: "checking", + progress: 62, + message: "Checking candidate findings", + report_url: undefined, + }; + apiMocks.listJobs.mockResolvedValue([]); + apiMocks.submitAnalysis.mockResolvedValue({ + job: runningJob, + links: { self: "/job", events: "/events" }, + }); + render(); + + await screen.findByText("Engine ready"); + await user.upload( + screen.getByLabelText("Contract"), + new File(["Agreement"], "new-agreement.txt", { type: "text/plain" }), + ); + await user.click(screen.getByRole("button", { name: "Run analysis" })); + + expect(await screen.findByRole("heading", { name: "Checking" })).toBeVisible(); + expect(screen.getByText("62%")).toBeVisible(); + expect(apiMocks.submitAnalysis).toHaveBeenCalledOnce(); + }); + + it("deletes a terminal review after confirmation", async () => { + const user = userEvent.setup(); + apiMocks.deleteJob.mockResolvedValue({ job: completedAnalysisJob, deleted: true }); + render(); + + await screen.findByText("services-agreement.txt"); + await user.click(screen.getByRole("button", { name: "Delete services-agreement.txt" })); + expect(screen.getByRole("dialog", { name: "Delete review?" })).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Delete" })); + + await waitFor(() => expect(screen.queryByText("services-agreement.txt")).not.toBeInTheDocument()); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..b990f05 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,325 @@ +import { + AlertCircle, + Menu, + RefreshCw, + ShieldCheck, + X, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { + ApiError, + deleteJob, + getHealth, + getReport, + listJobs, + submitAnalysis, + submitComparison, +} from "./api/client"; +import type { HealthResponse, Job, JobType, Report } from "./api/types"; +import { isAnalysisReport, isTerminalStatus } from "./api/types"; +import { AnalysisReportView } from "./components/AnalysisReportView"; +import { ComparisonReportView } from "./components/ComparisonReportView"; +import { JobHistory } from "./components/JobHistory"; +import { JobProgress } from "./components/JobProgress"; +import { SubmissionPanel } from "./components/SubmissionPanel"; +import { useJobStream } from "./hooks/useJobStream"; + +function errorMessage(cause: unknown): string { + if (cause instanceof ApiError && cause.retryAfter) { + return `${cause.message} Retry in ${cause.retryAfter} seconds.`; + } + return cause instanceof Error ? cause.message : "An unexpected error occurred."; +} + +function upsertJob(jobs: Job[], job: Job): Job[] { + const remaining = jobs.filter((candidate) => candidate.id !== job.id); + return [job, ...remaining].sort( + (left, right) => Date.parse(right.created_at) - Date.parse(left.created_at), + ); +} + +export default function App() { + const [mode, setMode] = useState("analysis"); + const [health, setHealth] = useState(null); + const [jobs, setJobs] = useState([]); + const [selectedJob, setSelectedJob] = useState(null); + const [report, setReport] = useState(null); + const [loadingJobs, setLoadingJobs] = useState(true); + const [loadingReport, setLoadingReport] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [streamConnected, setStreamConnected] = useState(false); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [error, setError] = useState(null); + const [deleteCandidate, setDeleteCandidate] = useState(null); + + const engineReady = health?.status === "ok" && health.engine.ready; + const selectedIsActive = Boolean(selectedJob && !isTerminalStatus(selectedJob.status)); + + const refreshHealth = useCallback(async () => { + try { + setHealth(await getHealth()); + } catch { + setHealth({ status: "unavailable", engine: { ready: false } }); + } + }, []); + + const refreshJobs = useCallback(async () => { + setLoadingJobs(true); + try { + const nextJobs = await listJobs(); + setJobs(nextJobs); + setSelectedJob((current) => + current ? (nextJobs.find((job) => job.id === current.id) ?? current) : current, + ); + } catch (cause) { + setError(errorMessage(cause)); + } finally { + setLoadingJobs(false); + } + }, []); + + const loadReport = useCallback(async (job: Job) => { + if (job.status !== "completed") return; + setLoadingReport(true); + try { + setReport(await getReport(job.id)); + } catch (cause) { + setError(errorMessage(cause)); + setReport(null); + } finally { + setLoadingReport(false); + } + }, []); + + useEffect(() => { + const initialTimer = window.setTimeout(() => { + void refreshHealth(); + void refreshJobs(); + }, 0); + const healthTimer = window.setInterval(() => void refreshHealth(), 30000); + const jobsTimer = window.setInterval(() => void refreshJobs(), 15000); + return () => { + window.clearTimeout(initialTimer); + window.clearInterval(healthTimer); + window.clearInterval(jobsTimer); + }; + }, [refreshHealth, refreshJobs]); + + const handleJobUpdate = useCallback( + (job: Job) => { + setJobs((current) => upsertJob(current, job)); + setSelectedJob((current) => (current?.id === job.id ? job : current)); + if (job.status === "completed") void loadReport(job); + if (job.error) setError(job.error.message); + }, + [loadReport], + ); + + const handleConnectionChange = useCallback((connected: boolean) => { + setStreamConnected(connected); + }, []); + + useJobStream({ + jobId: selectedJob?.id ?? null, + active: selectedIsActive, + onJob: handleJobUpdate, + onConnectionChange: handleConnectionChange, + }); + + const selectJob = useCallback( + (job: Job) => { + setSelectedJob(job); + setReport(null); + setError(job.error?.message ?? null); + setSidebarOpen(false); + if (job.status === "completed") void loadReport(job); + }, + [loadReport], + ); + + const acceptSubmission = (job: Job) => { + setJobs((current) => upsertJob(current, job)); + setSelectedJob(job); + setReport(null); + setError(null); + setSidebarOpen(false); + }; + + const analyze = async (file: File) => { + setSubmitting(true); + try { + acceptSubmission((await submitAnalysis(file)).job); + } finally { + setSubmitting(false); + } + }; + + const compare = async (original: File, modified: File) => { + setSubmitting(true); + try { + acceptSubmission((await submitComparison(original, modified)).job); + } finally { + setSubmitting(false); + } + }; + + const cancelSelectedJob = async () => { + if (!selectedJob) return; + try { + handleJobUpdate((await deleteJob(selectedJob.id)).job); + } catch (cause) { + setError(errorMessage(cause)); + } + }; + + const confirmDelete = async () => { + if (!deleteCandidate) return; + try { + const result = await deleteJob(deleteCandidate.id); + if (result.deleted) { + setJobs((current) => current.filter((job) => job.id !== deleteCandidate.id)); + if (selectedJob?.id === deleteCandidate.id) { + setSelectedJob(null); + setReport(null); + } + } + } catch (cause) { + setError(errorMessage(cause)); + } finally { + setDeleteCandidate(null); + } + }; + + const downloadReport = () => { + if (!report || !selectedJob) return; + const blob = new Blob([JSON.stringify(report, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `${selectedJob.type}-${selectedJob.id}.json`; + anchor.click(); + URL.revokeObjectURL(url); + }; + + const activeModelCount = health?.engine.models?.length ?? 0; + const workspaceState = useMemo(() => { + if (!selectedJob) return "empty"; + if (selectedIsActive) return "active"; + if (selectedJob.status === "completed") return "complete"; + return "failed"; + }, [selectedIsActive, selectedJob]); + + return ( +
+
+
+ + +
ClauseGuard AgentContract review
+
+ +
+ +
+ + {sidebarOpen ? +
+ ) : null} + + {workspaceState === "empty" ? ( +
+ +

Review workspace

+

No report selected

+
+ ) : null} + + {workspaceState === "active" && selectedJob ? ( + void cancelSelectedJob()} /> + ) : null} + + {workspaceState === "failed" && selectedJob ? ( +
+ +

{selectedJob.status === "cancelled" ? "Review cancelled" : "Review did not complete"}

+

{selectedJob.error?.message ?? selectedJob.message}

+
+ ) : null} + + {workspaceState === "complete" && loadingReport ? ( +
+ +

Loading report

+
+ ) : null} + + {workspaceState === "complete" && report ? ( + isAnalysisReport(report) ? ( + + ) : ( + + ) + ) : null} + +
+ + {deleteCandidate ? ( +
+
+

Delete review?

+

{deleteCandidate.inputs.map((input) => input.name).join(" / ")}

+
+ + +
+
+
+ ) : null} + + ); +} diff --git a/apps/web/src/api/client.test.ts b/apps/web/src/api/client.test.ts new file mode 100644 index 0000000..6262183 --- /dev/null +++ b/apps/web/src/api/client.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + deleteJob, + getHealth, + getJob, + getReport, + jobEventsURL, + listJobs, + submitAnalysis, + submitComparison, +} from "./client"; +import { completedAnalysisJob } from "../test/fixtures"; +import { analysisReport } from "../test/fixtures"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("API client", () => { + it("lists jobs", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ jobs: [completedAnalysisJob] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + await expect(listJobs()).resolves.toEqual([completedAnalysisJob]); + }); + + it("preserves a structured API error and retry delay", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: { code: "queue_full", message: "Queue full" } }), { + status: 429, + headers: { "Retry-After": "8" }, + }), + ), + ); + + await expect(listJobs()).rejects.toMatchObject({ + code: "queue_full", + status: 429, + retryAfter: 8, + }); + }); + + it("returns dependency health for a 503 response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ status: "unavailable", engine: { ready: false } }), { + status: 503, + }), + ), + ); + + await expect(getHealth()).resolves.toEqual({ status: "unavailable", engine: { ready: false } }); + }); + + it("submits an analysis as multipart form data", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + job: completedAnalysisJob, + links: { self: "/job", events: "/events" }, + }), + { status: 202 }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + const file = new File(["Agreement"], "agreement.txt", { type: "text/plain" }); + + await submitAnalysis(file); + + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(init.method).toBe("POST"); + expect(init.body).toBeInstanceOf(FormData); + expect((init.body as FormData).get("document")).toBe(file); + }); + + it("reads job state and reports", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ job: completedAnalysisJob }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(analysisReport), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await expect(getJob(completedAnalysisJob.id)).resolves.toEqual(completedAnalysisJob); + await expect(getReport(completedAnalysisJob.id)).resolves.toEqual(analysisReport); + expect(jobEventsURL(completedAnalysisJob.id)).toContain(`${completedAnalysisJob.id}/events`); + }); + + it("submits comparisons and deletes jobs", async () => { + const response = { job: completedAnalysisJob, links: { self: "/job", events: "/events" } }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify(response), { status: 202 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ job: completedAnalysisJob, deleted: true }), { status: 200 }), + ); + vi.stubGlobal("fetch", fetchMock); + const original = new File(["before"], "before.txt"); + const modified = new File(["after"], "after.txt"); + + await submitComparison(original, modified); + const comparisonBody = fetchMock.mock.calls[0]?.[1]?.body as FormData; + expect(comparisonBody.get("original")).toBe(original); + expect(comparisonBody.get("modified")).toBe(modified); + await expect(deleteJob(completedAnalysisJob.id)).resolves.toMatchObject({ deleted: true }); + }); +}); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts new file mode 100644 index 0000000..0def5e3 --- /dev/null +++ b/apps/web/src/api/client.ts @@ -0,0 +1,112 @@ +import type { + DeleteResponse, + HealthResponse, + Job, + Report, + SubmissionResponse, +} from "./types"; + +const configuredBase = import.meta.env.VITE_API_BASE_URL?.trim() ?? ""; +export const apiBase = configuredBase.replace(/\/$/, ""); + +interface ErrorEnvelope { + error?: { + code?: string; + message?: string; + }; +} + +export class ApiError extends Error { + readonly status: number; + readonly code: string; + readonly retryAfter?: number; + + constructor(status: number, code: string, message: string, retryAfter?: number) { + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; + this.retryAfter = retryAfter; + } +} + +async function parseJSON(response: Response): Promise { + let payload: T | ErrorEnvelope; + try { + payload = (await response.json()) as T | ErrorEnvelope; + } catch { + throw new ApiError(response.status, "invalid_response", "The server returned invalid JSON."); + } + if (!response.ok) { + const envelope = payload as ErrorEnvelope; + const retryHeader = response.headers.get("Retry-After"); + throw new ApiError( + response.status, + envelope.error?.code ?? "request_failed", + envelope.error?.message ?? `Request failed with status ${response.status}.`, + retryHeader ? Number.parseInt(retryHeader, 10) : undefined, + ); + } + return payload as T; +} + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`${apiBase}${path}`, { + ...init, + headers: { + Accept: "application/json", + ...init?.headers, + }, + }); + return parseJSON(response); +} + +export async function getHealth(): Promise { + const response = await fetch(`${apiBase}/api/v1/health`, { + headers: { Accept: "application/json" }, + }); + if (response.status === 503) { + return (await response.json()) as HealthResponse; + } + return parseJSON(response); +} + +export async function listJobs(limit = 50): Promise { + const payload = await request<{ jobs: Job[] }>(`/api/v1/jobs?limit=${limit}`); + return payload.jobs; +} + +export async function getJob(id: string): Promise { + const payload = await request<{ job: Job }>(`/api/v1/jobs/${encodeURIComponent(id)}`); + return payload.job; +} + +export async function getReport(id: string): Promise { + return request(`/api/v1/jobs/${encodeURIComponent(id)}/report`); +} + +export async function submitAnalysis(file: File): Promise { + const body = new FormData(); + body.append("document", file); + return request("/api/v1/analyses", { method: "POST", body }); +} + +export async function submitComparison( + original: File, + modified: File, +): Promise { + const body = new FormData(); + body.append("original", original); + body.append("modified", modified); + return request("/api/v1/comparisons", { method: "POST", body }); +} + +export async function deleteJob(id: string): Promise { + return request(`/api/v1/jobs/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +} + +export function jobEventsURL(id: string): string { + return `${apiBase}/api/v1/jobs/${encodeURIComponent(id)}/events`; +} diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts new file mode 100644 index 0000000..b106ce0 --- /dev/null +++ b/apps/web/src/api/types.ts @@ -0,0 +1,197 @@ +export type JobType = "analysis" | "comparison"; +export type JobStatus = + | "queued" + | "running" + | "completed" + | "failed" + | "cancelled" + | "timed_out"; +export type Severity = "LOW" | "MEDIUM" | "HIGH"; + +export interface InputFile { + name: string; +} + +export interface JobError { + code: string; + message: string; +} + +export interface Job { + id: string; + type: JobType; + status: JobStatus; + stage: string; + progress: number; + message: string; + inputs: InputFile[]; + report_url?: string; + error?: JobError; + exit_code?: number; + created_at: string; + updated_at: string; + started_at?: string; + completed_at?: string; +} + +export interface SubmissionResponse { + job: Job; + links: { + self: string; + events: string; + }; +} + +export interface DeleteResponse { + job: Job; + deleted: boolean; +} + +export interface ModelRole { + role?: string; + model?: string; + provider?: string; + purpose?: string; + free_tier_only?: boolean; + [key: string]: unknown; +} + +export interface HealthResponse { + status: "ok" | "unavailable"; + engine: { + ready: boolean; + models?: ModelRole[]; + error?: string; + }; +} + +export interface Clause { + id: string; + order: number; + title: string; + text: string; + category?: string; + risk_terms?: string[]; +} + +export interface Evidence { + id: string; + source: string; + title: string; + text: string; + relevance: number; + clause_id?: string | null; +} + +export interface ComponentScores { + deterministic_rules: number; + rag_evidence: number; + primary_reasoning: number; + verifier_agreement: number; + clause_structure: number; + final: number; + weights: Record; +} + +export interface Finding { + id: string; + issue_type: string; + severity: Severity; + clause_id?: string | null; + clause_title?: string | null; + explanation: string; + rule_id?: string; + signals?: string[]; + evidence?: Evidence[]; + component_scores: ComponentScores; + accepted: boolean; + acceptance_threshold?: number; + model_confidence: number; + verifier_confidence: number; + verifier_status?: "verified" | "not_run" | "unavailable"; + verifier_rationale?: string; + suggested_rewrite?: string | null; +} + +export interface LegalEntity { + text: string; + label: string; + source?: string; +} + +export interface Rewrite { + clause_id: string; + original_text: string; + rewritten_text: string; + rationale: string; +} + +export interface AnalysisReport { + schema_version: "1.0"; + document_id: string; + file_path: string; + title: string; + document_type: string; + summary: string; + clauses: Clause[]; + entities: LegalEntity[]; + evidence: Evidence[]; + findings: Finding[]; + rewrites: Rewrite[]; + generated_at?: string; + limitations?: string[]; +} + +export type ClauseDeltaStatus = "unchanged" | "changed" | "added" | "removed"; + +export interface ClauseDelta { + status: ClauseDeltaStatus; + similarity: number; + original_clause_id?: string | null; + original_title?: string | null; + original_category?: string | null; + original_preview?: string; + original_risk_terms?: string[]; + modified_clause_id?: string | null; + modified_title?: string | null; + modified_category?: string | null; + modified_preview?: string; + modified_risk_terms?: string[]; +} + +export interface ComparisonRiskSignal { + type: string; + clause: string; + detail: string; + severity: Severity; +} + +export interface ComparisonReport { + schema_version: "1.0"; + comparison_id: string; + original_document: string; + modified_document: string; + original_type: string; + modified_type: string; + original_clause_count: number; + modified_clause_count: number; + summary: { + matched: number; + changed: number; + added: number; + removed: number; + }; + clause_deltas: ClauseDelta[]; + risk_signals: ComparisonRiskSignal[]; + notes?: string[]; +} + +export type Report = AnalysisReport | ComparisonReport; + +export function isAnalysisReport(report: Report): report is AnalysisReport { + return "document_id" in report; +} + +export function isTerminalStatus(status: JobStatus): boolean { + return ["completed", "failed", "cancelled", "timed_out"].includes(status); +} diff --git a/apps/web/src/components/AnalysisReportView.test.tsx b/apps/web/src/components/AnalysisReportView.test.tsx new file mode 100644 index 0000000..4951b31 --- /dev/null +++ b/apps/web/src/components/AnalysisReportView.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { analysisReport } from "../test/fixtures"; +import { AnalysisReportView } from "./AnalysisReportView"; + +describe("AnalysisReportView", () => { + it("shows findings and their evidence", () => { + render(); + + expect(screen.getByRole("heading", { name: "SERVICES AGREEMENT" })).toBeVisible(); + expect(screen.getByText("Balanced Discretion Checklist")).toBeVisible(); + expect(screen.getByText("84%")).toBeVisible(); + }); + + it("filters by severity and opens report tabs", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Medium" })); + expect(screen.getByText("Payment Ambiguity")).toBeVisible(); + expect(screen.queryByText("Risky Language")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Rewrites" })); + expect(screen.getByText("Adds objective and mutual safeguards.")).toBeVisible(); + }); + + it("navigates clauses and audit details and downloads the report", async () => { + const user = userEvent.setup(); + const onDownload = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: "JSON" })); + expect(onDownload).toHaveBeenCalledOnce(); + + await user.click(screen.getByRole("button", { name: "Clauses" })); + expect(screen.getByRole("heading", { name: "Extracted clauses" })).toBeVisible(); + expect(screen.getByText("Provider may terminate in its sole discretion.")).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "Audit" })); + expect(screen.getByText(analysisReport.document_id)).toBeVisible(); + expect(screen.getByText("Findings require qualified legal review.")).toBeVisible(); + }); + + it("collapses an expanded finding and reports an empty filter", async () => { + const user = userEvent.setup(); + render(); + + const finding = screen.getByRole("button", { name: /Risky Language/i }); + expect(finding).toHaveAttribute("aria-expanded", "true"); + await user.click(finding); + expect(finding).toHaveAttribute("aria-expanded", "false"); + + await user.click(screen.getByRole("button", { name: "Low" })); + expect(screen.getByText("No matching findings")).toBeVisible(); + }); +}); diff --git a/apps/web/src/components/AnalysisReportView.tsx b/apps/web/src/components/AnalysisReportView.tsx new file mode 100644 index 0000000..1f8724c --- /dev/null +++ b/apps/web/src/components/AnalysisReportView.tsx @@ -0,0 +1,329 @@ +import { + AlertTriangle, + ChevronDown, + ChevronUp, + Download, + FileText, + Info, + ListChecks, + Scale, + ShieldAlert, + WandSparkles, +} from "lucide-react"; +import { useMemo, useState } from "react"; + +import type { AnalysisReport, Finding, Severity } from "../api/types"; +import { formatScore, humanize } from "../lib/format"; + +type AnalysisTab = "findings" | "clauses" | "rewrites" | "audit"; +type SeverityFilter = "ALL" | Severity; + +interface AnalysisReportViewProps { + report: AnalysisReport; + onDownload: () => void; +} + +const scoreRows: Array<[keyof Finding["component_scores"], string]> = [ + ["deterministic_rules", "Rule confidence"], + ["rag_evidence", "Evidence relevance"], + ["primary_reasoning", "Reasoning confidence"], + ["verifier_agreement", "Verifier agreement"], + ["clause_structure", "Document consistency"], +]; + +function FindingCard({ + finding, + expanded, + onToggle, +}: { + finding: Finding; + expanded: boolean; + onToggle: () => void; +}) { + return ( +
+ + + {expanded ? ( +
+

{finding.explanation}

+
+
+
Rule
+
{finding.rule_id || "Model-assisted review"}
+
+
+
Threshold
+
{formatScore(finding.acceptance_threshold ?? 0.55)}
+
+
+
Verifier
+
{humanize(finding.verifier_status ?? "not_run")}
+
+
+ + {finding.signals?.length ? ( +
+ {finding.signals.map((signal) => ( + {signal} + ))} +
+ ) : null} + +
+
+

Score breakdown

+ {scoreRows.map(([key, label]) => { + const value = finding.component_scores[key]; + if (typeof value !== "number") return null; + return ( +
+ {label} + + + + {formatScore(value)} +
+ ); + })} +
+ +
+

Supporting evidence

+ {finding.evidence?.length ? ( +
    + {finding.evidence.map((evidence) => ( +
  • + + {evidence.title} + {evidence.text} + + {formatScore(evidence.relevance)} +
  • + ))} +
+ ) : ( +

No evidence attached

+ )} +
+
+ + {finding.verifier_rationale ? ( +
+ + {finding.verifier_rationale} +
+ ) : null} + + {finding.suggested_rewrite ? ( +
+

+ + Suggested revision +

+

{finding.suggested_rewrite}

+
+ ) : null} +
+ ) : null} +
+ ); +} + +export function AnalysisReportView({ report, onDownload }: AnalysisReportViewProps) { + const [tab, setTab] = useState("findings"); + const [severity, setSeverity] = useState("ALL"); + const [expandedFinding, setExpandedFinding] = useState(report.findings[0]?.id ?? null); + + const acceptedFindings = useMemo( + () => report.findings.filter((finding) => finding.accepted), + [report.findings], + ); + const visibleFindings = useMemo( + () => + acceptedFindings.filter((finding) => severity === "ALL" || finding.severity === severity), + [acceptedFindings, severity], + ); + const highRiskCount = acceptedFindings.filter((finding) => finding.severity === "HIGH").length; + + return ( +
+
+
+ + + +
+

{report.document_type}

+

{report.title}

+ {report.file_path} +
+
+ +
+ +
+

{report.summary}

+
+
+ + {highRiskCount} + High risk +
+
+ + {acceptedFindings.length} + Findings +
+
+ + {report.clauses.length} + Clauses +
+
+ + {report.rewrites.length} + Rewrites +
+
+
+ + + +
+ {tab === "findings" ? ( + <> +
+

Accepted findings

+
+ {(["ALL", "HIGH", "MEDIUM", "LOW"] as SeverityFilter[]).map((level) => ( + + ))} +
+
+
+ {visibleFindings.map((finding) => ( + + setExpandedFinding((current) => (current === finding.id ? null : finding.id)) + } + /> + ))} + {visibleFindings.length === 0 ?

No matching findings

: null} +
+ + ) : null} + + {tab === "clauses" ? ( +
+

Extracted clauses

+
+ {report.clauses.map((clause) => ( +
+ {clause.order} +
+

{clause.category || "General"}

+

{clause.title}

+
{clause.text}
+ {clause.risk_terms?.length ? ( +
+ {clause.risk_terms.map((term) => {term})} +
+ ) : null} +
+
+ ))} +
+
+ ) : null} + + {tab === "rewrites" ? ( +
+

Suggested rewrites

+
+ {report.rewrites.map((rewrite) => ( +
+
+ +

{report.clauses.find((clause) => clause.id === rewrite.clause_id)?.title ?? rewrite.clause_id}

+
+
+
Original

{rewrite.original_text}

+
Suggested

{rewrite.rewritten_text}

+
+ {rewrite.rationale} +
+ ))} + {report.rewrites.length === 0 ?

No rewrites proposed

: null} +
+
+ ) : null} + + {tab === "audit" ? ( +
+

Analysis record

+
+
Document ID
{report.document_id}
+
Schema
{report.schema_version}
+
Evidence records
{report.evidence.length}
+
Entities
{report.entities.length}
+
+ {report.entities.length ? ( +
+ {report.entities.map((entity, index) => ( +
+ {entity.label}{entity.text}{entity.source ?? "heuristic"} +
+ ))} +
+ ) : null} + {report.limitations?.length ? ( +
+

Review limitations

+
    {report.limitations.map((item) =>
  • {item}
  • )}
+
+ ) : null} +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/ComparisonReportView.test.tsx b/apps/web/src/components/ComparisonReportView.test.tsx new file mode 100644 index 0000000..0d4bb37 --- /dev/null +++ b/apps/web/src/components/ComparisonReportView.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { comparisonReport } from "../test/fixtures"; +import { ComparisonReportView } from "./ComparisonReportView"; + +describe("ComparisonReportView", () => { + it("shows risk signals and version deltas", () => { + render(); + expect(screen.getByText("New Risk Term")).toBeVisible(); + expect(screen.getByText("Payment is due immediately on demand.")).toBeVisible(); + }); + + it("filters clause changes", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Unchanged" })); + expect(screen.getByText("Notices")).toBeVisible(); + expect(screen.queryByText("Payment is due immediately on demand.")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/ComparisonReportView.tsx b/apps/web/src/components/ComparisonReportView.tsx new file mode 100644 index 0000000..d3d489a --- /dev/null +++ b/apps/web/src/components/ComparisonReportView.tsx @@ -0,0 +1,115 @@ +import { AlertTriangle, ArrowRight, Download, GitCompareArrows } from "lucide-react"; +import { useMemo, useState } from "react"; + +import type { ClauseDeltaStatus, ComparisonReport } from "../api/types"; +import { formatScore, humanize } from "../lib/format"; + +type DeltaFilter = "all" | ClauseDeltaStatus; + +interface ComparisonReportViewProps { + report: ComparisonReport; + onDownload: () => void; +} + +export function ComparisonReportView({ report, onDownload }: ComparisonReportViewProps) { + const [filter, setFilter] = useState("all"); + const visibleDeltas = useMemo( + () => report.clause_deltas.filter((delta) => filter === "all" || delta.status === filter), + [filter, report.clause_deltas], + ); + + return ( +
+
+
+ +
+

Version comparison

+

{report.original_type || "Contract"}

+ + {report.original_document} {report.modified_document} + +
+
+ +
+ +
+
+
{report.summary.matched}Matched
+
{report.summary.changed}Changed
+
{report.summary.added}Added
+
{report.summary.removed}Removed
+
{report.risk_signals.length}Risk signals
+
+
+ + {report.risk_signals.length ? ( +
+

Risk signals

+
+ {report.risk_signals.map((signal, index) => ( +
+ {signal.severity} + +
{humanize(signal.type)}

{signal.detail}

{signal.clause}
+
+ ))} +
+
+ ) : null} + +
+
+

Clause changes

+
+ {(["all", "changed", "added", "removed", "unchanged"] as DeltaFilter[]).map((status) => ( + + ))} +
+
+
+ {visibleDeltas.map((delta, index) => ( +
+
+ {humanize(delta.status)} + {delta.modified_title ?? delta.original_title ?? "Untitled clause"} + {formatScore(delta.similarity)} similarity +
+
+
+ Original +

{delta.original_preview || "Not present"}

+ {delta.original_risk_terms?.length ? Risk terms: {delta.original_risk_terms.join(", ")} : null} +
+
+ Modified +

{delta.modified_preview || "Not present"}

+ {delta.modified_risk_terms?.length ? Risk terms: {delta.modified_risk_terms.join(", ")} : null} +
+
+
+ ))} + {visibleDeltas.length === 0 ?

No matching clause changes

: null} +
+
+ + {report.notes?.length ? ( +
+

Review notes

+
    {report.notes.map((note) =>
  • {note}
  • )}
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/FileDropzone.test.tsx b/apps/web/src/components/FileDropzone.test.tsx new file mode 100644 index 0000000..9166a3c --- /dev/null +++ b/apps/web/src/components/FileDropzone.test.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { FileDropzone } from "./FileDropzone"; + +describe("FileDropzone", () => { + it("accepts a supported document and can clear it", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const { rerender } = render( + , + ); + const file = new File(["contract"], "agreement.txt", { type: "text/plain" }); + + await user.upload(screen.getByLabelText("Contract"), file); + expect(onChange).toHaveBeenCalledWith(file); + + rerender(); + expect(screen.getByText("agreement.txt")).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Remove agreement.txt" })); + expect(onChange).toHaveBeenLastCalledWith(null); + }); + + it("rejects an unsupported extension", () => { + const onChange = vi.fn(); + render(); + + const file = new File(["data"], "agreement.csv", { type: "text/csv" }); + fireEvent.drop(screen.getByRole("button", { name: /choose or drop a document/i }), { + dataTransfer: { files: [file] }, + }); + + expect(screen.getByText("Choose a TXT, DOCX, or PDF file.")).toBeVisible(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("tracks drag state and accepts a dropped file", () => { + const onChange = vi.fn(); + render(); + const target = screen.getByRole("button", { name: /choose or drop a document/i }); + const file = new File(["terms"], "terms.pdf", { type: "application/pdf" }); + + fireEvent.dragEnter(target); + expect(target).toHaveClass("is-dragging"); + fireEvent.dragOver(target); + fireEvent.dragLeave(target); + expect(target).not.toHaveClass("is-dragging"); + + fireEvent.drop(target, { dataTransfer: { files: [file] } }); + expect(onChange).toHaveBeenCalledWith(file); + }); +}); diff --git a/apps/web/src/components/FileDropzone.tsx b/apps/web/src/components/FileDropzone.tsx new file mode 100644 index 0000000..016f06c --- /dev/null +++ b/apps/web/src/components/FileDropzone.tsx @@ -0,0 +1,109 @@ +import { FileText, Upload, X } from "lucide-react"; +import { useRef, useState } from "react"; + +import { formatBytes } from "../lib/format"; + +const allowedExtensions = new Set(["txt", "docx", "pdf"]); +const maxFileBytes = 25 * 1024 * 1024; + +interface FileDropzoneProps { + id: string; + label: string; + file: File | null; + disabled?: boolean; + onChange: (file: File | null) => void; +} + +function validateFile(file: File): string | null { + const extension = file.name.split(".").pop()?.toLowerCase() ?? ""; + if (!allowedExtensions.has(extension)) return "Choose a TXT, DOCX, or PDF file."; + if (file.size === 0) return "The selected file is empty."; + if (file.size > maxFileBytes) return "The selected file exceeds 25 MB."; + return null; +} + +export function FileDropzone({ + id, + label, + file, + disabled = false, + onChange, +}: FileDropzoneProps) { + const inputRef = useRef(null); + const [dragging, setDragging] = useState(false); + const [error, setError] = useState(null); + + const selectFile = (candidate: File | undefined) => { + if (!candidate) return; + const validationError = validateFile(candidate); + setError(validationError); + if (!validationError) onChange(candidate); + }; + + const clear = () => { + setError(null); + onChange(null); + if (inputRef.current) inputRef.current.value = ""; + }; + + return ( +
+ + selectFile(event.target.files?.[0])} + /> + {file ? ( +
+ + + {file.name} + {formatBytes(file.size)} + + +
+ ) : ( + + )} + {error ?

{error}

: null} +
+ ); +} diff --git a/apps/web/src/components/JobHistory.tsx b/apps/web/src/components/JobHistory.tsx new file mode 100644 index 0000000..f27d39f --- /dev/null +++ b/apps/web/src/components/JobHistory.tsx @@ -0,0 +1,84 @@ +import { FileSearch, GitCompareArrows, RefreshCw, Trash2 } from "lucide-react"; + +import type { Job } from "../api/types"; +import { formatDate, humanize, statusLabel } from "../lib/format"; + +interface JobHistoryProps { + jobs: Job[]; + selectedJobId: string | null; + loading: boolean; + onRefresh: () => void; + onSelect: (job: Job) => void; + onDelete: (job: Job) => void; +} + +export function JobHistory({ + jobs, + selectedJobId, + loading, + onRefresh, + onSelect, + onDelete, +}: JobHistoryProps) { + return ( +
+
+
+

Workspace

+

Recent reviews

+
+ +
+ +
+ {jobs.length === 0 ?

No reviews yet

: null} + {jobs.map((job) => { + const fileLabel = job.inputs.map((input) => input.name).join(" / "); + return ( +
+ + {job.status === "completed" || job.status === "failed" || job.status === "cancelled" ? ( + + ) : null} +
+ ); + })} +
+
+ ); +} diff --git a/apps/web/src/components/JobProgress.tsx b/apps/web/src/components/JobProgress.tsx new file mode 100644 index 0000000..8a8a19b --- /dev/null +++ b/apps/web/src/components/JobProgress.tsx @@ -0,0 +1,43 @@ +import { Radio, Square } from "lucide-react"; + +import type { Job } from "../api/types"; +import { clampProgress, humanize, statusLabel } from "../lib/format"; + +interface JobProgressProps { + job: Job; + connected: boolean; + onCancel: () => void; +} + +export function JobProgress({ job, connected, onCancel }: JobProgressProps) { + const progress = clampProgress(job.progress); + + return ( +
+
+
+ + + {statusLabel(job.status)} + +

{humanize(job.stage)}

+
+ + + {connected ? "Live" : "Syncing"} + +
+

{job.message}

+
+ +
+
+ {progress}% + +
+
+ ); +} diff --git a/apps/web/src/components/SubmissionPanel.test.tsx b/apps/web/src/components/SubmissionPanel.test.tsx new file mode 100644 index 0000000..504c8e8 --- /dev/null +++ b/apps/web/src/components/SubmissionPanel.test.tsx @@ -0,0 +1,94 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { SubmissionPanel } from "./SubmissionPanel"; + +describe("SubmissionPanel", () => { + it("submits an analysis and clears the selected file", async () => { + const user = userEvent.setup(); + const onAnalyze = vi.fn().mockResolvedValue(undefined); + render( + , + ); + const file = new File(["agreement"], "agreement.txt", { type: "text/plain" }); + + await user.upload(screen.getByLabelText("Contract"), file); + await user.click(screen.getByRole("button", { name: "Run analysis" })); + + expect(onAnalyze).toHaveBeenCalledWith(file); + expect(screen.queryByText("agreement.txt")).not.toBeInTheDocument(); + }); + + it("submits both documents in comparison mode", async () => { + const user = userEvent.setup(); + const onCompare = vi.fn().mockResolvedValue(undefined); + render( + , + ); + const original = new File(["original"], "original.docx"); + const modified = new File(["modified"], "modified.pdf"); + + await user.upload(screen.getByLabelText("Original"), original); + await user.upload(screen.getByLabelText("Modified"), modified); + await user.click(screen.getByRole("button", { name: "Compare versions" })); + + expect(onCompare).toHaveBeenCalledWith(original, modified); + }); + + it("surfaces submission errors and mode changes", async () => { + const user = userEvent.setup(); + const onAnalyze = vi.fn().mockRejectedValue(new Error("Queue limit reached")); + const onModeChange = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Compare" })); + expect(onModeChange).toHaveBeenCalledWith("comparison"); + + await user.upload( + screen.getByLabelText("Contract"), + new File(["agreement"], "agreement.txt", { type: "text/plain" }), + ); + await user.click(screen.getByRole("button", { name: "Run analysis" })); + expect(await screen.findByText("Queue limit reached")).toBeVisible(); + }); + + it("blocks submissions while the engine is unavailable", () => { + render( + , + ); + + expect(screen.getByText("Analysis engine unavailable")).toBeVisible(); + expect(screen.getByRole("button", { name: "Run analysis" })).toBeDisabled(); + }); +}); diff --git a/apps/web/src/components/SubmissionPanel.tsx b/apps/web/src/components/SubmissionPanel.tsx new file mode 100644 index 0000000..aeec9d5 --- /dev/null +++ b/apps/web/src/components/SubmissionPanel.tsx @@ -0,0 +1,118 @@ +import { GitCompareArrows, ScanSearch } from "lucide-react"; +import { FormEvent, useState } from "react"; + +import type { JobType } from "../api/types"; +import { FileDropzone } from "./FileDropzone"; + +interface SubmissionPanelProps { + mode: JobType; + busy: boolean; + engineReady: boolean; + onModeChange: (mode: JobType) => void; + onAnalyze: (file: File) => Promise; + onCompare: (original: File, modified: File) => Promise; +} + +export function SubmissionPanel({ + mode, + busy, + engineReady, + onModeChange, + onAnalyze, + onCompare, +}: SubmissionPanelProps) { + const [document, setDocument] = useState(null); + const [original, setOriginal] = useState(null); + const [modified, setModified] = useState(null); + const [error, setError] = useState(null); + + const canSubmit = engineReady && !busy && (mode === "analysis" ? document : original && modified); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + try { + if (mode === "analysis" && document) { + await onAnalyze(document); + setDocument(null); + } else if (mode === "comparison" && original && modified) { + await onCompare(original, modified); + setOriginal(null); + setModified(null); + } + } catch (cause) { + setError(cause instanceof Error ? cause.message : "The request could not be submitted."); + } + }; + + return ( +
+
+
+

New review

+

Document intake

+
+
+ +
+ + +
+ +
void submit(event)}> + {mode === "analysis" ? ( + + ) : ( +
+ + +
+ )} + + {error ?
{error}
: null} + {!engineReady ? ( +
Analysis engine unavailable
+ ) : null} + + + +
+ ); +} diff --git a/apps/web/src/hooks/useJobStream.test.ts b/apps/web/src/hooks/useJobStream.test.ts new file mode 100644 index 0000000..155d962 --- /dev/null +++ b/apps/web/src/hooks/useJobStream.test.ts @@ -0,0 +1,152 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { completedAnalysisJob } from "../test/fixtures"; + +const apiMocks = vi.hoisted(() => ({ + getJob: vi.fn(), +})); + +vi.mock("../api/client", async () => { + const actual = await vi.importActual("../api/client"); + return { ...actual, getJob: apiMocks.getJob }; +}); + +import { useJobStream } from "./useJobStream"; + +type EventHandler = (event: Event) => void; + +class FakeEventSource { + static instances: FakeEventSource[] = []; + + readonly url: string; + readonly close = vi.fn(); + private readonly handlers = new Map(); + + constructor(url: string | URL) { + this.url = String(url); + FakeEventSource.instances.push(this); + } + + addEventListener(type: string, handler: EventListenerOrEventListenerObject): void { + const callback = + typeof handler === "function" ? handler : (event: Event) => handler.handleEvent(event); + this.handlers.set(type, [...(this.handlers.get(type) ?? []), callback]); + } + + emit(type: string, data?: string): void { + const event = data === undefined ? new Event(type) : new MessageEvent(type, { data }); + this.handlers.get(type)?.forEach((handler) => handler(event)); + } +} + +beforeEach(() => { + vi.clearAllMocks(); + FakeEventSource.instances = []; + vi.stubGlobal("EventSource", FakeEventSource); +}); + +describe("useJobStream", () => { + it("does not connect without an active job", () => { + renderHook(() => + useJobStream({ + jobId: null, + active: false, + onJob: vi.fn(), + onConnectionChange: vi.fn(), + }), + ); + + expect(FakeEventSource.instances).toHaveLength(0); + }); + + it("accepts server events and closes after a terminal update", () => { + const onJob = vi.fn(); + const onConnectionChange = vi.fn(); + renderHook(() => + useJobStream({ + jobId: completedAnalysisJob.id, + active: true, + onJob, + onConnectionChange, + }), + ); + + const source = FakeEventSource.instances[0]!; + expect(source.url).toContain(`/api/v1/jobs/${completedAnalysisJob.id}/events`); + + act(() => source.emit("open")); + expect(onConnectionChange).toHaveBeenCalledWith(true); + + act(() => source.emit("job", JSON.stringify(completedAnalysisJob))); + expect(onJob).toHaveBeenCalledWith(completedAnalysisJob); + expect(source.close).toHaveBeenCalledOnce(); + expect(onConnectionChange).toHaveBeenLastCalledWith(false); + }); + + it("falls back to polling when the event stream fails", async () => { + const runningJob = { ...completedAnalysisJob, status: "running" as const, progress: 40 }; + apiMocks.getJob.mockResolvedValue(runningJob); + const onJob = vi.fn(); + const onConnectionChange = vi.fn(); + const { unmount } = renderHook(() => + useJobStream({ + jobId: runningJob.id, + active: true, + onJob, + onConnectionChange, + }), + ); + + act(() => FakeEventSource.instances[0]!.emit("error")); + + await waitFor(() => expect(apiMocks.getJob).toHaveBeenCalledWith(runningJob.id)); + expect(onJob).toHaveBeenCalledWith(runningJob); + expect(onConnectionChange).toHaveBeenCalledWith(false); + + unmount(); + expect(FakeEventSource.instances[0]!.close).toHaveBeenCalled(); + }); + + it("uses polling for malformed events and reports polling errors", async () => { + apiMocks.getJob.mockRejectedValue(new Error("offline")); + const onConnectionChange = vi.fn(); + renderHook(() => + useJobStream({ + jobId: completedAnalysisJob.id, + active: true, + onJob: vi.fn(), + onConnectionChange, + }), + ); + + act(() => FakeEventSource.instances[0]!.emit("job", "not-json")); + + await waitFor(() => expect(apiMocks.getJob).toHaveBeenCalledOnce()); + expect(onConnectionChange).toHaveBeenCalledWith(false); + }); + + it("polls when EventSource construction is unavailable", async () => { + apiMocks.getJob.mockResolvedValue(completedAnalysisJob); + vi.stubGlobal( + "EventSource", + class { + constructor() { + throw new Error("unsupported"); + } + }, + ); + const onJob = vi.fn(); + + renderHook(() => + useJobStream({ + jobId: completedAnalysisJob.id, + active: true, + onJob, + onConnectionChange: vi.fn(), + }), + ); + + await waitFor(() => expect(onJob).toHaveBeenCalledWith(completedAnalysisJob)); + }); +}); diff --git a/apps/web/src/hooks/useJobStream.ts b/apps/web/src/hooks/useJobStream.ts new file mode 100644 index 0000000..f7a12ba --- /dev/null +++ b/apps/web/src/hooks/useJobStream.ts @@ -0,0 +1,88 @@ +import { useEffect } from "react"; + +import { getJob, jobEventsURL } from "../api/client"; +import type { Job } from "../api/types"; +import { isTerminalStatus } from "../api/types"; + +interface JobStreamOptions { + jobId: string | null; + active: boolean; + onJob: (job: Job) => void; + onConnectionChange: (connected: boolean) => void; +} + +export function useJobStream({ + jobId, + active, + onJob, + onConnectionChange, +}: JobStreamOptions): void { + useEffect(() => { + if (!jobId || !active) return; + + let disposed = false; + let pollingTimer: number | undefined; + let source: EventSource | undefined; + + const stopPolling = () => { + if (pollingTimer !== undefined) { + window.clearInterval(pollingTimer); + pollingTimer = undefined; + } + }; + + const acceptJob = (job: Job) => { + if (disposed) return; + onJob(job); + if (isTerminalStatus(job.status)) { + stopPolling(); + source?.close(); + onConnectionChange(false); + } + }; + + const poll = async () => { + try { + acceptJob(await getJob(jobId)); + } catch { + onConnectionChange(false); + } + }; + + const startPolling = () => { + if (pollingTimer !== undefined || disposed) return; + void poll(); + pollingTimer = window.setInterval(() => void poll(), 1500); + }; + + try { + source = new EventSource(jobEventsURL(jobId)); + source.addEventListener("open", () => { + if (!disposed) onConnectionChange(true); + }); + source.addEventListener("job", (event) => { + try { + acceptJob(JSON.parse((event as MessageEvent).data) as Job); + } catch { + source?.close(); + onConnectionChange(false); + startPolling(); + } + }); + source.addEventListener("error", () => { + source?.close(); + onConnectionChange(false); + startPolling(); + }); + } catch { + startPolling(); + } + + return () => { + disposed = true; + source?.close(); + stopPolling(); + onConnectionChange(false); + }; + }, [active, jobId, onConnectionChange, onJob]); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..ba60ced --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import App from "./App"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css new file mode 100644 index 0000000..b923e01 --- /dev/null +++ b/apps/web/src/styles.css @@ -0,0 +1,1818 @@ +:root { + font-family: Inter, "Segoe UI", Arial, sans-serif; + color: #1f2725; + background: #f4f6f5; + font-synthesis: none; + text-rendering: optimizeLegibility; + --surface: #ffffff; + --surface-muted: #f7f8f8; + --border: #dfe4e2; + --border-strong: #cbd3d0; + --text: #1f2725; + --text-muted: #66716e; + --accent: #176b54; + --accent-strong: #115541; + --accent-soft: #e6f2ee; + --blue: #315f8a; + --blue-soft: #eaf1f7; + --amber: #a45d12; + --amber-soft: #fff3df; + --red: #a63d3d; + --red-soft: #fcecec; + --shadow: 0 8px 24px rgb(22 34 30 / 8%); +} + +* { + box-sizing: border-box; + letter-spacing: 0; +} + +html, +body, +#root { + min-width: 320px; + min-height: 100%; + margin: 0; +} + +body { + min-height: 100vh; + overflow: hidden; +} + +button, +input { + font: inherit; +} + +button { + color: inherit; +} + +button:focus-visible, +input:focus-visible { + outline: 3px solid rgb(23 107 84 / 25%); + outline-offset: 2px; +} + +h1, +h2, +h3, +h4, +p { + margin-top: 0; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.app-shell { + min-height: 100vh; + background: #f4f6f5; +} + +.app-header { + position: relative; + z-index: 30; + display: flex; + align-items: center; + justify-content: space-between; + height: 64px; + padding: 0 20px; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.brand-group, +.engine-health, +.report-title-group, +.progress-status, +.stream-status, +.rewrite-heading, +.verifier-note, +.inline-rewrite h4, +.limitations-block h3 { + display: flex; + align-items: center; +} + +.brand-group { + gap: 10px; + min-width: 0; +} + +.brand-group > div { + display: grid; + gap: 1px; + min-width: 0; +} + +.brand-group strong { + font-size: 15px; + line-height: 20px; +} + +.brand-group small { + color: var(--text-muted); + font-size: 11px; +} + +.brand-mark { + display: grid; + flex: 0 0 36px; + width: 36px; + height: 36px; + place-items: center; + color: #ffffff; + background: var(--accent); + border-radius: 7px; +} + +.mobile-menu-button { + display: none; + width: 36px; + height: 36px; + padding: 0; + place-items: center; + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; +} + +.engine-health { + gap: 9px; + min-width: 170px; + padding: 6px 8px; + text-align: left; + background: transparent; + border: 0; + border-radius: 6px; + cursor: pointer; +} + +.engine-health:hover { + background: var(--surface-muted); +} + +.engine-health > span:nth-child(2) { + display: grid; + flex: 1; +} + +.engine-health strong { + font-size: 12px; + line-height: 16px; +} + +.engine-health small { + color: var(--text-muted); + font-size: 10px; + line-height: 14px; +} + +.health-ready, +.health-down { + flex: 0 0 8px; + width: 8px; + height: 8px; + border-radius: 50%; +} + +.health-ready { + background: #26936d; + box-shadow: 0 0 0 4px #e2f3ec; +} + +.health-down { + background: #c24a4a; + box-shadow: 0 0 0 4px #f9e6e6; +} + +.app-layout { + display: grid; + grid-template-columns: 360px minmax(0, 1fr); + height: calc(100vh - 64px); +} + +.sidebar { + z-index: 20; + overflow-y: auto; + background: var(--surface); + border-right: 1px solid var(--border); +} + +.submission-section, +.history-section { + padding: 22px 20px; +} + +.submission-section { + border-bottom: 1px solid var(--border); +} + +.history-section { + padding-bottom: 12px; +} + +.section-heading-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 18px; +} + +.section-heading-row.compact { + align-items: center; + margin-bottom: 12px; +} + +.section-kicker { + margin-bottom: 3px; + color: var(--accent); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; +} + +.section-heading-row h2 { + margin: 0; + font-size: 17px; + line-height: 22px; +} + +.segmented-control { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3px; + padding: 3px; + margin-bottom: 18px; + background: #edf0ef; + border-radius: 7px; +} + +.segmented-control button { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 36px; + padding: 7px 10px; + color: var(--text-muted); + font-size: 12px; + font-weight: 650; + background: transparent; + border: 0; + border-radius: 5px; + cursor: pointer; +} + +.segmented-control button.is-active { + color: var(--text); + background: var(--surface); + box-shadow: 0 1px 3px rgb(31 39 37 / 12%); +} + +.file-field { + margin-bottom: 14px; +} + +.field-label { + display: block; + margin-bottom: 7px; + color: #46514e; + font-size: 11px; + font-weight: 700; +} + +.dropzone { + display: grid; + width: 100%; + min-height: 104px; + padding: 16px; + place-items: center; + color: var(--text-muted); + background: var(--surface-muted); + border: 1px dashed #bfc9c5; + border-radius: 7px; + cursor: pointer; +} + +.dropzone:hover, +.dropzone.is-dragging { + color: var(--accent); + background: var(--accent-soft); + border-color: var(--accent); +} + +.dropzone span { + margin-top: 6px; + color: var(--text); + font-size: 12px; + font-weight: 650; +} + +.dropzone small { + margin-top: 2px; + font-size: 10px; +} + +.dropzone:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.selected-file { + display: flex; + align-items: center; + gap: 10px; + min-height: 60px; + padding: 10px; + background: var(--surface-muted); + border: 1px solid var(--border); + border-radius: 7px; +} + +.file-icon, +.job-type-icon { + display: grid; + flex: 0 0 34px; + width: 34px; + height: 34px; + place-items: center; + color: var(--blue); + background: var(--blue-soft); + border-radius: 6px; +} + +.file-meta, +.job-copy { + display: grid; + min-width: 0; +} + +.file-meta { + flex: 1; +} + +.file-meta strong, +.job-copy strong { + overflow: hidden; + font-size: 12px; + line-height: 17px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-meta small, +.job-copy small { + color: var(--text-muted); + font-size: 10px; + line-height: 15px; +} + +.field-error { + margin: 6px 0 0; + color: var(--red); + font-size: 11px; +} + +.comparison-files { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 10px; +} + +.comparison-files .dropzone { + min-height: 116px; + padding: 12px 8px; +} + +.comparison-files .dropzone span { + max-width: 110px; + text-align: center; +} + +.comparison-files .selected-file { + display: grid; + min-height: 116px; + padding: 10px; + place-items: center; + text-align: center; +} + +.comparison-files .file-meta { + width: 100%; +} + +.primary-button, +.secondary-button, +.danger-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 36px; + padding: 8px 13px; + font-size: 12px; + font-weight: 700; + border-radius: 6px; + cursor: pointer; +} + +.primary-button { + color: #ffffff; + background: var(--accent); + border: 1px solid var(--accent); +} + +.primary-button:hover:not(:disabled) { + background: var(--accent-strong); +} + +.primary-button:disabled { + color: #98a29f; + background: #e6e9e8; + border-color: #e6e9e8; + cursor: not-allowed; +} + +.secondary-button { + color: #34413d; + background: var(--surface); + border: 1px solid var(--border-strong); +} + +.secondary-button:hover { + background: var(--surface-muted); +} + +.danger-button { + color: #ffffff; + background: var(--red); + border: 1px solid var(--red); +} + +.full-width { + width: 100%; +} + +.icon-button { + display: grid; + flex: 0 0 32px; + width: 32px; + height: 32px; + padding: 0; + place-items: center; + color: var(--text-muted); + background: transparent; + border: 1px solid transparent; + border-radius: 5px; + cursor: pointer; +} + +.icon-button:hover:not(:disabled) { + color: var(--text); + background: #edf0ef; +} + +.icon-button:disabled { + opacity: 0.55; +} + +.inline-alert { + padding: 9px 10px; + margin: 10px 0; + font-size: 11px; + border-radius: 6px; +} + +.error-alert { + color: var(--red); + background: var(--red-soft); + border: 1px solid #f0caca; +} + +.warning-alert { + color: #81500d; + background: var(--amber-soft); + border: 1px solid #eed4a8; +} + +.job-list { + display: grid; + gap: 3px; +} + +.empty-list, +.empty-content { + padding: 22px 10px; + margin: 0; + color: var(--text-muted); + font-size: 12px; + text-align: center; +} + +.job-row { + position: relative; + display: flex; + min-width: 0; + border-radius: 7px; +} + +.job-row:hover, +.job-row.is-selected { + background: #f0f4f2; +} + +.job-row.is-selected::before { + position: absolute; + top: 9px; + bottom: 9px; + left: 0; + width: 3px; + content: ""; + background: var(--accent); + border-radius: 0 2px 2px 0; +} + +.job-select { + display: flex; + flex: 1; + align-items: center; + gap: 9px; + min-width: 0; + min-height: 56px; + padding: 8px 10px; + text-align: left; + background: transparent; + border: 0; + cursor: pointer; +} + +.job-copy { + flex: 1; +} + +.status-dot { + display: inline-block; + flex: 0 0 8px; + width: 8px; + height: 8px; + background: #9aa4a1; + border-radius: 50%; +} + +.status-queued { + background: #76817e; +} + +.status-running { + background: #3276a9; +} + +.status-completed { + background: #21845f; +} + +.status-failed, +.status-timed_out { + background: #bd4040; +} + +.status-cancelled { + background: #9b671f; +} + +.job-delete { + display: none; + width: 30px; + padding: 0; + color: var(--text-muted); + background: transparent; + border: 0; + cursor: pointer; +} + +.job-row:hover .job-delete, +.job-delete:focus-visible { + display: grid; + place-items: center; +} + +.job-delete:hover { + color: var(--red); +} + +.workspace { + position: relative; + min-width: 0; + overflow-y: auto; + background: #f4f6f5; +} + +.workspace-empty, +.progress-panel { + position: absolute; + top: 50%; + left: 50%; + width: min(540px, calc(100% - 40px)); + transform: translate(-50%, -50%); +} + +.workspace-empty { + display: grid; + place-items: center; + color: var(--text-muted); + text-align: center; +} + +.workspace-empty > span:first-child:not(.spinner) { + display: grid; + width: 56px; + height: 56px; + margin-bottom: 14px; + place-items: center; + color: var(--accent); + background: var(--accent-soft); + border-radius: 8px; +} + +.workspace-empty h1 { + margin-bottom: 5px; + color: var(--text); + font-size: 20px; +} + +.workspace-empty p { + margin: 0; + font-size: 13px; +} + +.workspace-empty.error-state > span { + color: var(--red); + background: var(--red-soft); +} + +.progress-panel { + padding: 24px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.progress-header, +.progress-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.progress-header h2 { + margin: 7px 0 0; + font-size: 19px; +} + +.progress-status, +.stream-status { + gap: 7px; + color: var(--text-muted); + font-size: 11px; + font-weight: 650; +} + +.stream-status.is-connected { + color: var(--accent); +} + +.progress-panel > p { + margin: 14px 0 18px; + color: var(--text-muted); + font-size: 13px; +} + +.progress-track, +.mini-track { + display: block; + overflow: hidden; + background: #e7ebe9; + border-radius: 4px; +} + +.progress-track { + height: 8px; +} + +.progress-track > span, +.mini-track > i { + display: block; + height: 100%; + background: var(--accent); + border-radius: inherit; + transition: width 220ms ease; +} + +.progress-footer { + margin-top: 14px; +} + +.progress-footer strong { + font-size: 13px; +} + +.danger-text { + color: var(--red); +} + +.global-alert { + position: sticky; + top: 12px; + z-index: 10; + display: flex; + align-items: center; + gap: 9px; + width: min(920px, calc(100% - 32px)); + min-height: 42px; + padding: 9px 11px; + margin: 12px auto -54px; + color: #762b2b; + font-size: 12px; + background: #fff4f4; + border: 1px solid #edc4c4; + border-radius: 7px; + box-shadow: 0 4px 14px rgb(80 24 24 / 9%); +} + +.global-alert span { + flex: 1; +} + +.global-alert button { + display: grid; + width: 28px; + height: 28px; + padding: 0; + place-items: center; + background: transparent; + border: 0; + border-radius: 4px; + cursor: pointer; +} + +.report-view { + width: min(1180px, 100%); + min-height: 100%; + margin: 0 auto; + padding: 30px 34px 60px; +} + +.report-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + padding-bottom: 22px; +} + +.report-title-group { + min-width: 0; + gap: 12px; +} + +.report-title-group > div { + min-width: 0; +} + +.report-type-icon { + display: grid; + flex: 0 0 42px; + width: 42px; + height: 42px; + place-items: center; + border-radius: 7px; +} + +.analysis-icon { + color: var(--red); + background: var(--red-soft); +} + +.compare-icon { + color: var(--blue); + background: var(--blue-soft); +} + +.report-title-group p { + margin-bottom: 2px; + color: var(--accent); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; +} + +.report-title-group h1 { + max-width: 760px; + margin-bottom: 3px; + overflow-wrap: anywhere; + font-size: 22px; + line-height: 28px; +} + +.report-title-group > div > span { + display: block; + overflow: hidden; + color: var(--text-muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.report-title-group > div > .comparison-document-pair { + display: flex; + align-items: center; + gap: 5px; +} + +.report-summary { + padding: 20px 0 24px; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} + +.report-summary > p { + max-width: 880px; + margin-bottom: 18px; + color: #4e5956; + font-size: 13px; + line-height: 20px; +} + +.metric-grid { + display: grid; + gap: 10px; +} + +.analysis-metrics { + grid-template-columns: repeat(4, minmax(110px, 1fr)); +} + +.comparison-metrics { + grid-template-columns: repeat(5, minmax(90px, 1fr)); +} + +.metric-grid > div { + position: relative; + display: grid; + min-height: 70px; + padding: 12px 14px; + align-content: center; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 7px; +} + +.metric-grid strong { + font-size: 20px; + line-height: 24px; +} + +.metric-grid small { + color: var(--text-muted); + font-size: 10px; +} + +.analysis-metrics > div { + padding-left: 48px; +} + +.metric-icon { + position: absolute; + top: 18px; + left: 12px; + display: grid; + width: 28px; + height: 28px; + place-items: center; + border-radius: 5px; +} + +.risk-icon { + color: var(--red); + background: var(--red-soft); +} + +.finding-icon { + color: var(--amber); + background: var(--amber-soft); +} + +.clause-icon { + color: var(--blue); + background: var(--blue-soft); +} + +.rewrite-icon { + color: var(--accent); + background: var(--accent-soft); +} + +.report-tabs { + display: flex; + gap: 24px; + min-height: 50px; + border-bottom: 1px solid var(--border); +} + +.report-tabs button { + position: relative; + padding: 0 1px; + color: var(--text-muted); + font-size: 12px; + font-weight: 650; + background: transparent; + border: 0; + cursor: pointer; +} + +.report-tabs button.is-active { + color: var(--text); +} + +.report-tabs button.is-active::after { + position: absolute; + right: 0; + bottom: -1px; + left: 0; + height: 2px; + content: ""; + background: var(--accent); +} + +.report-content, +.comparison-deltas, +.comparison-risks, +.comparison-notes { + padding-top: 24px; +} + +.content-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + min-height: 34px; + margin-bottom: 14px; +} + +.content-toolbar h2, +.comparison-notes h2 { + margin: 0; + font-size: 15px; + line-height: 22px; +} + +.filter-control { + display: flex; + overflow-x: auto; + padding: 3px; + background: #e9edeb; + border-radius: 6px; +} + +.filter-control button { + min-height: 28px; + padding: 5px 9px; + color: var(--text-muted); + font-size: 10px; + font-weight: 700; + white-space: nowrap; + background: transparent; + border: 0; + border-radius: 4px; + cursor: pointer; +} + +.filter-control button.is-active { + color: var(--text); + background: var(--surface); + box-shadow: 0 1px 2px rgb(31 39 37 / 10%); +} + +.findings-list, +.rewrite-list, +.delta-list, +.risk-signal-list { + display: grid; + gap: 10px; +} + +.finding-card, +.rewrite-card, +.delta-row, +.risk-signal-list article { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 7px; +} + +.finding-card { + overflow: hidden; + border-left-width: 3px; +} + +.severity-border-high { + border-left-color: var(--red); +} + +.severity-border-medium { + border-left-color: var(--amber); +} + +.severity-border-low { + border-left-color: var(--blue); +} + +.finding-header { + display: grid; + grid-template-columns: 66px minmax(0, 1fr) 78px 20px; + gap: 12px; + width: 100%; + min-height: 72px; + padding: 13px 15px; + align-items: center; + text-align: left; + background: transparent; + border: 0; + cursor: pointer; +} + +.finding-header:hover { + background: #fafbfb; +} + +.severity-badge { + display: inline-grid; + width: fit-content; + min-width: 58px; + min-height: 24px; + padding: 4px 7px; + place-items: center; + font-size: 9px; + font-weight: 800; + border-radius: 4px; +} + +.severity-high { + color: #8e2f2f; + background: var(--red-soft); +} + +.severity-medium { + color: #87500f; + background: var(--amber-soft); +} + +.severity-low { + color: #315f8a; + background: var(--blue-soft); +} + +.finding-title, +.finding-score { + display: grid; + min-width: 0; +} + +.finding-title strong { + overflow: hidden; + font-size: 13px; + line-height: 19px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.finding-title small, +.finding-score small { + color: var(--text-muted); + font-size: 10px; +} + +.finding-score { + text-align: right; +} + +.finding-score strong { + font-size: 15px; +} + +.finding-body { + padding: 0 15px 18px; + border-top: 1px solid #edf0ef; +} + +.finding-explanation { + padding-top: 16px; + margin-bottom: 14px; + color: #3f4b47; + font-size: 13px; + line-height: 20px; +} + +.finding-metadata, +.audit-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin: 0 0 14px; +} + +.finding-metadata > div, +.audit-grid > div { + display: grid; + gap: 3px; + padding: 10px; + background: var(--surface-muted); + border-radius: 5px; +} + +.finding-metadata dt, +.audit-grid dt { + color: var(--text-muted); + font-size: 9px; + font-weight: 700; + text-transform: uppercase; +} + +.finding-metadata dd, +.audit-grid dd { + margin: 0; + overflow-wrap: anywhere; + font-size: 11px; + font-weight: 650; +} + +.signal-list { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin: 10px 0 14px; +} + +.signal-list span { + padding: 4px 7px; + color: #7c4a0f; + font-size: 9px; + font-weight: 650; + background: var(--amber-soft); + border: 1px solid #efd7b0; + border-radius: 4px; +} + +.finding-detail-grid { + display: grid; + grid-template-columns: minmax(260px, 0.9fr) minmax(300px, 1.1fr); + gap: 22px; + padding: 15px 0; + border-top: 1px solid #edf0ef; +} + +.finding-detail-grid h4, +.inline-rewrite h4 { + margin-bottom: 11px; + font-size: 11px; +} + +.score-row { + display: grid; + grid-template-columns: minmax(130px, 1fr) minmax(70px, 1fr) 34px; + gap: 9px; + min-height: 26px; + align-items: center; + font-size: 10px; +} + +.mini-track { + height: 5px; +} + +.score-row strong { + text-align: right; +} + +.evidence-list { + display: grid; + gap: 7px; + padding: 0; + margin: 0; + list-style: none; +} + +.evidence-list li { + display: flex; + gap: 10px; + min-height: 50px; + padding: 8px 9px; + align-items: flex-start; + background: var(--surface-muted); + border-radius: 5px; +} + +.evidence-list li > span { + display: grid; + flex: 1; + gap: 2px; + min-width: 0; +} + +.evidence-list strong { + font-size: 10px; +} + +.evidence-list small { + display: -webkit-box; + overflow: hidden; + color: var(--text-muted); + font-size: 9px; + line-height: 14px; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.evidence-list b { + color: var(--accent); + font-size: 10px; +} + +.muted-copy { + color: var(--text-muted); + font-size: 11px; +} + +.verifier-note { + gap: 8px; + padding: 10px; + margin-top: 2px; + color: #3c5162; + font-size: 10px; + line-height: 16px; + background: var(--blue-soft); + border-radius: 5px; +} + +.inline-rewrite { + padding-top: 15px; + margin-top: 15px; + border-top: 1px solid #edf0ef; +} + +.inline-rewrite h4, +.limitations-block h3 { + gap: 7px; +} + +.inline-rewrite p { + margin: 0; + padding: 12px; + color: #28473e; + font-family: Georgia, "Times New Roman", serif; + font-size: 12px; + line-height: 20px; + white-space: pre-wrap; + background: #eef6f3; + border-left: 3px solid var(--accent); +} + +.clause-list { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 7px; +} + +.clause-row { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 12px; + padding: 16px; + border-bottom: 1px solid var(--border); +} + +.clause-row:last-child { + border-bottom: 0; +} + +.clause-number { + display: grid; + width: 30px; + height: 30px; + place-items: center; + color: var(--text-muted); + font-size: 10px; + font-weight: 700; + background: #edf0ef; + border-radius: 5px; +} + +.clause-row p { + margin-bottom: 2px; + color: var(--accent); + font-size: 9px; + font-weight: 700; + text-transform: uppercase; +} + +.clause-row h3 { + margin-bottom: 8px; + font-size: 13px; +} + +.clause-text { + color: #4a5552; + font-family: Georgia, "Times New Roman", serif; + font-size: 12px; + line-height: 20px; + white-space: pre-wrap; +} + +.rewrite-card { + padding: 16px; +} + +.rewrite-heading { + gap: 8px; + margin-bottom: 12px; + color: var(--accent); +} + +.rewrite-heading h3 { + margin: 0; + color: var(--text); + font-size: 13px; +} + +.rewrite-columns, +.delta-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + overflow: hidden; + background: var(--border); + border: 1px solid var(--border); + border-radius: 5px; +} + +.rewrite-columns > div, +.delta-columns > div { + padding: 12px; + background: var(--surface-muted); +} + +.rewrite-columns span, +.delta-columns span { + display: block; + margin-bottom: 7px; + color: var(--text-muted); + font-size: 9px; + font-weight: 700; + text-transform: uppercase; +} + +.rewrite-columns p, +.delta-columns p { + margin: 0; + font-family: Georgia, "Times New Roman", serif; + font-size: 11px; + line-height: 18px; + white-space: pre-wrap; +} + +.rewrite-card > small { + display: block; + margin-top: 10px; + color: var(--text-muted); + font-size: 10px; +} + +.audit-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.entity-table { + margin: 18px 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 7px; +} + +.entity-table > div { + display: grid; + grid-template-columns: 120px minmax(0, 1fr) 100px; + gap: 12px; + padding: 10px 12px; + align-items: center; + border-bottom: 1px solid var(--border); +} + +.entity-table > div:last-child { + border-bottom: 0; +} + +.entity-table span, +.entity-table small { + color: var(--text-muted); + font-size: 10px; +} + +.entity-table strong { + overflow-wrap: anywhere; + font-size: 11px; +} + +.limitations-block { + padding: 14px; + color: #5d5339; + background: #fff9ec; + border: 1px solid #eadcb9; + border-radius: 7px; +} + +.limitations-block h3 { + margin-bottom: 8px; + font-size: 12px; +} + +.limitations-block ul, +.comparison-notes ul { + padding-left: 18px; + margin: 0; + font-size: 10px; + line-height: 17px; +} + +.risk-signal-list article { + display: grid; + grid-template-columns: 64px 20px minmax(0, 1fr); + gap: 10px; + padding: 13px; + align-items: start; +} + +.risk-signal-list article > svg { + margin-top: 3px; + color: var(--amber); +} + +.risk-signal-list strong { + font-size: 12px; +} + +.risk-signal-list p { + margin: 3px 0; + color: #4d5855; + font-size: 11px; + line-height: 17px; +} + +.risk-signal-list small { + color: var(--text-muted); + font-size: 9px; +} + +.delta-row { + overflow: hidden; + border-left-width: 3px; +} + +.delta-changed { + border-left-color: var(--amber); +} + +.delta-added { + border-left-color: var(--accent); +} + +.delta-removed { + border-left-color: var(--red); +} + +.delta-unchanged { + border-left-color: #a5aeab; +} + +.delta-heading { + display: grid; + grid-template-columns: 72px minmax(0, 1fr) 88px; + gap: 10px; + min-height: 48px; + padding: 10px 12px; + align-items: center; +} + +.delta-heading strong { + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.delta-heading small { + color: var(--text-muted); + font-size: 9px; + text-align: right; +} + +.delta-status { + display: inline-grid; + min-height: 22px; + padding: 3px 6px; + place-items: center; + font-size: 9px; + font-weight: 750; + background: #edf0ef; + border-radius: 4px; +} + +.status-changed { + color: #87500f; + background: var(--amber-soft); +} + +.status-added { + color: var(--accent); + background: var(--accent-soft); +} + +.status-removed { + color: var(--red); + background: var(--red-soft); +} + +.delta-columns { + border-right: 0; + border-bottom: 0; + border-left: 0; + border-radius: 0; +} + +.delta-columns small { + display: block; + margin-top: 8px; + color: var(--amber); + font-size: 9px; +} + +.comparison-notes { + margin-top: 22px; + border-top: 1px solid var(--border); +} + +.modal-backdrop { + position: fixed; + z-index: 100; + inset: 0; + display: grid; + padding: 20px; + place-items: center; + background: rgb(24 31 29 / 42%); +} + +.confirm-dialog { + width: min(400px, 100%); + padding: 20px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.confirm-dialog h2 { + margin-bottom: 6px; + font-size: 18px; +} + +.confirm-dialog p { + overflow-wrap: anywhere; + color: var(--text-muted); + font-size: 12px; +} + +.confirm-dialog > div { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 20px; +} + +.spinner { + width: 28px; + height: 28px; + margin-bottom: 14px; + border: 3px solid #d9dfdc; + border-top-color: var(--accent); + border-radius: 50%; + animation: rotate 700ms linear infinite; +} + +.spin { + animation: rotate 800ms linear infinite; +} + +@keyframes rotate { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} + +@media (max-width: 980px) { + .mobile-menu-button { + display: grid; + } + + .app-layout { + grid-template-columns: 1fr; + } + + .sidebar { + position: fixed; + top: 64px; + bottom: 0; + left: 0; + width: min(360px, 92vw); + transform: translateX(-102%); + transition: transform 180ms ease; + box-shadow: var(--shadow); + } + + .sidebar.is-open { + transform: translateX(0); + } + + .sidebar-scrim { + position: fixed; + z-index: 15; + inset: 64px 0 0; + background: rgb(23 31 28 / 34%); + border: 0; + } + + .report-view { + padding-right: 24px; + padding-left: 24px; + } +} + +@media (max-width: 720px) { + .app-header { + padding: 0 12px; + } + + .brand-group small, + .engine-health small, + .engine-health > svg { + display: none; + } + + .engine-health { + min-width: 0; + } + + .analysis-metrics, + .comparison-metrics { + grid-template-columns: repeat(2, minmax(100px, 1fr)); + } + + .comparison-metrics > div:last-child { + grid-column: 1 / -1; + } + + .finding-detail-grid, + .rewrite-columns, + .delta-columns { + grid-template-columns: 1fr; + } + + .content-toolbar { + align-items: flex-start; + flex-direction: column; + } + + .filter-control { + width: 100%; + } + + .filter-control button { + flex: 1; + } + + .audit-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 520px) { + .brand-group strong { + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .engine-health span:nth-child(2) { + display: none; + } + + .engine-health { + padding: 8px; + } + + .report-view { + padding: 20px 14px 44px; + } + + .report-header { + align-items: flex-start; + } + + .report-header .secondary-button { + min-width: 36px; + padding: 8px; + font-size: 0; + } + + .report-title-group h1 { + font-size: 18px; + line-height: 23px; + } + + .report-tabs { + gap: 0; + justify-content: space-between; + } + + .finding-header { + grid-template-columns: 58px minmax(0, 1fr) 20px; + gap: 8px; + padding: 11px; + } + + .finding-score { + display: none; + } + + .finding-body { + padding-right: 11px; + padding-left: 11px; + } + + .finding-metadata { + grid-template-columns: 1fr; + } + + .score-row { + grid-template-columns: minmax(110px, 1fr) 70px 34px; + } + + .entity-table > div { + grid-template-columns: 90px minmax(0, 1fr); + } + + .entity-table small { + display: none; + } + + .delta-heading { + grid-template-columns: 68px minmax(0, 1fr); + } + + .delta-heading small { + display: none; + } + + .risk-signal-list article { + grid-template-columns: 58px minmax(0, 1fr); + } + + .risk-signal-list article > svg { + display: none; + } +} diff --git a/apps/web/src/test/fixtures.ts b/apps/web/src/test/fixtures.ts new file mode 100644 index 0000000..cb90e77 --- /dev/null +++ b/apps/web/src/test/fixtures.ts @@ -0,0 +1,156 @@ +import type { AnalysisReport, ComparisonReport, Job } from "../api/types"; + +export const completedAnalysisJob: Job = { + id: "0123456789abcdef0123456789abcdef", + type: "analysis", + status: "completed", + stage: "completed", + progress: 100, + message: "Analysis completed", + inputs: [{ name: "services-agreement.txt" }], + report_url: "/api/v1/jobs/0123456789abcdef0123456789abcdef/report", + created_at: "2026-08-11T12:00:00Z", + updated_at: "2026-08-11T12:00:02Z", + completed_at: "2026-08-11T12:00:02Z", +}; + +export const analysisReport: AnalysisReport = { + schema_version: "1.0", + document_id: completedAnalysisJob.id, + file_path: "services-agreement.txt", + title: "SERVICES AGREEMENT", + document_type: "Service Agreement", + summary: "Analyzed 2 clauses and accepted 2 evidence-backed findings.", + clauses: [ + { + id: "clause-001", + order: 1, + title: "Payment", + text: "Customer shall pay all invoices within thirty days.", + category: "Payment", + risk_terms: [], + }, + { + id: "clause-002", + order: 2, + title: "Termination", + text: "Provider may terminate in its sole discretion.", + category: "Termination", + risk_terms: ["sole discretion"], + }, + ], + entities: [{ text: "Customer", label: "PARTY", source: "heuristic" }], + evidence: [], + findings: [ + { + id: "finding-1", + issue_type: "risky_language", + severity: "HIGH", + clause_id: "clause-002", + clause_title: "Termination", + explanation: "The termination right is unilateral and lacks objective limits.", + rule_id: "contextual_risky_language", + signals: ["sole discretion"], + evidence: [ + { + id: "evidence-1", + source: "local_reference", + title: "Balanced Discretion Checklist", + text: "Unilateral discretion should use objective standards.", + relevance: 0.86, + clause_id: "clause-002", + }, + ], + component_scores: { + deterministic_rules: 0.9, + rag_evidence: 0.86, + primary_reasoning: 0.82, + verifier_agreement: 0.78, + clause_structure: 0.8, + final: 0.84, + weights: {}, + }, + accepted: true, + acceptance_threshold: 0.62, + model_confidence: 0.82, + verifier_confidence: 0.78, + verifier_status: "verified", + verifier_rationale: "The clause contains no balancing safeguard.", + suggested_rewrite: "Either party may terminate for documented cause after notice.", + }, + { + id: "finding-2", + issue_type: "payment_ambiguity", + severity: "MEDIUM", + clause_id: "clause-001", + clause_title: "Payment", + explanation: "The payment mechanism does not define invoice disputes.", + component_scores: { + deterministic_rules: 0.72, + rag_evidence: 0.68, + primary_reasoning: 0.7, + verifier_agreement: 0.66, + clause_structure: 0.75, + final: 0.7, + weights: {}, + }, + accepted: true, + model_confidence: 0.7, + verifier_confidence: 0.66, + }, + ], + rewrites: [ + { + clause_id: "clause-002", + original_text: "Provider may terminate in its sole discretion.", + rewritten_text: "Either party may terminate for documented cause after notice.", + rationale: "Adds objective and mutual safeguards.", + }, + ], + limitations: ["Findings require qualified legal review."], +}; + +export const comparisonReport: ComparisonReport = { + schema_version: "1.0", + comparison_id: "fedcba9876543210fedcba9876543210", + original_document: "original.txt", + modified_document: "modified.txt", + original_type: "Service Agreement", + modified_type: "Service Agreement", + original_clause_count: 2, + modified_clause_count: 2, + summary: { matched: 2, changed: 1, added: 0, removed: 0 }, + clause_deltas: [ + { + status: "changed", + similarity: 0.72, + original_clause_id: "clause-001", + original_title: "Payment", + original_preview: "Payment is due within thirty days.", + original_risk_terms: [], + modified_clause_id: "clause-001", + modified_title: "Payment", + modified_preview: "Payment is due immediately on demand.", + modified_risk_terms: ["on demand"], + }, + { + status: "unchanged", + similarity: 1, + original_clause_id: "clause-002", + original_title: "Notices", + original_preview: "Notices must be in writing.", + modified_clause_id: "clause-002", + modified_title: "Notices", + modified_preview: "Notices must be in writing.", + }, + ], + risk_signals: [ + { + type: "new_risk_term", + clause: "Payment", + detail: "The modified clause introduces payment on demand.", + severity: "HIGH", + }, + ], + notes: ["Comparison is based on normalized clause text."], +}; diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts new file mode 100644 index 0000000..0ecbed3 --- /dev/null +++ b/apps/web/src/test/setup.ts @@ -0,0 +1,5 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(() => cleanup()); diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/apps/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json new file mode 100644 index 0000000..57b41cf --- /dev/null +++ b/apps/web/tsconfig.app.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src"] +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/apps/web/tsconfig.node.json b/apps/web/tsconfig.node.json new file mode 100644 index 0000000..eb50ba0 --- /dev/null +++ b/apps/web/tsconfig.node.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true + }, + "include": ["vite.config.ts", "eslint.config.js"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..5a2d1e1 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,39 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + server: { + host: "127.0.0.1", + port: 5173, + strictPort: true, + proxy: { + "/api": { + target: "http://127.0.0.1:8080", + changeOrigin: false, + }, + }, + }, + preview: { + host: "127.0.0.1", + port: 4173, + strictPort: true, + }, + test: { + environment: "jsdom", + setupFiles: "./src/test/setup.ts", + css: true, + coverage: { + provider: "v8", + reporter: ["text", "json-summary"], + include: ["src/**/*.{ts,tsx}"], + exclude: ["src/**/*.test.{ts,tsx}", "src/test/**", "src/main.tsx", "src/vite-env.d.ts"], + thresholds: { + lines: 75, + functions: 75, + statements: 75, + branches: 65, + }, + }, + }, +}); From ce4999481e6096a3af997b155b531c9949bdfe55 Mon Sep 17 00:00:00 2001 From: arpitJ-dev Date: Tue, 11 Aug 2026 16:22:47 -0700 Subject: [PATCH 4/7] feat(demo): serve and validate the full stack --- .env.example | 1 + .github/workflows/ci.yml | 44 ++++++ .gitignore | 3 +- apps/server/cmd/clauseguard-server/main.go | 21 ++- .../cmd/clauseguard-server/main_test.go | 91 +++++++++++ apps/server/internal/api/server.go | 51 +++++++ apps/server/internal/api/server_test.go | 56 +++++++ apps/server/internal/config/config.go | 12 ++ apps/server/internal/config/config_test.go | 4 + apps/web/e2e/fixtures/modified.txt | 10 ++ apps/web/e2e/fixtures/original.txt | 10 ++ apps/web/e2e/global-setup.ts | 122 +++++++++++++++ apps/web/e2e/workbench.spec.ts | 64 ++++++++ apps/web/eslint.config.js | 4 +- apps/web/package-lock.json | 64 ++++++++ apps/web/package.json | 2 + apps/web/playwright.config.ts | 20 +++ .../web/src/components/AnalysisReportView.tsx | 9 +- apps/web/src/components/JobProgress.tsx | 9 +- apps/web/src/styles.css | 18 ++- apps/web/vite.config.ts | 1 + scripts/start_demo.py | 142 ++++++++++++++++++ tests/test_demo_launcher.py | 27 ++++ 23 files changed, 764 insertions(+), 21 deletions(-) create mode 100644 apps/server/cmd/clauseguard-server/main_test.go create mode 100644 apps/web/e2e/fixtures/modified.txt create mode 100644 apps/web/e2e/fixtures/original.txt create mode 100644 apps/web/e2e/global-setup.ts create mode 100644 apps/web/e2e/workbench.spec.ts create mode 100644 apps/web/playwright.config.ts create mode 100644 scripts/start_demo.py create mode 100644 tests/test_demo_launcher.py diff --git a/.env.example b/.env.example index 99838ba..3d32fb6 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,7 @@ CLAUSEGUARD_SERVER_ADDR=127.0.0.1:8080 CLAUSEGUARD_PYTHON=python CLAUSEGUARD_WORKSPACE= CLAUSEGUARD_DATA_DIR=.clauseguard +CLAUSEGUARD_WEB_DIR=apps/web/dist CLAUSEGUARD_MAX_UPLOAD_BYTES=26214400 CLAUSEGUARD_JOB_TIMEOUT=10m CLAUSEGUARD_MAX_CONCURRENT_JOBS=2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aae15e1..84b2f60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,23 @@ jobs: - name: Test run: python -m pytest --cov=clauseguard --cov-report=term-missing --cov-fail-under=80 + web: + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/web + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: apps/web/package-lock.json + - run: npm ci + - run: npm run lint + - run: npm run test:coverage + - run: npm run build + control-plane: runs-on: ubuntu-latest defaults: @@ -63,3 +80,30 @@ jobs: awk -v total="$total" 'BEGIN { if (total < 70) exit 1 }' - name: Build run: go build ./... + + demo-e2e: + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/web + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - uses: actions/setup-go@v5 + with: + go-version-file: apps/server/go.mod + cache-dependency-path: apps/server/go.sum + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: apps/web/package-lock.json + - name: Install application + working-directory: . + run: python -m pip install -e . + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e diff --git a/.gitignore b/.gitignore index 677989d..135e935 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,8 @@ env/ # Vectorstore qdrant_storage -.clauseguard/ +.clauseguard*/ +.runtime-*/ analysis_outputs/ test_outputs/ diff --git a/apps/server/cmd/clauseguard-server/main.go b/apps/server/cmd/clauseguard-server/main.go index 10a969f..de5b889 100644 --- a/apps/server/cmd/clauseguard-server/main.go +++ b/apps/server/cmd/clauseguard-server/main.go @@ -19,13 +19,15 @@ import ( ) func main() { - if err := run(); err != nil { + signalContext, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := run(signalContext); err != nil { slog.Error("ClauseGuard server stopped", "error", err) os.Exit(1) } } -func run() error { +func run(runContext context.Context) error { settings, err := config.Load() if err != nil { return err @@ -52,11 +54,14 @@ func run() error { } handler, err := api.New(manager, api.Config{ DataDir: settings.DataDir, + WebDir: settings.WebDir, MaxUploadBytes: settings.MaxUploadBytes, AllowedOrigin: settings.AllowedOrigin, }) if err != nil { - return err + shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return errors.Join(err, manager.Shutdown(shutdownContext)) } httpServer := &http.Server{ @@ -71,18 +76,18 @@ func run() error { slog.Info( "ClauseGuard control plane listening", "address", settings.Address, + "web_dir", settings.WebDir, "mock_models", settings.MockModels, ) serverErrors <- httpServer.ListenAndServe() }() - signalContext, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() + var listenError error select { - case <-signalContext.Done(): + case <-runContext.Done(): case err := <-serverErrors: if !errors.Is(err, http.ErrServerClosed) { - return err + listenError = err } } @@ -94,5 +99,5 @@ func run() error { }() managerError := manager.Shutdown(shutdownContext) serverError := <-serverShutdown - return errors.Join(serverError, managerError) + return errors.Join(listenError, serverError, managerError) } diff --git a/apps/server/cmd/clauseguard-server/main_test.go b/apps/server/cmd/clauseguard-server/main_test.go new file mode 100644 index 0000000..2eb7db1 --- /dev/null +++ b/apps/server/cmd/clauseguard-server/main_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestRunStartsAndShutsDown(t *testing.T) { + workspace := t.TempDir() + writeTestFile(t, filepath.Join(workspace, "pyproject.toml"), "[project]\n") + if err := os.Mkdir(filepath.Join(workspace, "clauseguard"), 0o750); err != nil { + t.Fatal(err) + } + webDir := filepath.Join(workspace, "apps", "web", "dist") + writeTestFile(t, filepath.Join(webDir, "index.html"), "
ClauseGuard
") + + t.Setenv("CLAUSEGUARD_WORKSPACE", workspace) + t.Setenv("CLAUSEGUARD_DATA_DIR", filepath.Join(workspace, ".runtime-test")) + t.Setenv("CLAUSEGUARD_WEB_DIR", webDir) + t.Setenv("CLAUSEGUARD_SERVER_ADDR", "127.0.0.1:0") + t.Setenv("CLAUSEGUARD_MOCK_MODELS", "true") + + runContext, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { + result <- run(runContext) + }() + + time.Sleep(100 * time.Millisecond) + cancel() + select { + case err := <-result: + if err != nil { + t.Fatalf("server shutdown failed: %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("server did not stop after cancellation") + } +} + +func TestRunFailsClearlyWithoutWebBundle(t *testing.T) { + workspace := t.TempDir() + writeTestFile(t, filepath.Join(workspace, "pyproject.toml"), "[project]\n") + if err := os.Mkdir(filepath.Join(workspace, "clauseguard"), 0o750); err != nil { + t.Fatal(err) + } + t.Setenv("CLAUSEGUARD_WORKSPACE", workspace) + t.Setenv("CLAUSEGUARD_DATA_DIR", filepath.Join(workspace, ".runtime-test")) + t.Setenv("CLAUSEGUARD_WEB_DIR", filepath.Join(workspace, "missing-web")) + t.Setenv("CLAUSEGUARD_SERVER_ADDR", "127.0.0.1:0") + + err := run(context.Background()) + if err == nil { + t.Fatal("server started without a web bundle") + } +} + +func TestRunCleansUpAfterListenFailure(t *testing.T) { + workspace := t.TempDir() + writeTestFile(t, filepath.Join(workspace, "pyproject.toml"), "[project]\n") + if err := os.Mkdir(filepath.Join(workspace, "clauseguard"), 0o750); err != nil { + t.Fatal(err) + } + webDir := filepath.Join(workspace, "apps", "web", "dist") + writeTestFile(t, filepath.Join(webDir, "index.html"), "
ClauseGuard
") + + t.Setenv("CLAUSEGUARD_WORKSPACE", workspace) + t.Setenv("CLAUSEGUARD_DATA_DIR", filepath.Join(workspace, ".runtime-test")) + t.Setenv("CLAUSEGUARD_WEB_DIR", webDir) + t.Setenv("CLAUSEGUARD_SERVER_ADDR", "127.0.0.1:-1") + t.Setenv("CLAUSEGUARD_MOCK_MODELS", "true") + + err := run(context.Background()) + if err == nil || !strings.Contains(err.Error(), "invalid port") { + t.Fatalf("expected a listen error, got %v", err) + } +} + +func writeTestFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/apps/server/internal/api/server.go b/apps/server/internal/api/server.go index 7e1a53d..ab1fc53 100644 --- a/apps/server/internal/api/server.go +++ b/apps/server/internal/api/server.go @@ -43,6 +43,7 @@ type JobService interface { type Config struct { DataDir string + WebDir string MaxUploadBytes int64 AllowedOrigin string } @@ -50,6 +51,7 @@ type Config struct { type Server struct { service JobService dataDir string + webDir string maxUploadBytes int64 allowedOrigin string } @@ -74,9 +76,22 @@ func New(service JobService, config Config) (http.Handler, error) { if err != nil { return nil, fmt.Errorf("resolve API data directory: %w", err) } + webDir := strings.TrimSpace(config.WebDir) + if webDir != "" { + webDir, err = filepath.Abs(webDir) + if err != nil { + return nil, fmt.Errorf("resolve web directory: %w", err) + } + indexPath := filepath.Join(webDir, "index.html") + indexInfo, statErr := os.Stat(indexPath) + if statErr != nil || indexInfo.IsDir() { + return nil, fmt.Errorf("web bundle not found at %s; run the frontend build first", indexPath) + } + } server := &Server{ service: service, dataDir: dataDir, + webDir: webDir, maxUploadBytes: config.MaxUploadBytes, allowedOrigin: strings.TrimSpace(config.AllowedOrigin), } @@ -90,9 +105,43 @@ func New(service JobService, config Config) (http.Handler, error) { mux.HandleFunc("GET /api/v1/jobs/{id}/report", server.handleReport) mux.HandleFunc("DELETE /api/v1/jobs/{id}", server.handleDeleteJob) mux.HandleFunc("GET /api/v1/health", server.handleHealth) + mux.HandleFunc("/", server.handleWeb) return server.middleware(mux), nil } +func (server *Server) handleWeb(writer http.ResponseWriter, request *http.Request) { + if strings.HasPrefix(request.URL.Path, "/api/") || server.webDir == "" { + writeError(writer, http.StatusNotFound, "route_not_found", "The requested route does not exist.") + return + } + if request.Method != http.MethodGet && request.Method != http.MethodHead { + writeError(writer, http.StatusMethodNotAllowed, "method_not_allowed", "The request method is not allowed.") + return + } + + cleaned := path.Clean("/" + request.URL.Path) + if cleaned != "/" { + relative := strings.TrimPrefix(cleaned, "/") + target := filepath.Join(server.webDir, filepath.FromSlash(relative)) + if within(server.webDir, target) { + if info, err := os.Stat(target); err == nil && !info.IsDir() { + if strings.HasPrefix(cleaned, "/assets/") { + writer.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } + http.ServeFile(writer, request, target) + return + } + } + if filepath.Ext(cleaned) != "" { + http.NotFound(writer, request) + return + } + } + + writer.Header().Set("Cache-Control", "no-cache") + http.ServeFile(writer, request, filepath.Join(server.webDir, "index.html")) +} + func (server *Server) handleAnalysis(writer http.ResponseWriter, request *http.Request) { server.createJob(writer, request, domain.JobTypeAnalysis, []string{"document"}) } @@ -378,6 +427,8 @@ func (server *Server) middleware(next http.Handler) http.Handler { writer.Header().Set("X-Content-Type-Options", "nosniff") writer.Header().Set("Referrer-Policy", "no-referrer") writer.Header().Set("X-Frame-Options", "DENY") + writer.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'") + writer.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") origin := strings.TrimSpace(request.Header.Get("Origin")) if origin != "" { if !server.originAllowed(request, origin) { diff --git a/apps/server/internal/api/server_test.go b/apps/server/internal/api/server_test.go index 0242754..20dd378 100644 --- a/apps/server/internal/api/server_test.go +++ b/apps/server/internal/api/server_test.go @@ -342,6 +342,62 @@ func TestShuttingDownReturnsUnavailable(t *testing.T) { } } +func TestWebBundleAndSPARoutesAreServed(t *testing.T) { + webDir := t.TempDir() + if err := os.Mkdir(filepath.Join(webDir, "assets"), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(webDir, "index.html"), []byte("
ClauseGuard
"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(webDir, "assets", "app.js"), []byte("console.log('ready')"), 0o600); err != nil { + t.Fatal(err) + } + service := &stubService{health: domain.Health{Ready: true}} + handler, err := New(service, Config{DataDir: t.TempDir(), WebDir: webDir, MaxUploadBytes: 1 << 20}) + if err != nil { + t.Fatal(err) + } + + for _, route := range []string{"/", "/reviews/current"} { + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, route, nil)) + if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "ClauseGuard") { + t.Fatalf("SPA route %s returned %d: %s", route, recorder.Code, recorder.Body.String()) + } + if recorder.Header().Get("Content-Security-Policy") == "" { + t.Fatalf("SPA route %s omitted browser security headers", route) + } + } + + asset := httptest.NewRecorder() + handler.ServeHTTP(asset, httptest.NewRequest(http.MethodGet, "/assets/app.js", nil)) + if asset.Code != http.StatusOK || asset.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" { + t.Fatalf("asset was not served with immutable caching: %d %+v", asset.Code, asset.Header()) + } + + missingAsset := httptest.NewRecorder() + handler.ServeHTTP(missingAsset, httptest.NewRequest(http.MethodGet, "/assets/missing.js", nil)) + if missingAsset.Code != http.StatusNotFound { + t.Fatalf("missing asset returned %d", missingAsset.Code) + } + + missingAPI := httptest.NewRecorder() + handler.ServeHTTP(missingAPI, httptest.NewRequest(http.MethodGet, "/api/v1/missing", nil)) + if missingAPI.Code != http.StatusNotFound || !strings.Contains(missingAPI.Body.String(), "route_not_found") { + t.Fatalf("unknown API route returned %d: %s", missingAPI.Code, missingAPI.Body.String()) + } +} + +func TestWebBundleMustContainIndex(t *testing.T) { + _, err := New(&stubService{}, Config{ + DataDir: t.TempDir(), WebDir: t.TempDir(), MaxUploadBytes: 1 << 20, + }) + if err == nil || !strings.Contains(err.Error(), "web bundle not found") { + t.Fatalf("missing web bundle returned %v", err) + } +} + type upload struct { name string content string diff --git a/apps/server/internal/config/config.go b/apps/server/internal/config/config.go index f171b05..6191e03 100644 --- a/apps/server/internal/config/config.go +++ b/apps/server/internal/config/config.go @@ -26,6 +26,7 @@ type Config struct { PythonExecutable string Workspace string DataDir string + WebDir string MaxUploadBytes int64 JobTimeout time.Duration MaxConcurrentJobs int @@ -53,6 +54,16 @@ func Load() (Config, error) { if err != nil { return Config{}, fmt.Errorf("resolve data directory: %w", err) } + webDir := strings.TrimSpace(os.Getenv("CLAUSEGUARD_WEB_DIR")) + if webDir == "" { + webDir = filepath.Join(workspace, "apps", "web", "dist") + } else if !filepath.IsAbs(webDir) { + webDir = filepath.Join(workspace, webDir) + } + webDir, err = filepath.Abs(webDir) + if err != nil { + return Config{}, fmt.Errorf("resolve web directory: %w", err) + } maxUploadBytes, err := positiveInt64("CLAUSEGUARD_MAX_UPLOAD_BYTES", defaultMaxUploadBytes) if err != nil { @@ -92,6 +103,7 @@ func Load() (Config, error) { PythonExecutable: python, Workspace: workspace, DataDir: dataDir, + WebDir: webDir, MaxUploadBytes: maxUploadBytes, JobTimeout: jobTimeout, MaxConcurrentJobs: maxConcurrentJobs, diff --git a/apps/server/internal/config/config_test.go b/apps/server/internal/config/config_test.go index f056dfb..fc0a577 100644 --- a/apps/server/internal/config/config_test.go +++ b/apps/server/internal/config/config_test.go @@ -18,6 +18,7 @@ func TestLoadUsesValidatedEnvironment(t *testing.T) { dataDir := filepath.Join(t.TempDir(), "runtime") t.Setenv("CLAUSEGUARD_WORKSPACE", workspace) t.Setenv("CLAUSEGUARD_DATA_DIR", dataDir) + t.Setenv("CLAUSEGUARD_WEB_DIR", "web-build") t.Setenv("CLAUSEGUARD_PYTHON", "python-test") t.Setenv("CLAUSEGUARD_SERVER_ADDR", "127.0.0.1:9000") t.Setenv("CLAUSEGUARD_MAX_UPLOAD_BYTES", "4096") @@ -34,6 +35,9 @@ func TestLoadUsesValidatedEnvironment(t *testing.T) { if loaded.Workspace != workspace || loaded.DataDir != dataDir { t.Fatalf("unexpected paths: %+v", loaded) } + if loaded.WebDir != filepath.Join(workspace, "web-build") { + t.Fatalf("unexpected web directory: %q", loaded.WebDir) + } if loaded.PythonExecutable != "python-test" || loaded.Address != "127.0.0.1:9000" { t.Fatalf("unexpected process configuration: %+v", loaded) } diff --git a/apps/web/e2e/fixtures/modified.txt b/apps/web/e2e/fixtures/modified.txt new file mode 100644 index 0000000..7108e29 --- /dev/null +++ b/apps/web/e2e/fixtures/modified.txt @@ -0,0 +1,10 @@ +SERVICE AGREEMENT + +1. Payment +Customer shall pay all invoices immediately on demand. + +2. Termination +Provider may terminate this agreement at any time in its sole discretion without notice. + +3. Governing Law +This agreement is governed by the laws of California. diff --git a/apps/web/e2e/fixtures/original.txt b/apps/web/e2e/fixtures/original.txt new file mode 100644 index 0000000..05aebff --- /dev/null +++ b/apps/web/e2e/fixtures/original.txt @@ -0,0 +1,10 @@ +SERVICE AGREEMENT + +1. Payment +Customer shall pay valid invoices within thirty days of receipt. + +2. Termination +Either party may terminate this agreement for material breach after ten days' written notice and an opportunity to cure. + +3. Governing Law +This agreement is governed by the laws of California. diff --git a/apps/web/e2e/global-setup.ts b/apps/web/e2e/global-setup.ts new file mode 100644 index 0000000..7680738 --- /dev/null +++ b/apps/web/e2e/global-setup.ts @@ -0,0 +1,122 @@ +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const address = "127.0.0.1:18080"; +const healthURL = `http://${address}/api/v1/health`; + +function goExecutable(): string { + const candidates = ["go"]; + if (process.env.GOROOT) { + candidates.unshift(join(process.env.GOROOT, "bin", process.platform === "win32" ? "go.exe" : "go")); + } + if (process.platform === "win32" && process.env.ProgramFiles) { + candidates.unshift(join(process.env.ProgramFiles, "Go", "bin", "go.exe")); + } + + for (const candidate of candidates) { + if (candidate !== "go" && !existsSync(candidate)) { + continue; + } + const probe = spawnSync(candidate, ["version"], { encoding: "utf8" }); + if (!probe.error && probe.status === 0) { + return candidate; + } + } + throw new Error("Go is required to run the full-stack browser tests."); +} + +function waitForExit(process: ChildProcess, timeoutMs: number): Promise { + if (process.exitCode !== null) { + return Promise.resolve(true); + } + + return new Promise((resolveExit) => { + const timeout = setTimeout(() => resolveExit(false), timeoutMs); + process.once("exit", () => { + clearTimeout(timeout); + resolveExit(true); + }); + }); +} + +async function waitForHealth(process: ChildProcess): Promise { + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + if (process.exitCode !== null) { + throw new Error(`ClauseGuard server exited during startup (${process.exitCode}).`); + } + try { + const response = await fetch(healthURL, { signal: AbortSignal.timeout(1_000) }); + if (response.ok) { + return; + } + } catch { + // The socket is expected to reject connections until the server is ready. + } + await new Promise((resolveWait) => setTimeout(resolveWait, 200)); + } + throw new Error(`ClauseGuard server did not become healthy at ${healthURL}.`); +} + +export default async function globalSetup(): Promise<() => Promise> { + const root = resolve(import.meta.dirname, "../../.."); + const serverDir = resolve(root, "apps/server"); + const runtimeDir = resolve(root, "apps/web/.runtime-e2e"); + const dataDir = resolve(runtimeDir, "data"); + const serverBinary = resolve( + runtimeDir, + process.platform === "win32" ? "clauseguard-server.exe" : "clauseguard-server", + ); + + rmSync(runtimeDir, { recursive: true, force: true }); + mkdirSync(dataDir, { recursive: true }); + + const build = spawnSync( + goExecutable(), + ["build", "-buildvcs=false", "-o", serverBinary, "./cmd/clauseguard-server"], + { + cwd: serverDir, + encoding: "utf8", + }, + ); + if (build.error || build.status !== 0) { + const details = build.error?.message ?? `${build.stdout}\n${build.stderr}`; + throw new Error(`Go server build failed:\n${details}`); + } + + const server = spawn(serverBinary, [], { + cwd: serverDir, + env: { + ...process.env, + CLAUSEGUARD_DATA_DIR: dataDir, + CLAUSEGUARD_MOCK_MODELS: "true", + CLAUSEGUARD_SERVER_ADDR: address, + CLAUSEGUARD_WEB_DIR: resolve(root, "apps/web/dist"), + CLAUSEGUARD_WORKSPACE: root, + }, + stdio: "inherit", + windowsHide: true, + }); + + try { + await waitForHealth(server); + } catch (error) { + server.kill(); + await waitForExit(server, 2_000); + throw error; + } + + return async () => { + if (server.exitCode !== null) { + return; + } + server.kill(); + if (!(await waitForExit(server, 5_000))) { + server.kill("SIGKILL"); + if (!(await waitForExit(server, 2_000))) { + throw new Error("ClauseGuard E2E server did not stop."); + } + } + }; +} diff --git a/apps/web/e2e/workbench.spec.ts b/apps/web/e2e/workbench.spec.ts new file mode 100644 index 0000000..00d6ef4 --- /dev/null +++ b/apps/web/e2e/workbench.spec.ts @@ -0,0 +1,64 @@ +import { expect, type Page, test } from "@playwright/test"; +import path from "node:path"; + +const fixtures = path.resolve(import.meta.dirname, "fixtures"); +const browserErrors = new WeakMap(); + +async function openIntake(page: Page): Promise { + const menu = page.getByRole("button", { name: "Toggle workspace panel" }); + if (await menu.isVisible()) await menu.click(); +} + +test.beforeEach(async ({ page }) => { + const errors: string[] = []; + browserErrors.set(page, errors); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/"); + await expect(page.getByText("Engine ready")).toBeAttached(); + await openIntake(page); +}); + +test.afterEach(async ({ page }) => { + expect(browserErrors.get(page) ?? []).toEqual([]); +}); + +test("analyzes a contract and exposes an explainable report", async ({ page }) => { + const response = await page.request.get("/"); + expect(response.headers()["content-security-policy"]).toContain("default-src 'self'"); + expect(response.headers()["permissions-policy"]).toContain("camera=()"); + + await page + .getByLabel("Contract", { exact: true }) + .setInputFiles(path.join(fixtures, "modified.txt")); + await page.getByRole("button", { name: "Run analysis" }).click(); + + await expect(page.getByRole("heading", { name: "SERVICE AGREEMENT" })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByRole("heading", { name: "Accepted findings" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Score breakdown" }).first()).toBeVisible(); + await expect(page.getByRole("heading", { name: "Supporting evidence" }).first()).toBeVisible(); + + await page.getByRole("button", { name: "Audit" }).click(); + await expect(page.getByRole("heading", { name: "Analysis record" })).toBeVisible(); + await expect(page.getByText("Review limitations")).toBeVisible(); +}); + +test("compares contract versions and identifies clause deltas", async ({ page }) => { + await page.getByRole("button", { name: "Compare" }).click(); + await page + .getByLabel("Original", { exact: true }) + .setInputFiles(path.join(fixtures, "original.txt")); + await page + .getByLabel("Modified", { exact: true }) + .setInputFiles(path.join(fixtures, "modified.txt")); + await page.getByRole("button", { name: "Compare versions" }).click(); + + await expect(page.getByText("Version comparison")).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("heading", { name: "Clause changes" })).toBeVisible(); + await expect(page.getByText("Changed", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("immediately on demand", { exact: false }).first()).toBeVisible(); +}); diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js index 7141de7..97de46a 100644 --- a/apps/web/eslint.config.js +++ b/apps/web/eslint.config.js @@ -3,7 +3,9 @@ import reactHooks from "eslint-plugin-react-hooks"; import tseslint from "typescript-eslint"; export default tseslint.config( - { ignores: ["dist", "coverage"] }, + { + ignores: ["dist", "coverage", "playwright-report", "test-results", ".runtime-e2e"], + }, js.configs.recommended, ...tseslint.configs.recommended, reactHooks.configs.flat.recommended, diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 1cab415..44e4061 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -14,6 +14,7 @@ }, "devDependencies": { "@eslint/js": "9.39.2", + "@playwright/test": "1.62.1", "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "16.3.2", "@testing-library/user-event": "14.6.3", @@ -789,6 +790,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", @@ -3344,6 +3361,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index f6e5ed2..501c5f7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,6 +9,7 @@ "lint": "eslint . --max-warnings 0", "test": "vitest run", "test:coverage": "vitest run --coverage", + "test:e2e": "npm run build && playwright test", "preview": "vite preview" }, "dependencies": { @@ -18,6 +19,7 @@ }, "devDependencies": { "@eslint/js": "9.39.2", + "@playwright/test": "1.62.1", "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "16.3.2", "@testing-library/user-event": "14.6.3", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000..aa4d6a7 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + globalSetup: "./e2e/global-setup.ts", + fullyParallel: false, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: process.env.CI ? "github" : "list", + use: { + baseURL: "http://127.0.0.1:18080", + screenshot: "only-on-failure", + trace: "retain-on-failure", + }, + projects: [ + { name: "desktop-chromium", use: { ...devices["Desktop Chrome"] } }, + { name: "mobile-chromium", use: { ...devices["Pixel 7"] } }, + ], +}); diff --git a/apps/web/src/components/AnalysisReportView.tsx b/apps/web/src/components/AnalysisReportView.tsx index 1f8724c..2188cc7 100644 --- a/apps/web/src/components/AnalysisReportView.tsx +++ b/apps/web/src/components/AnalysisReportView.tsx @@ -92,9 +92,12 @@ function FindingCard({ return (
{label} - - - + {formatScore(value)}
); diff --git a/apps/web/src/components/JobProgress.tsx b/apps/web/src/components/JobProgress.tsx index 8a8a19b..c5f4550 100644 --- a/apps/web/src/components/JobProgress.tsx +++ b/apps/web/src/components/JobProgress.tsx @@ -28,9 +28,12 @@ export function JobProgress({ job, connected, onCancel }: JobProgressProps) {

{job.message}

-
- -
+
{progress}%