diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..5c19b0f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,36 @@ +--- +name: Bug Report +about: Report a bug in AigisCode +title: '[Bug] ' +labels: bug +assignees: '' +--- + +## Description + +A clear description of what the bug is. + +## Steps to Reproduce + +1. Run `aigiscode ...` +2. ... +3. See error + +## Expected Behavior + +What you expected to happen. + +## Actual Behavior + +What actually happened. Include error messages or output. + +## Environment + +- **AigisCode version:** (`aigiscode --version`) +- **Python version:** (`python --version`) +- **OS:** (e.g., Ubuntu 24.04, macOS 15) +- **Project languages:** (e.g., PHP + TypeScript) + +## Additional Context + +Any other context, logs, or screenshots. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..9533e7c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature Request +about: Suggest a new feature for AigisCode +title: '[Feature] ' +labels: enhancement +assignees: '' +--- + +## Problem + +What problem does this feature solve? What's your use case? + +## Proposed Solution + +How would you like this to work? + +## Alternatives Considered + +Any other approaches you've thought about. + +## Additional Context + +Examples, links, or references that might help. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ce35828 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,21 @@ +## Description + +What does this PR do? Link to related issues. + +## Type of Change + +- [ ] Bug fix +- [ ] New feature +- [ ] Breaking change +- [ ] Documentation update + +## Testing + +What tests were added or modified? + +## Checklist + +- [ ] Tests pass (`python -m pytest tests/ -v`) +- [ ] Type hints added for new code +- [ ] Documentation updated (if applicable) +- [ ] Follows existing code patterns diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ff04461 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install pytest + + - name: Run tests + run: python -m pytest tests/ -v + + website: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install and build website + working-directory: website + run: | + npm ci + npm run build diff --git a/.gitignore b/.gitignore index 2d70ecb..334d50e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ node_modules/ website/dist/ .env uv.lock +.memories/ +website/.memories/ +docs/plans/ diff --git a/.plan/TODO.md b/.plan/TODO.md new file mode 100644 index 0000000..4148b04 --- /dev/null +++ b/.plan/TODO.md @@ -0,0 +1,78 @@ +# TODO + +## Backlog +- [ ] Extend Rust-specific external analysis using a large reference workspace: evaluate `cargo-audit` vs `osv-scanner` overlap for Rust SCA, decide whether `cargo-geiger` adds enough value as an optional unsafe-surface signal, and tune defaults around the shipped `cargo-deny`/`cargo-clippy` integrations. +- [ ] Calibrate Rust detector fidelity against `zed`: after enabling native Rust dead-code/hardwiring support, the real report now shows `3822` dead-code findings and `901` hardwiring findings on the current clone (`1725` files, `1706` Rust files, `60695` symbols, `41722` dependencies). Reduce obvious noise before trusting those counts as actionability signals. +- [ ] Fix `analyze --skip-synthesis` so it actually skips Phase 3 semantic-envelope generation; the flag was ignored during real `../zed` runs on March 11, 2026. +- [ ] Make semantic-envelope generation scale for large repos like `../zed`; the live run only advanced a few files after minutes, which blocks end-to-end AI synthesis on real Rust-heavy codebases. +- [ ] Add end-to-end coverage for AI review/report serialization using a reproducible local backend harness instead of stubs. +- [ ] Evaluate optional integration points for Semgrep, CodeQL, gitleaks/trufflehog, and dependency vulnerability scanners. +- [ ] Rename remaining internal/backend identifiers that still say `codex_sdk` even though the Python path uses the OpenAI Responses API directly. +- [ ] Add backend provenance to AI-generated report sections so downstream agents can distinguish Codex SDK, Codex CLI, and Claude-backed outputs. +- [ ] Design a normalized security finding schema with stable fingerprints, tool provenance, SARIF import/export, and package/license metadata for external scanners. +- [ ] Prototype external scanner ingestion for `osv-scanner`, `gitleaks`, `bandit`, and `psalm`, preserving raw artifacts under `.aigiscode/reports//raw/`. +- [ ] Decide whether Semgrep is acceptable despite LGPL engine licensing and the Semgrep Rules License on the maintained rules repo, or whether custom/internal rules should be the default. +- [ ] Decide whether CodeQL can be offered only as an opt-in integration because its published usage terms restrict automated use outside the documented open-source/research cases. +- [ ] Design a normalized external-security import layer for Semgrep, Gitleaks, pip-audit, OSV-Scanner, and RustSec outputs under a stable AigisCode schema. +- [ ] Extend the self-healing loop into external-finding triage so imported scanner results can also become actionable, accepted/suppressed, or informational instead of staying outside AI/rules feedback. +- [ ] Replace manual JSON parsing in AI review/security review with OpenAI Responses structured outputs as the primary path, keeping Codex CLI/Claude JSON prompting as fallbacks. +- [ ] Integration note from official docs: use JSON as the required ingest format across Gitleaks, pip-audit, OSV-Scanner, PHPStan, Ruff, Biome, and ESLint; prefer native SARIF only where officially supported (Gitleaks, OSV-Scanner, Ruff, Biome), and treat PHPStan/ESLint as JSON-first unless we add a converter or custom formatter layer. +- [ ] Define a language-agnostic architectural rule engine for YAGNI/overengineering/principles analysis with adapters for PHP, Python, JS/TS, Ruby, and Rust. +- [ ] Time-box or background heavy external analyzers like `phpstan` on large repos so fast security runs do not stall `analyze`. +- [ ] Normalize noisy tool stderr (for example Composer deprecation notices on PHP 8.5) before surfacing tool summaries in reports. +- [ ] Add timeouts and per-tool opt-in defaults for long-running external analyzers like PHPStan on very large repos. +- [ ] Calibrate and deduplicate the 243 imported Ruff security findings in `../newerp` before enabling broader external-tool defaults. +- [ ] Add CLI e2e coverage for `report --external-tool` / `report --run-ruff-security`, including unsupported, unavailable, and failed tool-run cases. +- [ ] Decide whether external findings should enter the rules/AI-review loop; today they are collected after Phase 2c, so they are reported but never triaged or suppressible through the existing review pipeline. +- [ ] Expand self-analysis coverage: run AigisCode against its own repo and codify any recurring architectural smells into rules/docs. +- [ ] Add architecture governance and enforcement guidance to `AGENTS.md`, including forbidden dependencies, exception process, and change-rejection criteria. +- [ ] Keep expanding `AGENTS.md` as the product constitution: generic core, pluggable analyzers, rule-engine-first design, and language -> framework -> platform support strategy. +- [ ] Codify Python architecture governance as executable contracts: Import Linter layer/forbidden/protected rules, Ruff banned APIs/imports, mypy strict module boundaries, and a dedicated exception registry with owners/expiry. +- [ ] Add architecture import-boundary tests so detector modules cannot depend directly on plugin dispatch/orchestration layers. +- [ ] Extract shared analysis/report assembly from `cli.py` so `analyze` and `report` stop duplicating the same orchestration flow. +- [ ] Extract a deterministic analysis-assembly service from `cli.py` for shared policy/plugin resolution, graph+detector execution, rules/external filtering, and `ReportData` construction, leaving Rich/Typer messaging in the CLI. +- [ ] Move semantic-envelope layer classification behind a dedicated boundary instead of importing graph heuristics directly into `workers/codex.py`. +- [ ] Replace `ReportData.dead_code/review/hardwiring` `Any` fields with typed report DTOs or protocols so report-layer boundaries are explicit and enforceable. +- [ ] Keep reducing self-analysis hardwiring noise in core modules (`rules/engine.py`, `graph/hardwiring.py`, `cli.py`, `policy/plugins.py`) before deciding what website styling literals should remain reportable. +- [ ] Address the remaining self-analysis Ruff security imports: dynamic SQL false positive in `graph/deadcode.py`, subprocess policy in `security/external.py`, and benign-IP handling in `graph/hardwiring.py`. + +## In Progress + +## Blocked + +## Done +- [x] Add first-class Rust dead-code and hardwiring detection so Rust no longer shows up as detector partial coverage in standard AigisCode reports +- [x] Run a real AI-backed evaluation on `../zed`, capture the resulting report/handoff, and verify what AigisCode currently concludes about the Rust reference workspace +- [x] Add first-pass Rust symbol and dependency extraction so indexed `.rs` files contribute basic graph analysis and import coverage +- [x] Add `cargo-deny` as a first-class external analyzer for Rust advisories/licenses/bans/sources and normalize its findings into AigisCode reports +- [x] Add `cargo-clippy` as a first-class external analyzer for Rust workspaces +- [x] Record the current Rust coverage baseline for `../zed` (`1725` supported source files in the current clone) so future Rust audit work has a real regression target +- [x] Clone `zed` into `../zed` and assess current Rust audit coverage gaps for the whole AigisCode suite +- [x] Implement a backend-neutral session handoff/brief artifact in AigisCode reports and CLI outputs so AI agents can resume work without chat context +- [x] Evaluate whether the Claude Code limit-management workflow from the referenced dev.to article should be adopted in AigisCode or the website/app tooling +- [x] Investigate security-analysis flow, Codex SDK integration, reporting outputs, and concrete bugs in the end-to-end pipeline +- [x] Add first-class security analysis output and report sections for high-signal hardcoded network and env findings +- [x] Add per-run report archival so agent findings are preserved under `.aigiscode/reports/` +- [x] Add end-to-end coverage for `aigiscode analyze` security findings and archived report outputs +- [x] Align the JSON report contract with documented `graph_analysis.*` paths and expose `--output-dir` across commands for dedicated report locations +- [x] Implement normalized external-security ingestion and archived per-run findings for `../newerp` +- [x] Preserve unsupported `--external-tool` selections as explicit failed tool runs instead of silently dropping them during normalization +- [x] Make report archival collision-safe when two runs share the same second-level timestamp, so archived findings are never overwritten +- [x] Generate the security-remediation recommendation for external-only security findings, not just internal hardwiring findings +- [x] Keep external raw artifacts and archived report files under the same collision-safe run directory across same-second runs +- [x] Harden CLI e2e archive assertions so collision-safe per-run report directories are selected deterministically +- [x] Add strict architecture governance rules and self-audit the AigisCode codebase against them +- [x] Move semantic-envelope layer classification behind a dedicated boundary instead of importing graph heuristics directly into `workers/codex.py` +- [x] Treat non-zero external tool exits with zero normalized findings as failed runs instead of clean passes +- [x] Add explicit product-direction guidance to `AGENTS.md` for generic, pluggable, rules-based analyzer architecture +- [x] Extract shared deterministic analysis/report assembly from `cli.py` into an orchestration module +- [x] Fix `report` so `--plugin`, `--policy-file`, and `--plugin-module` actually flow into policy/runtime resolution +- [x] Stop `analyze` and `report` from reserving archive run directories before a real report run exists +- [x] Teach TS/TSX unused-import analysis to respect JSX component usage so React icon imports are not flagged as dead code +- [x] Fix website locale-loader typing so `npm run lint` succeeds with nested translation JSON payloads +- [x] Split `src/aigiscode/indexer/store.py` into focused repositories behind a thin store shell so self-analysis no longer flags it as a god class +- [x] Reduce detector-internal hardwiring noise in core modules enough to bring self-analysis from 81 to 76 hardwiring findings while keeping dead code and god classes at zero +- [x] Make the self-healing loop first-class in reports and policy by exposing actionable / accepted-by-policy / informational dispositions and AI-feedback counts +- [x] Revise README and architecture/agent docs so the self-healing loop, informational policy, and external-security lifecycle are consistent everywhere +- [x] Extend external security findings into the same typed AI review / rules feedback loop as native detector findings +- [x] Correct backend naming/selection so authenticated Codex CLI sessions are preferred when available and Python code stops pretending the Responses API path is the Codex SDK diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3c521ba --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,166 @@ +# AGENTS.md + +> AigisCode is an AI-powered codebase evaluator. This file helps AI coding agents understand and use this project. + +## What This Project Does + +AigisCode analyzes entire codebases to find structural issues that single-file linters miss: +- Circular dependencies between modules (strong architectural cycles and total runtime cycles) +- Dead code (unused imports, unreferenced methods, orphaned properties, abandoned classes) +- Hardwired values (magic strings, repeated literals, hardcoded IPs and URLs, env access outside config) +- Layer violations, god classes, bottleneck files, orphan modules + +It runs a six-stage pipeline: Index, Graph, Detect, Rules, AI Review, Report. Output is machine-readable JSON at `.aigiscode/aigiscode-report.json`. Timestamped archives are also written under `.aigiscode/reports/`. + +## For AI Agents Using AigisCode + +### Quick Start + +```bash +pip install aigiscode +aigiscode analyze /path/to/project +``` + +The analysis report is written to `.aigiscode/aigiscode-report.json` inside the target project directory. + +### Reading Results + +Parse `.aigiscode/aigiscode-report.json`. Key fields: + +| JSON Path | Description | +|---|---| +| `graph_analysis.strong_circular_dependencies` | Architectural cycles (high priority) | +| `graph_analysis.circular_dependencies` | All cycles including runtime/load | +| `graph_analysis.god_classes` | Classes with excessive responsibility | +| `graph_analysis.bottleneck_files` | Files with high coupling | +| `graph_analysis.orphan_files` | Files with no inbound dependencies | +| `dead_code` | Unused imports, methods, properties, classes | +| `hardwiring` | Magic strings, repeated literals, hardcoded network | +| `extensions.contract_inventory` | Routes, hooks, env vars, config keys | +| `summary.detector_coverage` | Which detectors covered which languages | + +### Recommended Agent Workflow + +1. Run `aigiscode analyze /repo` to generate the baseline report. +2. Parse `.aigiscode/aigiscode-report.json` for structured findings. +3. Triage findings by severity and confidence. Start with `high` confidence findings. +4. Sample findings and classify as true positive, false positive, or uncertain. +5. Apply fixes for confirmed true positives. +6. Add exclusion rules for false positives to `.aigiscode/rules.json`. +7. Encode repeated false positive patterns in `.aigiscode/policy.json`. +8. Run `aigiscode report /repo` for fast re-evaluation after policy changes. + +### Policy Customization + +Create `.aigiscode/policy.json` to adapt behavior per project: + +```json +{ + "graph": { + "js_import_aliases": { "@/": "src/" }, + "orphan_entry_patterns": ["src/bootstrap/**/*.ts"] + }, + "dead_code": { + "abandoned_entry_patterns": ["/Contracts/"] + }, + "hardwiring": { + "repeated_literal_min_occurrences": 4, + "skip_path_patterns": ["app/Console/*"] + } +} +``` + +See [docs/PLUGIN_SYSTEM.md](docs/PLUGIN_SYSTEM.md) for all policy fields and plugin documentation. + +### What Agents Should Not Do + +- Do not accept lower finding counts without sampling the new findings. +- Do not widen suppressions until the pattern is clear across multiple findings. +- Do not treat AI review as a substitute for reading the referenced code. +- Do not patch aigiscode core when policy can express the rule. + +## For AI Agents Contributing to AigisCode + +### Project Structure + +``` +src/aigiscode/ +├── __init__.py # Package version +├── __main__.py # python -m aigiscode entry +├── cli.py # Typer CLI (6 commands: index, analyze, report, tune, info, plugins) +├── models.py # Pydantic data models +├── contracts.py # Runtime contract extraction +├── extensions.py # Plugin loading and extension dispatch +├── filters.py # Finding filtering +├── builtin_runtime_plugins.py # Built-in plugin profiles (generic, django, wordpress, laravel) +├── indexer/ +│ ├── parser.py # tree-sitter + Python AST parsing +│ ├── store.py # SQLite storage layer +│ └── symbols.py # Symbol extraction per language +├── graph/ +│ ├── builder.py # NetworkX graph construction +│ ├── analyzer.py # Cycles, coupling, layers, god classes +│ ├── deadcode.py # Dead code detection +│ └── hardwiring.py # Hardwired value detection +├── policy/ +│ ├── models.py # Policy Pydantic models +│ ├── plugins.py # Plugin profile loading and merge +│ └── analytical.py # AI-guided policy tuning +├── report/ +│ ├── generator.py # Markdown + JSON report generation +│ └── contracts.py # Contract inventory for reports +├── review/ +│ └── ai_reviewer.py # AI-assisted finding classification +├── rules/ +│ ├── engine.py # Exclusion rule engine +│ └── checks.py # Rule validation +├── ai/ +│ └── backends.py # AI backend adapters (OpenAI, Anthropic) +├── synthesis/ +│ └── claude.py # Claude-specific synthesis +└── workers/ + └── codex.py # Codex worker integration +``` + +### Build and Test + +```bash +# Install in development mode +uv pip install -e . + +# Run tests +python -m pytest tests/ -v + +# Run analysis on a project +aigiscode analyze /path/to/project + +# Run without AI backends (deterministic only) +aigiscode analyze /path/to/project --skip-ai +``` + +### Code Style + +- Python 3.12+ with full type hints on all function signatures +- Pydantic models for all data structures (see `models.py` and `policy/models.py`) +- tree-sitter for parsing PHP, TypeScript, JavaScript, Vue; Python AST for Python +- NetworkX for dependency graph construction and analysis +- Typer for CLI, Rich for terminal output +- Policy drives behavior --- do not hardcode project-specific logic into detectors + +### Architecture Principles + +- Generic analysis in core, project-specific behavior in policy and plugins +- Detectors emit candidates with confidence levels, not final verdicts +- AI review is final-stage classification, not first-pass detection +- Partial but explicit coverage over false certainty +- Prefer explainable heuristics over opaque model-only decisions +- Patch the analyzer core only when the issue reproduces across multiple codebases and policy cannot represent the distinction + +### Key Design Boundary + +An AI agent contributing to this project should prefer changing policy over changing analyzer code when: +- The false positive is project-specific +- The distinction can be expressed through an existing policy field +- The same detector is otherwise useful on other repositories + +Patch aigiscode core only when policy cannot represent the distinction cleanly. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c0a9377 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,124 @@ +# Contributing to AigisCode + +Thank you for your interest in contributing to AigisCode! This guide will help you get started. + +## Development Setup + +### Prerequisites + +- Python 3.12 or higher +- [uv](https://docs.astral.sh/uv/) (recommended) or pip + +### Getting Started + +```bash +# Fork and clone the repository +git clone https://github.com/david-strejc/aigiscode.git +cd aigiscode + +# Create a virtual environment and install dependencies +uv venv +source .venv/bin/activate +uv sync + +# Or with pip +python -m venv .venv +source .venv/bin/activate +pip install -e . +``` + +### Verify Your Setup + +```bash +# Run the CLI +aigiscode --help + +# Run the test suite +python -m pytest tests/ -v +``` + +## Making Changes + +### Workflow + +1. **Fork** the repository on GitHub +2. **Create a branch** from `main` for your changes: + ```bash + git checkout -b feature/your-feature-name + ``` +3. **Make your changes** following the code style guidelines below +4. **Write or update tests** for your changes +5. **Run the test suite** to make sure everything passes: + ```bash + python -m pytest tests/ -v + ``` +6. **Commit** with a clear message describing what and why +7. **Push** your branch and open a Pull Request + +### Commit Messages + +Use clear, descriptive commit messages: + +- `feat: add support for Rust analysis` +- `fix: handle empty files in parser` +- `docs: update plugin development guide` +- `refactor: simplify graph traversal logic` + +## Code Style Guidelines + +### Type Hints + +All functions and methods must include type hints. Use modern Python typing syntax: + +```python +def analyze_file(path: Path, depth: int = 3) -> AnalysisResult: + ... +``` + +### Pydantic Models + +Use Pydantic models for data structures and configuration. Follow existing patterns in `src/aigiscode/models/`: + +```python +class PluginConfig(BaseModel): + name: str + enabled: bool = True + options: dict[str, Any] = {} +``` + +### General Guidelines + +- Follow existing patterns in the codebase +- Keep functions focused and small +- Write docstrings for public APIs +- Prefer composition over inheritance +- Use `Path` objects instead of string paths + +## Plugin Development + +AigisCode has an extensible plugin system. If you want to create a new analyzer or reporter, see [docs/PLUGIN_SYSTEM.md](docs/PLUGIN_SYSTEM.md) for the plugin architecture and development guide. + +## Reporting Bugs + +Use the [Bug Report](https://github.com/david-strejc/aigiscode/issues/new?template=bug_report.md) issue template. Include: + +- Steps to reproduce the issue +- Expected vs. actual behavior +- Python version and OS +- AigisCode version (`aigiscode --version`) + +## Requesting Features + +Use the [Feature Request](https://github.com/david-strejc/aigiscode/issues/new?template=feature_request.md) issue template. Describe the problem you are trying to solve and your proposed solution. + +## Code of Conduct + +This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers. + +## Questions? + +Open a [Discussion](https://github.com/david-strejc/aigiscode/discussions) if you have questions about contributing or the codebase architecture. + +--- + +Thank you for helping make AigisCode better! diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a79295c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 AigisCode Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d0e0f22 --- /dev/null +++ b/README.md @@ -0,0 +1,380 @@ +

+
+ AigisCode +
+ Static analysis built for AI agents. Whole-codebase evaluation at scale. +
+
+

+ +

+ PyPI version + Python 3.12+ + CI status + MIT License +

+ +--- + +> **Built by AI. Used by AI. Understood by AI.** +> +> AigisCode is evaluation infrastructure for AI coding agents. It analyzes entire +> codebases to find the structural problems that single-file linters miss --- +> circular dependencies, dead code, hardwired values, layer violations, and +> architectural bottlenecks. The output is machine-readable JSON designed for +> AI agents to parse, triage, and act on. Humans benefit from the reports, but +> the primary workflow is: **AI agent runs AigisCode, AI agent reads JSON, +> AI agent fixes code.** + +**Aigis** (Greek: Aigis) is the ancient Greek word for _Aegis_ --- the divine shield. +The first two letters happen to be **AI**. AigisCode is your codebase's shield +against architectural decay. + +## Quick Start + +```bash +pip install aigiscode +cd your-project +aigiscode analyze . +``` + +AigisCode indexes your source, builds a dependency graph, runs detectors, +applies policy rules, optionally asks an AI backend to review the results, +and generates both human-readable Markdown and machine-readable JSON reports. + +On Python, AigisCode does not use the TypeScript Codex SDK directly. The local +runtime uses either an authenticated `codex` CLI session or the OpenAI +Responses API, depending on configured backend order. + +The machine-readable report is at: + +``` +.aigiscode/aigiscode-report.json +``` + +Each run also writes a resumable agent handoff artifact at: + +``` +.aigiscode/aigiscode-handoff.json +.aigiscode/aigiscode-handoff.md +``` + +Each run also writes timestamped archives under: + +``` +.aigiscode/reports/ +``` + +If you want agents to write into a dedicated folder such as `reports/aigiscode`, +use `--output-dir reports/aigiscode`. + +## Real-World Evaluation Results + +AigisCode has been tested on major open-source codebases. These are real numbers +from production runs, not synthetic benchmarks. + +| Project | Files | Symbols | Dependencies | Circular Deps | God Classes | Dead Code | Hardwiring | +|---|---|---|---|---|---|---|---| +| **Django** | 2,929 | 46,064 | 25,311 | 100 | 102 | 151 | 135 | +| **WordPress** | 3,340 | 33,804 | 6,791 | 9 strong / 64 total | 150 | 120 | 2,221 | +| **Spina** (Rails) | 292 | 1,247 | 968 | 1 | 4 | minimal | minimal | +| **Newerp** (Laravel+Vue) | 5,363 | 27,302 | 21,055 | 22 | 289 | 428 | 1,293 | + +Independent validation on Newerp: approximately 50% dead code precision, layer +violations confirmed actionable by human reviewers. These results demonstrate +that AigisCode scales from small Rails gems to large multi-language monoliths. + +## What Does AigisCode Find? + +| Category | Examples | +|---|---| +| **Circular dependencies** | Module A imports B, B imports C, C imports A --- both strong (architectural) and total (runtime/load) cycles | +| **Dead code** | Unused imports, unreferenced private methods, orphaned properties, abandoned classes | +| **Hardwired values** | Magic strings, repeated literals, hardcoded IPs and URLs, env access outside config | +| **Layer violations** | A controller importing directly from a view, a model reaching into middleware | +| **Structural risks** | God classes, bottleneck files, orphan modules with no inbound dependencies | +| **Runtime contracts** | Routes, hooks, env vars, config keys --- extracted and cross-referenced against findings | + +Every finding includes file path, line number, category, confidence level, and a +suggested fix. False positive rates are driven down by contract-aware filtering +and optional AI review. + +## How It Works + +AigisCode runs a six-stage pipeline: + +``` + Source Code + | + v + +---------+ +----------+ +----------+ +---------+ +-----------+ +----------+ + | Index | --> | Graph | --> | Detect | --> | Rules | --> | AI Review | --> | Report | + +---------+ +----------+ +----------+ +---------+ +-----------+ +----------+ + tree-sitter dependency dead code saved rules classify JSON + MD + Python AST analysis hardwiring pre-filter true_positive contract + SQLite store cycles, magic strings false false_positive inventory + symbols, coupling, positives needs_context metrics + dependencies layers +``` + +**1. Index** --- Parses source files with tree-sitter (PHP, TypeScript, JavaScript, Vue) and Python AST. Stores files, symbols, dependencies, and semantic envelopes in a local SQLite database. Supports incremental re-indexing. + +**2. Graph** --- Builds a file-level dependency graph with NetworkX. Computes circular dependencies (strong vs. total), coupling metrics, bottleneck files, layer violations, god classes, orphan files, and runtime entry candidates. + +**3. Detect** --- Runs generic detector passes for dead code and hardwiring. Detectors emit candidates with confidence levels; they do not encode project-specific logic. + +**4. Rules** --- Applies saved exclusion rules from `.aigiscode/rules.json` to pre-filter known false positives. Rules are the durable memory of prior audits. + +**5. AI Review** --- Sends a sample of remaining findings to an AI backend (OpenAI Codex or Anthropic Claude) for classification as `true_positive`, `false_positive`, or `needs_context`. Proposes new exclusion rules from confirmed false positives. + +**6. Report** --- Generates a structured JSON report and a human-readable Markdown summary. Includes a contract inventory (routes, hooks, env keys, config keys) and full metric breakdowns. + +When external analyzers are enabled, imported `domain=security` findings also flow through a dedicated AI security review step during `analyze`. That review emits verdicts under `security_review`, can generate durable exclusion rules, and is folded into the same feedback-loop accounting. + +The report also exposes a first-class **self-healing feedback loop** summary: +- actionable visible findings that still need work +- accepted / suppressed findings already encoded in rules or policy +- informational findings that should not page humans like real defects +- imported external findings that must converge into the same triage lifecycle +- AI review counts and whether the next run should be quieter because new rules were learned + +Every run also writes a backend-neutral **agent handoff artifact** alongside the full report: +- `aigiscode-handoff.json` for the next agent/tool session +- `aigiscode-handoff.md` for human-readable resume context +- archived copies under `.aigiscode/reports//` +- structured priorities, accepted noise, needs-context items, verification commands, and coverage warnings + +Imported external findings follow the same high-level contract: +- raw scanner artifacts are preserved under `.aigiscode/reports//raw/` +- AigisCode normalizes them into `external_analysis` +- saved rules can pre-filter repeated false positives before any review happens +- `analyze` can AI-review imported security findings as the final triage step +- `report` stays fast and deterministic: it re-runs normalization and rules, but does not perform a fresh AI review + +## Supported Languages + +| Language | Index | Dead Code Detection | Hardwiring Detection | Parser | +|---|:---:|:---:|:---:|---| +| PHP | yes | yes | yes | tree-sitter | +| Python | yes | yes | yes | Python AST | +| TypeScript | yes | yes | yes | tree-sitter | +| JavaScript | yes | yes | yes | tree-sitter | +| Vue | yes | yes | yes | tree-sitter | +| Ruby | yes | -- | yes | tree-sitter | +| Rust | yes | yes | yes | tree-sitter | + +Detector coverage is reported explicitly. When a language is indexed but a +detector does not yet support it, the report flags partial coverage instead of +silently treating it as fully analyzed. + +## CLI Commands + +``` +aigiscode index Parse and store the codebase index +aigiscode analyze Full pipeline: index + graph + detect + review + report +aigiscode report Re-generate report from existing index (fast re-evaluation) +aigiscode tune AI-guided policy tuning with regression guards +aigiscode info Show index stats and detector coverage +aigiscode plugins List available plugins and their policy fields +``` + +Key flags: + +``` +--skip-ai Run without AI backends (deterministic only) +--analytical-mode Ask AI to propose a policy patch +--reset Full re-index (ignore incremental cache) +--output-dir Store the DB, rules, policies, and reports outside `.aigiscode/` +--external-tool Run external analyzers (`ruff`, `gitleaks`, `pip-audit`, `osv-scanner`, `phpstan`, `composer-audit`, `npm-audit`, `cargo-deny`, `cargo-clippy`, `all`) +-P Select a built-in plugin profile +--plugin-module Load an external Python plugin module +--policy-file Override policy from a JSON file +-v / --verbose Enable debug logging +``` + +## For AI Agents + +AigisCode is designed to be consumed by AI coding agents as evaluation +infrastructure. The primary machine interface is the JSON report: + +``` +.aigiscode/aigiscode-report.json +``` + +It contains structured data for every finding category, metric, and contract +inventory --- ready for downstream planning, triage, and automated remediation +without parsing prose. + +Important lifecycle field: +- `feedback_loop` + - `detected_total` + - `accepted_by_policy` + - `actionable_visible` + - `informational_visible` + - `external_visible` + - `ai_reviewed` + - `rules_generated` + - `next_run_should_improve` + +Important resume field: +- `agent_handoff` + - `summary` + - `priorities` + - `accepted_noise` + - `needs_context` + - `next_steps` + - `verification_commands` + - `coverage_warnings` + +Important imported-security fields: +- `external_analysis.tool_runs` + - execution status, artifact paths, and per-tool summaries +- `external_analysis.findings` + - normalized findings with tool/rule provenance and stable fingerprints +- `security_review` + - AI triage results for imported security findings during `analyze` +- `review.verdicts` + - AI verdicts for native dead-code and hardwiring findings + +### Recommended Agent Workflow + +1. Run `aigiscode analyze /repo` --- generate baseline report +2. Parse `.aigiscode/aigiscode-report.json` --- read structured findings +3. Sample findings and classify (true positive / false positive / uncertain) +4. Encode narrow policy for repeated false positives in `.aigiscode/policy.json` +5. Run `aigiscode report /repo` --- fast re-evaluation after policy changes +6. Run `aigiscode tune /repo -i 2` --- optional AI-guided policy refinement + +### Key JSON Fields for Agents + +| JSON Path | Description | +|---|---| +| `graph_analysis.strong_circular_dependencies` | Architectural cycle triage | +| `graph_analysis.circular_dependencies` | Broader runtime context | +| `dead_code` | Unused imports, methods, properties, classes | +| `hardwiring` | Magic strings, repeated literals, hardcoded network | +| `security` | Security-focused summary of hardcoded network/env findings | +| `external_analysis` | Imported findings and archived raw artifacts from external analyzers | +| `security_review` | AI verdicts for imported external security findings reviewed during `analyze` | +| `feedback_loop` | Self-healing lifecycle summary across rules, informational policy, external findings, and AI review | +| `extensions.contract_inventory` | Routes, hooks, env keys, config keys | + +See [docs/AI_AGENT_USAGE.md](docs/AI_AGENT_USAGE.md) for the full agent +integration guide. + +## Configuration + +AigisCode is policy-driven. Instead of hard-coding project-specific behavior +into the analyzer, express it through a JSON policy file: + +```json +{ + "graph": { + "js_import_aliases": { "@/": "src/" }, + "orphan_entry_patterns": ["src/bootstrap/**/*.ts"], + "layer_violation_excludes": ["resources/js/**"] + }, + "dead_code": { + "abandoned_entry_patterns": ["/Contracts/"], + "abandoned_languages": ["php"] + }, + "hardwiring": { + "repeated_literal_min_occurrences": 4, + "skip_path_patterns": ["app/Console/*"], + "informational_path_patterns": ["website/src/pages/**"], + "informational_value_regexes": ["^https://aigiscode\\.com"], + "informational_context_regexes": ["canonical|og:url|schema"], + "js_env_allow_names": ["DEV", "PROD", "MODE"] + }, + "ai": { + "allow_claude_fallback": true + } +} +``` + +Use informational hardwiring policy when a value is legitimate repository context that should remain visible but should not count as actionable debt. Use saved rules when the finding is a durable false-positive pattern that should disappear on the next run. + +External analyzers follow the same product direction: raw artifacts are archived, normalized findings land in `external_analysis`, and imported findings should converge into the same rules / AI-review / feedback-loop lifecycle instead of remaining a permanent side channel. + +Policy is merged in layers: built-in defaults, selected plugins, auto-detected +plugins, external plugin modules, project file (`.aigiscode/policy.json`), +and ad-hoc `--policy-file`. Later layers override earlier ones. + +### Built-in Plugins + +| Plugin | Description | +|---|---| +| `generic` | Safe defaults for mixed-language repositories (always loaded) | +| `django` | Django-aware runtime conventions and entry points | +| `wordpress` | WordPress admin and hook conventions | +| `laravel` | Laravel-specific entry points and dynamic contexts | + +### External Plugins + +Write a Python module with `build_policy_patch()` and optional runtime hooks: + +```python +def build_policy_patch(project_path, selected_plugins): + return { + "dead_code": { + "abandoned_entry_patterns": ["/app/Legacy/"] + } + } + +# Optional: refine results at runtime +def refine_graph_result(graph_result, graph, store, project_path, policy): + return graph_result + +def refine_dead_code_result(dead_code_result, store, project_path, policy): + return dead_code_result +``` + +```bash +aigiscode analyze /repo --plugin-module ./my_plugin.py +``` + +See [docs/PLUGIN_SYSTEM.md](docs/PLUGIN_SYSTEM.md) for the full plugin +documentation. + +## Architecture + +The system separates generic analysis from project-specific interpretation +across four layers of responsibility: + +1. **Index and graph construction** --- generic, language-aware parsing +2. **Generic detectors** --- emit candidates, not verdicts +3. **Policy and exclusion rules** --- project-specific adaptation +4. **AI review and tuning** --- final-stage classification + +Design principles: decoupling over convenience, explainable heuristics over +opaque model-only decisions, partial but explicit coverage over false certainty. + +See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full design document. + +## Requirements + +- Python 3.12+ +- Optional: `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` for AI-assisted review +- Optional: authenticated `codex` CLI session (`~/.codex/auth.json`) for ChatGPT-authenticated Codex usage without relying on the Python API path + +Core dependencies: tree-sitter, NetworkX, Pydantic, Typer, Rich. + +## Contributing + +Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for +guidelines on development setup, testing, and pull request conventions. + +Before patching the analyzer core, consider whether the issue can be expressed +through policy or a plugin module. The design boundary is intentional: +generic analysis logic belongs in the core, project-specific behavior belongs +in policy. + +## License + +[MIT](LICENSE) + +--- + +

+ AigisCode --- your codebase's shield against architectural decay. +

diff --git a/docs/AI_AGENT_USAGE.md b/docs/AI_AGENT_USAGE.md index 17c9974..034ae40 100644 --- a/docs/AI_AGENT_USAGE.md +++ b/docs/AI_AGENT_USAGE.md @@ -47,6 +47,15 @@ Primary machine interface: /repo/.aigiscode/aigiscode-report.json ``` +Each run also archives a timestamped copy under: + +```text +/repo/.aigiscode/reports/ +``` + +If a repository wants agent outputs under a conventional reports path, invoke +commands with `--output-dir /repo/reports/aigiscode`. + An agent should prefer the JSON report over Markdown for: - category counts - per-finding sampling diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..e45faff --- /dev/null +++ b/install.ps1 @@ -0,0 +1,154 @@ +# AigisCode Installer for Windows +# Usage: irm https://raw.githubusercontent.com/david-strejc/aigiscode/main/install.ps1 | iex +# or: Invoke-WebRequest -Uri https://raw.githubusercontent.com/david-strejc/aigiscode/main/install.ps1 -UseBasicParsing | Invoke-Expression + +$ErrorActionPreference = "Stop" + +$VERSION = if ($env:AIGISCODE_VERSION) { $env:AIGISCODE_VERSION } else { "0.1.0" } +$INSTALL_DIR = if ($env:AIGISCODE_DIR) { $env:AIGISCODE_DIR } else { "$env:USERPROFILE\.aigiscode" } +$REPO = "david-strejc/aigiscode" +$MIN_PYTHON_MINOR = 12 + +function Write-Info { param($msg) Write-Host " > " -ForegroundColor Blue -NoNewline; Write-Host $msg } +function Write-Ok { param($msg) Write-Host " ✓ " -ForegroundColor Green -NoNewline; Write-Host $msg } +function Write-Warn { param($msg) Write-Host " ! " -ForegroundColor Yellow -NoNewline; Write-Host $msg } +function Write-Err { param($msg) Write-Host " ✗ " -ForegroundColor Red -NoNewline; Write-Host $msg } + +function Show-Banner { + Write-Host "" + Write-Host " ╔═══════════════════════════════════════╗" -ForegroundColor Magenta + Write-Host " ║ AigisCode Installer v$VERSION ║" -ForegroundColor Magenta + Write-Host " ║ AI-Powered Code Guardian ║" -ForegroundColor Magenta + Write-Host " ╚═══════════════════════════════════════╝" -ForegroundColor Magenta + Write-Host "" +} + +function Find-Python { + $candidates = @("python3.13", "python3.12", "python3", "python", "py") + foreach ($cmd in $candidates) { + try { + $ver = & $cmd -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>$null + if ($ver) { + $parts = $ver.Split(".") + if ([int]$parts[0] -ge 3 -and [int]$parts[1] -ge $MIN_PYTHON_MINOR) { + return $cmd + } + } + } catch { } + } + # Try py launcher + try { + $ver = & py -3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>$null + if ($ver) { + $parts = $ver.Split(".") + if ([int]$parts[0] -ge 3 -and [int]$parts[1] -ge $MIN_PYTHON_MINOR) { + return "py -3" + } + } + } catch { } + return $null +} + +function Install-WithUv { + Write-Info "Installing with uv (fastest)..." + try { + & uv tool install "aigiscode==$VERSION" 2>$null + if ($LASTEXITCODE -ne 0) { throw "PyPI failed" } + } catch { + & uv tool install "git+https://github.com/$REPO.git" + if ($LASTEXITCODE -ne 0) { throw "uv install failed" } + } +} + +function Install-WithPipx { + Write-Info "Installing with pipx..." + try { + & pipx install "aigiscode==$VERSION" 2>$null + if ($LASTEXITCODE -ne 0) { throw "PyPI failed" } + } catch { + & pipx install "git+https://github.com/$REPO.git" + if ($LASTEXITCODE -ne 0) { throw "pipx install failed" } + } +} + +function Install-WithVenv { + param($PythonCmd) + + Write-Info "Installing into isolated venv at $INSTALL_DIR..." + New-Item -ItemType Directory -Force -Path $INSTALL_DIR | Out-Null + + & $PythonCmd -m venv "$INSTALL_DIR\venv" + $pip = "$INSTALL_DIR\venv\Scripts\pip.exe" + + & $pip install --upgrade pip --quiet 2>$null + + try { + & $pip install "aigiscode==$VERSION" --quiet 2>$null + if ($LASTEXITCODE -ne 0) { throw "PyPI failed" } + } catch { + & $pip install "git+https://github.com/$REPO.git" --quiet + if ($LASTEXITCODE -ne 0) { throw "venv install failed" } + } + + # Add to user PATH + $binPath = "$INSTALL_DIR\venv\Scripts" + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + if ($userPath -notlike "*$binPath*") { + [Environment]::SetEnvironmentVariable("Path", "$binPath;$userPath", "User") + Write-Ok "Added $binPath to user PATH" + Write-Warn "Restart your terminal for PATH changes to take effect" + } +} + +# ── Main ────────────────────────────────────────────────────────────────────── + +Show-Banner + +Write-Info "Detected: Windows/$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)" + +# Find Python +$pythonCmd = Find-Python +if (-not $pythonCmd) { + Write-Err "Python 3.12+ is required but not found." + Write-Host "" + Write-Host " Install Python from: https://python.org/downloads/" -ForegroundColor Yellow + Write-Host " Or with winget: winget install Python.Python.3.12" -ForegroundColor Yellow + Write-Host "" + exit 1 +} + +$pyVer = & $pythonCmd --version 2>&1 +Write-Ok "Found $pyVer" + +# Try installers: uv > pipx > venv +$installed = $false +if (Get-Command uv -ErrorAction SilentlyContinue) { + Write-Ok "Found uv" + Install-WithUv + $installed = $true +} elseif (Get-Command pipx -ErrorAction SilentlyContinue) { + Write-Ok "Found pipx" + Install-WithPipx + $installed = $true +} else { + Write-Info "No uv or pipx found, using venv" + Install-WithVenv -PythonCmd $pythonCmd + $installed = $true +} + +# Verify +try { + $ver = & aigiscode --version 2>$null + Write-Ok "AigisCode installed successfully! ($ver)" +} catch { + Write-Ok "AigisCode installed. Restart your terminal to use it." +} + +Write-Host "" +Write-Host " Get started:" -ForegroundColor White +Write-Host " aigiscode analyze . " -ForegroundColor Green -NoNewline; Write-Host "# Analyze current directory" +Write-Host " aigiscode --help " -ForegroundColor Green -NoNewline; Write-Host "# See all commands" +Write-Host "" +Write-Host " Docs: https://aigiscode.com/docs" -ForegroundColor Blue +Write-Host " GitHub: https://github.com/$REPO" -ForegroundColor Blue +Write-Host "" diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..9ef3022 --- /dev/null +++ b/install.sh @@ -0,0 +1,262 @@ +#!/usr/bin/env bash +# AigisCode Installer +# Usage: curl -fsSL https://raw.githubusercontent.com/david-strejc/aigiscode/main/install.sh | bash +# or: wget -qO- https://raw.githubusercontent.com/david-strejc/aigiscode/main/install.sh | bash +# +# Environment variables: +# AIGISCODE_VERSION - version to install (default: latest) +# AIGISCODE_DIR - installation directory (default: ~/.aigiscode) +# NO_COLOR - disable colored output + +set -euo pipefail + +VERSION="${AIGISCODE_VERSION:-0.1.0}" +INSTALL_DIR="${AIGISCODE_DIR:-$HOME/.aigiscode}" +REPO="david-strejc/aigiscode" +MIN_PYTHON="3.12" + +# ── Colors ──────────────────────────────────────────────────────────────────── +if [ -z "${NO_COLOR:-}" ] && [ -t 1 ]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[0;33m' + BLUE='\033[0;34m' + PURPLE='\033[0;35m' + BOLD='\033[1m' + RESET='\033[0m' +else + RED='' GREEN='' YELLOW='' BLUE='' PURPLE='' BOLD='' RESET='' +fi + +info() { printf "${BLUE}▸${RESET} %s\n" "$*"; } +ok() { printf "${GREEN}✓${RESET} %s\n" "$*"; } +warn() { printf "${YELLOW}!${RESET} %s\n" "$*"; } +err() { printf "${RED}✗${RESET} %s\n" "$*" >&2; } +die() { err "$*"; exit 1; } + +banner() { + printf "\n" + printf "${PURPLE}${BOLD}" + printf " ╔═══════════════════════════════════════╗\n" + printf " ║ 🛡️ AigisCode Installer v%s ║\n" "$VERSION" + printf " ║ AI-Powered Code Guardian ║\n" + printf " ╚═══════════════════════════════════════╝\n" + printf "${RESET}\n" +} + +# ── OS / Architecture Detection ─────────────────────────────────────────────── +detect_os() { + case "$(uname -s)" in + Linux*) echo "linux" ;; + Darwin*) echo "macos" ;; + CYGWIN*|MINGW*|MSYS*) echo "windows" ;; + *) die "Unsupported operating system: $(uname -s)" ;; + esac +} + +detect_arch() { + case "$(uname -m)" in + x86_64|amd64) echo "x64" ;; + arm64|aarch64) echo "arm64" ;; + *) echo "$(uname -m)" ;; + esac +} + +# ── Python Detection ────────────────────────────────────────────────────────── +find_python() { + local candidates=("python3.13" "python3.12" "python3" "python") + for cmd in "${candidates[@]}"; do + if command -v "$cmd" &>/dev/null; then + local ver + ver=$("$cmd" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null) || continue + local major minor + major=$(echo "$ver" | cut -d. -f1) + minor=$(echo "$ver" | cut -d. -f2) + if [ "$major" -ge 3 ] && [ "$minor" -ge 12 ]; then + echo "$cmd" + return 0 + fi + fi + done + return 1 +} + +# ── pipx Detection ──────────────────────────────────────────────────────────── +find_pipx() { + if command -v pipx &>/dev/null; then + echo "pipx" + return 0 + fi + return 1 +} + +# ── uv Detection ────────────────────────────────────────────────────────────── +find_uv() { + if command -v uv &>/dev/null; then + echo "uv" + return 0 + fi + return 1 +} + +# ── Installation Methods ────────────────────────────────────────────────────── +install_with_uv() { + info "Installing with uv (fastest)..." + uv tool install "aigiscode==$VERSION" 2>/dev/null || \ + uv tool install "aigiscode>=$VERSION" 2>/dev/null || \ + uv tool install "git+https://github.com/${REPO}.git@v${VERSION}" 2>/dev/null || \ + uv tool install "git+https://github.com/${REPO}.git" || \ + die "Failed to install with uv" +} + +install_with_pipx() { + info "Installing with pipx..." + pipx install "aigiscode==$VERSION" 2>/dev/null || \ + pipx install "aigiscode>=$VERSION" 2>/dev/null || \ + pipx install "git+https://github.com/${REPO}.git@v${VERSION}" 2>/dev/null || \ + pipx install "git+https://github.com/${REPO}.git" || \ + die "Failed to install with pipx" +} + +install_with_venv() { + local python_cmd="$1" + + info "Installing into isolated venv at ${INSTALL_DIR}..." + mkdir -p "$INSTALL_DIR" + + # Create virtual environment + "$python_cmd" -m venv "$INSTALL_DIR/venv" + local pip="$INSTALL_DIR/venv/bin/pip" + + # Upgrade pip silently + "$pip" install --upgrade pip --quiet 2>/dev/null + + # Install aigiscode + "$pip" install "aigiscode==$VERSION" --quiet 2>/dev/null || \ + "$pip" install "aigiscode>=$VERSION" --quiet 2>/dev/null || \ + "$pip" install "git+https://github.com/${REPO}.git@v${VERSION}" --quiet 2>/dev/null || \ + "$pip" install "git+https://github.com/${REPO}.git" --quiet || \ + die "Failed to install aigiscode" + + # Create wrapper script + local bin_dir + if [ "$(detect_os)" = "windows" ]; then + bin_dir="$INSTALL_DIR/venv/Scripts" + else + bin_dir="$INSTALL_DIR/venv/bin" + fi + + # Symlink or add to PATH + local target_dir="$HOME/.local/bin" + mkdir -p "$target_dir" + + if [ -f "$bin_dir/aigiscode" ]; then + ln -sf "$bin_dir/aigiscode" "$target_dir/aigiscode" + ok "Linked aigiscode to $target_dir/aigiscode" + fi +} + +# ── PATH Setup ──────────────────────────────────────────────────────────────── +ensure_path() { + local target_dir="$HOME/.local/bin" + if [[ ":$PATH:" != *":$target_dir:"* ]]; then + warn "$target_dir is not in your PATH" + + local shell_name + shell_name=$(basename "${SHELL:-/bin/bash}") + local rc_file="" + + case "$shell_name" in + zsh) rc_file="$HOME/.zshrc" ;; + bash) rc_file="$HOME/.bashrc" ;; + fish) rc_file="$HOME/.config/fish/config.fish" ;; + esac + + if [ -n "$rc_file" ]; then + local path_line='export PATH="$HOME/.local/bin:$PATH"' + if [ "$shell_name" = "fish" ]; then + path_line='set -gx PATH $HOME/.local/bin $PATH' + fi + + if [ -f "$rc_file" ] && ! grep -q '.local/bin' "$rc_file" 2>/dev/null; then + echo "" >> "$rc_file" + echo "# Added by AigisCode installer" >> "$rc_file" + echo "$path_line" >> "$rc_file" + ok "Added $target_dir to PATH in $rc_file" + info "Run: source $rc_file (or restart your terminal)" + fi + fi + fi +} + +# ── Verify Installation ────────────────────────────────────────────────────── +verify_install() { + # Check common locations + local aigis_cmd="" + if command -v aigiscode &>/dev/null; then + aigis_cmd="aigiscode" + elif [ -f "$HOME/.local/bin/aigiscode" ]; then + aigis_cmd="$HOME/.local/bin/aigiscode" + elif [ -f "$INSTALL_DIR/venv/bin/aigiscode" ]; then + aigis_cmd="$INSTALL_DIR/venv/bin/aigiscode" + fi + + if [ -n "$aigis_cmd" ]; then + local installed_ver + installed_ver=$("$aigis_cmd" --version 2>/dev/null || echo "unknown") + ok "AigisCode installed successfully! (${installed_ver})" + else + ok "AigisCode installed. You may need to restart your terminal." + fi +} + +# ── Main ────────────────────────────────────────────────────────────────────── +main() { + banner + + local os arch + os=$(detect_os) + arch=$(detect_arch) + info "Detected: ${os}/${arch}" + + # Find Python 3.12+ + local python_cmd + python_cmd=$(find_python) || die "Python ${MIN_PYTHON}+ is required but not found. + +Install Python: + macOS: brew install python@3.12 + Ubuntu: sudo apt install python3.12 python3.12-venv + Fedora: sudo dnf install python3.12 + Windows: https://python.org/downloads/" + + local python_ver + python_ver=$("$python_cmd" --version 2>&1) + ok "Found ${python_ver}" + + # Try installers in order: uv > pipx > venv + local uv_cmd pipx_cmd + if uv_cmd=$(find_uv); then + ok "Found uv" + install_with_uv + elif pipx_cmd=$(find_pipx); then + ok "Found pipx" + install_with_pipx + else + info "No uv or pipx found, using venv" + install_with_venv "$python_cmd" + ensure_path + fi + + verify_install + + printf "\n" + printf "${BOLD}Get started:${RESET}\n" + printf " ${GREEN}aigiscode analyze .${RESET} # Analyze current directory\n" + printf " ${GREEN}aigiscode --help${RESET} # See all commands\n" + printf "\n" + printf " ${BLUE}Docs:${RESET} https://aigiscode.com/docs\n" + printf " ${BLUE}GitHub:${RESET} https://github.com/${REPO}\n" + printf "\n" +} + +main "$@" diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..bb21701 --- /dev/null +++ b/llms.txt @@ -0,0 +1,60 @@ +# AigisCode + +> AI-powered static analysis that watches your entire codebase + +AigisCode is a whole-codebase evaluator for large mixed-language projects. It finds circular dependencies, dead code, hardwired values, and architectural issues using a six-stage pipeline: Index, Graph, Detect, Rules, AI Review, Report. + +Designed for AI coding agents. The primary output is machine-readable JSON at `.aigiscode/aigiscode-report.json`, structured for automated parsing, triage, and remediation. + +## Key Links + +- [Documentation](https://aigiscode.com) +- [GitHub Repository](https://github.com/david-strejc/aigiscode) +- [AI Agent Usage Guide](https://github.com/david-strejc/aigiscode/blob/main/docs/AI_AGENT_USAGE.md) +- [Architecture](https://github.com/david-strejc/aigiscode/blob/main/docs/ARCHITECTURE.md) +- [Plugin System](https://github.com/david-strejc/aigiscode/blob/main/docs/PLUGIN_SYSTEM.md) +- [Agent Instructions](https://github.com/david-strejc/aigiscode/blob/main/AGENTS.md) + +## Quick Start + +```bash +pip install aigiscode +aigiscode analyze /path/to/project +cat .aigiscode/aigiscode-report.json +``` + +## Supported Languages + +PHP, Python, TypeScript, JavaScript, Vue, Ruby + +## Features + +- Circular dependency detection (strong architectural cycles and total runtime cycles) +- Dead code detection (unused imports, unreferenced methods, orphaned properties, abandoned classes) +- Hardwiring detection (magic strings, repeated literals, hardcoded IPs and URLs, env access outside config) +- Layer violation detection (controllers importing views, models reaching into middleware) +- God class detection (classes with excessive responsibility and coupling) +- Bottleneck file identification (files with disproportionate inbound dependencies) +- AI-assisted review (classifies findings as true_positive, false_positive, or needs_context) +- Policy-driven behavior with plugin system (generic, django, wordpress, laravel profiles) +- Machine-readable JSON output for AI agent consumption +- Runtime contract extraction (routes, hooks, env vars, config keys) +- Exclusion rule engine for durable false positive suppression + +## Real-World Results + +Tested on major codebases: Django (2,929 files, 46,064 symbols), WordPress (3,340 files, 33,804 symbols), and mixed Laravel+Vue projects (5,363 files, 27,302 symbols). Independent validation shows approximately 50% dead code precision with layer violations confirmed actionable. + +## Docs + +- [README](https://github.com/david-strejc/aigiscode/blob/main/README.md): Project overview, quick start, feature list +- [AI Agent Usage](https://github.com/david-strejc/aigiscode/blob/main/docs/AI_AGENT_USAGE.md): How AI agents should use AigisCode +- [Architecture](https://github.com/david-strejc/aigiscode/blob/main/docs/ARCHITECTURE.md): Six-stage pipeline, design principles, policy boundary +- [Plugin System](https://github.com/david-strejc/aigiscode/blob/main/docs/PLUGIN_SYSTEM.md): Policy shape, merge order, external plugin modules +- [Reliability](https://github.com/david-strejc/aigiscode/blob/main/docs/RELIABILITY.md): Validation methodology and precision tracking +- [Analytical Mode](https://github.com/david-strejc/aigiscode/blob/main/docs/ANALYTICAL_MODE.md): AI-guided policy tuning + +## Optional + +- [Contributing](https://github.com/david-strejc/aigiscode/blob/main/CONTRIBUTING.md): Development setup and pull request conventions +- [License](https://github.com/david-strejc/aigiscode/blob/main/LICENSE): MIT diff --git a/src/aigiscode/ai/backends.py b/src/aigiscode/ai/backends.py index 29c8db0..83d90ad 100644 --- a/src/aigiscode/ai/backends.py +++ b/src/aigiscode/ai/backends.py @@ -19,17 +19,40 @@ def _has_openai_key() -> bool: return bool(os.environ.get("OPENAI_API_KEY")) +def _has_codex_cli() -> bool: + """Check if Codex CLI is installed (supports logged-in auth without API key).""" + return shutil.which("codex") is not None + + def _has_anthropic_key() -> bool: return bool(os.environ.get("ANTHROPIC_API_KEY")) +def describe_backend_order( + primary_backend: str = "codex", + allow_codex_cli_fallback: bool = True, + allow_claude_fallback: bool = True, +) -> str: + """Return a human-readable string describing the active backend fallback chain.""" + backends: list[str] = [] + if primary_backend == "codex" and _has_openai_key(): + backends.append("Codex SDK") + if allow_codex_cli_fallback and _has_codex_cli(): + backends.append("Codex CLI") + if allow_claude_fallback and _has_anthropic_key(): + backends.append("Claude") + return " → ".join(backends) if backends else "no backend available" + + def has_any_backend( - allow_codex_cli_fallback: bool = True, allow_claude_fallback: bool = True + primary_backend: str = "codex", + allow_codex_cli_fallback: bool = True, + allow_claude_fallback: bool = True, ) -> bool: """Return True when at least one configured backend is available.""" - if _has_openai_key(): + if primary_backend == "codex" and _has_openai_key(): return True - if allow_codex_cli_fallback and shutil.which("codex") is not None: + if allow_codex_cli_fallback and _has_codex_cli(): return True if allow_claude_fallback and _has_anthropic_key(): return True @@ -42,7 +65,7 @@ async def call_codex_sdk( model: str, reasoning_effort: str = "medium", ) -> str | None: - """Call Codex/OpenAI via Responses API.""" + """Call Codex/OpenAI via Responses API (requires OPENAI_API_KEY).""" if not _has_openai_key(): return None @@ -157,16 +180,21 @@ async def generate_text( ) -> tuple[str | None, str]: """Generate text with ordered fallbacks. - Order: - 1) Codex SDK (Responses API) - 2) Codex CLI (optional) - 3) Claude (optional) + When OPENAI_API_KEY is set: + 1) Codex SDK (Responses API) + 2) Codex CLI (optional fallback) + 3) Claude (optional fallback) + + When no API key but Codex CLI is installed (logged-in auth): + 1) Codex CLI (primary — uses logged-in session, no API key needed) + 2) Claude (optional fallback) """ - text = await call_codex_sdk( - system, user, model=model, reasoning_effort=reasoning_effort - ) - if text: - return text, "codex_sdk" + if _has_openai_key(): + text = await call_codex_sdk( + system, user, model=model, reasoning_effort=reasoning_effort + ) + if text: + return text, "codex_sdk" if allow_codex_cli_fallback: prompt = f"{system}\n\n{user}" diff --git a/src/aigiscode/cli.py b/src/aigiscode/cli.py index 8ce5478..4063e88 100644 --- a/src/aigiscode/cli.py +++ b/src/aigiscode/cli.py @@ -17,9 +17,17 @@ from rich.panel import Panel from rich.table import Table -from aigiscode.ai.backends import has_any_backend +from aigiscode.ai.backends import describe_backend_order, has_any_backend from aigiscode import __version__ -from aigiscode.models import AigisCodeConfig, ReportData +from aigiscode.models import AigisCodeConfig, ReportData, ReviewResult +from aigiscode.orchestration import ( + build_report_data, + combine_runtime_plugins, + collect_external_analysis_for_report, + resolve_runtime_environment, + run_deterministic_analysis, + selected_external_tools, +) app = typer.Typer( name="aigiscode", @@ -28,27 +36,10 @@ ) console = Console() - -def _collect_detector_coverage( - language_breakdown: dict[str, int], -) -> dict[str, list[str]]: - from aigiscode.graph.deadcode import SUPPORTED_DEAD_CODE_LANGUAGES - from aigiscode.graph.hardwiring import SUPPORTED_HARDWIRING_LANGUAGES - - indexed_languages = { - language for language, count in language_breakdown.items() if count > 0 - } - coverage: dict[str, list[str]] = {} - - dead_code_missing = sorted(indexed_languages - set(SUPPORTED_DEAD_CODE_LANGUAGES)) - if dead_code_missing: - coverage["dead_code"] = dead_code_missing - - hardwiring_missing = sorted(indexed_languages - set(SUPPORTED_HARDWIRING_LANGUAGES)) - if hardwiring_missing: - coverage["hardwiring"] = hardwiring_missing - - return coverage +SYMBOLS_EXTRACTED_KEY = "symbols_extracted" +DEPENDENCIES_FOUND_KEY = "dependencies_found" +UNSUPPORTED_SOURCE_FILES_KEY = "unsupported_source_files" +UNSUPPORTED_LANGUAGE_BREAKDOWN_KEY = "unsupported_language_breakdown" def _format_detector_coverage( @@ -63,6 +54,18 @@ def _format_detector_coverage( ) +def _accumulate_prefiltered( + review_result: ReviewResult | None, + excluded: int, +) -> ReviewResult | None: + if excluded <= 0: + return review_result + if review_result is None: + return ReviewResult(rules_prefiltered=excluded) + review_result.rules_prefiltered += excluded + return review_result + + def _describe_project_type( *, project_path: Path, @@ -109,22 +112,6 @@ def _describe_project_type( return f"Project at {project_path.name}" -def _combine_runtime_plugins( - selected_plugins: list[str], external_plugins: list -) -> list: - from aigiscode.builtin_runtime_plugins import load_builtin_runtime_plugins - - combined = [*load_builtin_runtime_plugins(selected_plugins), *external_plugins] - deduped = [] - seen: set[str] = set() - for plugin in combined: - if plugin.ref in seen: - continue - seen.add(plugin.ref) - deduped.append(plugin) - return deduped - - def _normalize_confidence_option(value: str | None, option_name: str) -> str | None: if value is None: return None @@ -303,6 +290,11 @@ def _is_candidate_improvement( @app.command() def index( project_path: str = typer.Argument(..., help="Path to the project to index"), + output_dir: Path | None = typer.Option( + None, + "--output-dir", + help="Directory for aigiscode.db, rules, policies, and reports", + ), reset: bool = typer.Option( False, "--reset", "-r", help="Reset the index before indexing" ), @@ -315,7 +307,7 @@ def index( """ _configure_logging(verbose) path = _resolve_project(project_path) - config = AigisCodeConfig(project_path=path) + config = AigisCodeConfig(project_path=path, output_dir=output_dir) _print_header(f"Indexing: {path}") @@ -345,8 +337,8 @@ def index( table.add_row("Files skipped (unchanged)", str(result["files_skipped"])) if result.get("files_pruned", 0) > 0: table.add_row("Files pruned (stale)", str(result["files_pruned"])) - table.add_row("Symbols extracted", str(result["symbols_extracted"])) - table.add_row("Dependencies found", str(result["dependencies_found"])) + table.add_row("Symbols extracted", str(result[SYMBOLS_EXTRACTED_KEY])) + table.add_row("Dependencies found", str(result[DEPENDENCIES_FOUND_KEY])) console.print(table) # Language breakdown @@ -374,6 +366,11 @@ def index( @app.command() def analyze( project_path: str = typer.Argument(..., help="Path to the project to analyze"), + output_dir: Path | None = typer.Option( + None, + "--output-dir", + help="Directory for aigiscode.db, rules, policies, and reports", + ), skip_ai: bool = typer.Option( False, "--skip-ai", help="Skip AI workers (Codex/OpenAI)" ), @@ -425,6 +422,19 @@ def analyze( max_workers: int = typer.Option( 4, "--workers", "-w", help="Max parallel AI workers" ), + external_tools: list[str] = typer.Option( + None, + "--external-tool", + help=( + "Run external analyzer (repeatable): " + "ruff, gitleaks, pip-audit, osv-scanner, phpstan, composer-audit, npm-audit, cargo-deny, cargo-clippy, all" + ), + ), + run_ruff_security: bool = typer.Option( + False, + "--run-ruff-security", + help="Run Ruff S-rule security checks and archive raw JSON artifacts", + ), reset: bool = typer.Option( False, "--reset", "-r", help="Reset index before analysis" ), @@ -446,6 +456,7 @@ def analyze( ) config = AigisCodeConfig( project_path=path, + output_dir=output_dir, max_workers=max_workers, skip_ai=skip_ai, skip_review=skip_review, @@ -455,35 +466,15 @@ def analyze( analytical_mode=analytical_mode, plugin_modules=plugin_modules or [], ) - _print_header(f"Analyzing: {path}") if config.is_laravel: console.print("[dim]Detected: Laravel project[/dim]") - from aigiscode.extensions import ( - apply_dead_code_result_plugins, - apply_graph_result_plugins, - apply_hardwiring_result_plugins, - build_report_extensions, - load_external_plugins, - ) from aigiscode.filters import filter_dead_code_result, filter_hardwiring_result - from aigiscode.policy.plugins import resolve_policy - - external_plugins = load_external_plugins(config.plugin_modules) - - policy = resolve_policy( - path, - plugin_names=config.plugins, - policy_file=config.policy_file, - plugin_modules=config.plugin_modules, - external_plugins=external_plugins, - ) - runtime_plugins = _combine_runtime_plugins( - policy.plugins_applied, - external_plugins, - ) + runtime_env = resolve_runtime_environment(config) + policy = runtime_env.policy + runtime_plugins = runtime_env.runtime_plugins console.print(f"[dim]Policy plugins: {', '.join(policy.plugins_applied)}[/dim]") if runtime_plugins: console.print( @@ -513,16 +504,16 @@ def analyze( f"{index_result['symbols_extracted']} symbols, " f"{index_result['dependencies_found']} dependencies{pruned_msg}" ) - if index_result.get("unsupported_source_files", 0): + if index_result.get(UNSUPPORTED_SOURCE_FILES_KEY, 0): breakdown = ", ".join( f"{lang}={count}" for lang, count in index_result.get( - "unsupported_language_breakdown", {} + UNSUPPORTED_LANGUAGE_BREAKDOWN_KEY, {} ).items() ) console.print( " [yellow]Coverage warning:[/yellow] " - f"{index_result['unsupported_source_files']} unsupported source files skipped" + f"{index_result[UNSUPPORTED_SOURCE_FILES_KEY]} unsupported source files skipped" + (f" ({breakdown})" if breakdown else "") ) @@ -533,21 +524,25 @@ def analyze( store.close() raise typer.Exit(0) - # --- Phase 2: Graph Analysis --- - console.print("\n[bold blue]Phase 2: Graph Analysis[/bold blue]") - from aigiscode.graph.builder import build_file_graph - from aigiscode.graph.analyzer import analyze_graph + run_timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + generated_at = datetime.strptime(run_timestamp, "%Y%m%d_%H%M%S") + from aigiscode.report.generator import allocate_archive_stem - graph = build_file_graph(store, policy=policy.graph) - graph_result = analyze_graph(graph, store, policy=policy.graph) - graph_result = apply_graph_result_plugins( - graph_result, - runtime_plugins, - graph=graph, + run_id = allocate_archive_stem(config.effective_output_dir, run_timestamp) + + deterministic = run_deterministic_analysis( + config=config, store=store, - project_path=path, policy=policy, + runtime_plugins=runtime_plugins, ) + graph = deterministic.graph + graph_result = deterministic.graph_result + dead_code_result = deterministic.dead_code_result + hardwiring_result = deterministic.hardwiring_result + + # --- Phase 2: Graph Analysis --- + console.print("\n[bold blue]Phase 2: Graph Analysis[/bold blue]") console.print( f" Graph: {graph_result.node_count} nodes, {graph_result.edge_count} edges" @@ -570,30 +565,6 @@ def analyze( # --- Phase 2b: Dead Code & Hardwiring Analysis --- console.print("\n[bold blue]Phase 2b: Dead Code & Hardwiring Analysis[/bold blue]") - from aigiscode.graph.deadcode import analyze_dead_code - from aigiscode.graph.hardwiring import analyze_hardwiring - - dead_code_result = analyze_dead_code(store, policy=policy.dead_code) - hardwiring_result = analyze_hardwiring( - store, - policy=policy.hardwiring, - external_plugins=runtime_plugins, - project_path=path, - ) - dead_code_result = apply_dead_code_result_plugins( - dead_code_result, - runtime_plugins, - store=store, - project_path=path, - policy=policy, - ) - hardwiring_result = apply_hardwiring_result_plugins( - hardwiring_result, - runtime_plugins, - store=store, - project_path=path, - policy=policy, - ) dc = dead_code_result console.print( @@ -613,7 +584,6 @@ def analyze( # --- Phase 2c: Rule Filtering + AI Finding Review --- review_result = None rules_path = config.effective_output_dir / "rules.json" - run_id = datetime.now().strftime("%Y%m%d_%H%M%S") phase_suffix = " [yellow](AI review skipped)[/yellow]" if config.skip_review else "" console.print( f"\n[bold blue]Phase 2c: Rule Filtering + AI Finding Review[/bold blue]{phase_suffix}" @@ -675,15 +645,15 @@ def analyze( ) if config.skip_review: - if excluded: - from aigiscode.models import ReviewResult - - review_result = ReviewResult(rules_prefiltered=excluded) + review_result = _accumulate_prefiltered(review_result, excluded) elif remaining > 0 and has_any_backend( + primary_backend=policy.ai.primary_backend, allow_codex_cli_fallback=policy.ai.allow_codex_cli_fallback, allow_claude_fallback=policy.ai.allow_claude_fallback, ): - console.print(f" Reviewing {remaining} findings with Codex SDK + fallbacks...") + console.print( + f" Reviewing {remaining} findings with {describe_backend_order(primary_backend=policy.ai.primary_backend, allow_codex_cli_fallback=policy.ai.allow_codex_cli_fallback, allow_claude_fallback=policy.ai.allow_claude_fallback)}..." + ) from aigiscode.review.ai_reviewer import review_findings @@ -700,6 +670,7 @@ def analyze( ), store=store, review_model=policy.ai.review_model, + primary_backend=policy.ai.primary_backend, allow_claude_fallback=policy.ai.allow_claude_fallback, ) ) @@ -708,6 +679,8 @@ def analyze( if new_rules: added = append_rules(rules_path, new_rules) console.print(f" Generated {added} new exclusion rules → {rules_path}") + if added: + existing_rules = load_rules(rules_path) console.print( f" Verdicts: {review_result.true_positives} true positives, " @@ -718,7 +691,7 @@ def analyze( console.print(" [green]All findings pre-filtered by existing rules[/green]") else: console.print( - " [yellow]Skipped:[/yellow] No Codex SDK key (or Claude fallback) available" + " [yellow]Skipped:[/yellow] No OpenAI API key, Codex CLI session, or Claude fallback available" ) # --- Phase 3: AI Workers (Semantic Envelopes) --- @@ -729,11 +702,12 @@ def analyze( ) if has_any_backend( + primary_backend=policy.ai.primary_backend, allow_codex_cli_fallback=policy.ai.allow_codex_cli_fallback, allow_claude_fallback=False, ): console.print( - f" Backend order: Codex SDK -> Codex CLI ({policy.ai.codex_model})" + f" Backend order: {describe_backend_order(primary_backend=policy.ai.primary_backend, allow_codex_cli_fallback=policy.ai.allow_codex_cli_fallback, allow_claude_fallback=False)} ({policy.ai.codex_model})" ) from aigiscode.workers.codex import process_files @@ -744,12 +718,13 @@ def analyze( config.project_path, config.max_workers, model=policy.ai.codex_model, + primary_backend=policy.ai.primary_backend, ) ) console.print(f" Generated {envelopes_count} semantic envelopes") else: console.print( - " [yellow]Skipped:[/yellow] No Codex SDK key (or CLI fallback) available" + " [yellow]Skipped:[/yellow] No OpenAI API key or Codex CLI session available" ) else: console.print( @@ -762,6 +737,7 @@ def analyze( console.print("\n[bold blue]Phase 4: AI Synthesis[/bold blue]") if has_any_backend( + primary_backend=policy.ai.primary_backend, allow_codex_cli_fallback=policy.ai.allow_codex_cli_fallback, allow_claude_fallback=policy.ai.allow_claude_fallback, ): @@ -773,6 +749,7 @@ def analyze( graph_result, envelopes_by_layer, model=policy.ai.synthesis_model, + primary_backend=policy.ai.primary_backend, allow_claude_fallback=policy.ai.allow_claude_fallback, ) ) @@ -782,7 +759,7 @@ def analyze( console.print(" [yellow]Synthesis returned empty result[/yellow]") else: console.print( - " [yellow]Skipped:[/yellow] No Codex SDK key (or Claude fallback) available" + " [yellow]Skipped:[/yellow] No OpenAI API key, Codex CLI session, or Claude fallback available" ) else: console.print( @@ -791,46 +768,108 @@ def analyze( # --- Phase 5: Report Generation --- console.print("\n[bold blue]Phase 5: Report Generation[/bold blue]") + external_analysis = None + security_review_result = None + chosen_external_tools = selected_external_tools( + external_tools, + run_ruff_security=run_ruff_security, + ) + if chosen_external_tools: + console.print(" Running external analyzers...") + external_analysis, external_excluded = collect_external_analysis_for_report( + project_path=path, + output_dir=config.effective_output_dir, + run_id=run_id, + selected_tools=chosen_external_tools, + existing_rules=existing_rules, + ctx=ctx, + ) + if external_excluded: + console.print( + f" Pre-filtered {external_excluded} external findings using {len(existing_rules)} saved rules" + ) + review_result = _accumulate_prefiltered( + review_result, + external_excluded, + ) + console.print( + f" External findings: {len(external_analysis.findings)} from {len(external_analysis.tool_runs)} tool(s)" + ) + security_findings = [ + finding + for finding in external_analysis.findings + if finding.domain == "security" + ] + if config.skip_review: + pass + elif security_findings and has_any_backend( + primary_backend=policy.ai.primary_backend, + allow_codex_cli_fallback=policy.ai.allow_codex_cli_fallback, + allow_claude_fallback=policy.ai.allow_claude_fallback, + ): + console.print( + f" Reviewing {len(security_findings)} external security findings with {describe_backend_order(primary_backend=policy.ai.primary_backend, allow_codex_cli_fallback=policy.ai.allow_codex_cli_fallback, allow_claude_fallback=policy.ai.allow_claude_fallback)}..." + ) + from aigiscode.review.security_reviewer import ( + review_external_security_findings, + ) - # Use total DB counts (not just newly-indexed) for accurate reporting - language_breakdown = store.get_language_breakdown() - detector_coverage = _collect_detector_coverage(language_breakdown) - - report_data = ReportData( - project_path=str(path), - generated_at=datetime.now(), - files_indexed=store.get_file_count(), - symbols_extracted=store.get_symbol_count(), - dependencies_found=store.get_dependency_count(), - unsupported_source_files=index_result.get("unsupported_source_files", 0), - unsupported_language_breakdown=index_result.get( - "unsupported_language_breakdown", {} - ), - detector_coverage=detector_coverage, - graph_analysis=graph_result, + security_review_result, security_rules = asyncio.run( + review_external_security_findings( + external_analysis, + project_path=path, + project_type=_describe_project_type( + project_path=path, + store=store, + selected_plugins=policy.plugins_applied, + is_laravel=config.is_laravel, + ), + review_model=policy.ai.review_model, + primary_backend=policy.ai.primary_backend, + allow_claude_fallback=policy.ai.allow_claude_fallback, + ) + ) + if security_rules: + added = append_rules(rules_path, security_rules) + console.print( + f" Generated {added} security exclusion rules → {rules_path}" + ) + if added: + existing_rules = load_rules(rules_path) + console.print( + f" Security verdicts: {security_review_result.actionable} actionable, " + f"{security_review_result.accepted_noise} accepted noise, " + f"{security_review_result.needs_context} needs context" + ) + elif security_findings: + console.print( + " [yellow]Skipped external security review:[/yellow] No OpenAI API key, Codex CLI session, or Claude fallback available" + ) + + report_data = build_report_data( + store=store, + project_path=path, + generated_at=generated_at, + graph=graph, + graph_result=graph_result, + dead_code_result=dead_code_result, + hardwiring_result=hardwiring_result, + review_result=review_result, + security_review_result=security_review_result, + external_analysis=external_analysis, + runtime_plugins=runtime_plugins, + policy=policy, + unsupported_breakdown=deterministic.unsupported_breakdown, + synthesis_text=synthesis_text, envelopes_generated=envelopes_count, - synthesis=synthesis_text, - language_breakdown=language_breakdown, - dead_code=dead_code_result, - hardwiring=hardwiring_result, - review=review_result, ) - from aigiscode.report.contracts import build_contract_inventory from aigiscode.report.generator import write_reports - report_data.extensions = { - "contract_inventory": build_contract_inventory(store), - **build_report_extensions( - runtime_plugins, - report=report_data, - graph=graph, - store=store, - project_path=path, - policy=policy, - ), - } - - md_path, json_path = write_reports(report_data, config.effective_output_dir) + md_path, json_path = write_reports( + report_data, + config.effective_output_dir, + archive_stem=run_id, + ) # Store metrics (run_id was set in Phase 2c) store.insert_metric(run_id, "files_indexed", index_result["files_indexed"]) @@ -857,6 +896,12 @@ def analyze( console.print(f" Markdown: {md_path}") console.print(f" JSON: {json_path}") + console.print( + f" Handoff: {config.effective_output_dir / 'aigiscode-handoff.md'}" + ) + console.print( + f" Handoff JSON: {config.effective_output_dir / 'aigiscode-handoff.json'}" + ) if config.analytical_mode: from aigiscode.policy.analytical import propose_policy_patch, save_policy_patch @@ -887,6 +932,11 @@ def analyze( @app.command() def report( project_path: str = typer.Argument(..., help="Path to the project"), + output_dir: Path | None = typer.Option( + None, + "--output-dir", + help="Directory for aigiscode.db, rules, policies, and reports", + ), plugins: list[str] = typer.Option( None, "--plugin", @@ -921,6 +971,19 @@ def report( "--min-hardwiring-confidence", help="Filter hardwiring findings below this confidence: low|medium|high", ), + external_tools: list[str] = typer.Option( + None, + "--external-tool", + help=( + "Run external analyzer (repeatable): " + "ruff, gitleaks, pip-audit, osv-scanner, phpstan, composer-audit, npm-audit, cargo-deny, cargo-clippy, all" + ), + ), + run_ruff_security: bool = typer.Option( + False, + "--run-ruff-security", + help="Run Ruff S-rule security checks and archive raw JSON artifacts", + ), verbose: bool = typer.Option(False, "--verbose", "-V", help="Enable debug logging"), ) -> None: """Generate a report from existing index data. @@ -937,8 +1000,13 @@ def report( min_hardwiring_confidence, "--min-hardwiring-confidence", ) - config = AigisCodeConfig(project_path=path) - + config = AigisCodeConfig( + project_path=path, + output_dir=output_dir, + plugins=plugins or [], + policy_file=policy_file, + plugin_modules=plugin_modules or [], + ) _print_header(f"Generating report for: {path}") if not config.db_path.exists(): @@ -947,33 +1015,17 @@ def report( ) raise typer.Exit(1) - from aigiscode.extensions import ( - apply_dead_code_result_plugins, - apply_graph_result_plugins, - apply_hardwiring_result_plugins, - build_report_extensions, - load_external_plugins, - ) + run_timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + generated_at = datetime.strptime(run_timestamp, "%Y%m%d_%H%M%S") + from aigiscode.report.generator import allocate_archive_stem, write_reports + + run_id = allocate_archive_stem(config.effective_output_dir, run_timestamp) + from aigiscode.filters import filter_dead_code_result, filter_hardwiring_result - from aigiscode.indexer.parser import discover_unsupported_source_files from aigiscode.indexer.store import IndexStore - from aigiscode.policy.plugins import resolve_policy - from aigiscode.graph.builder import build_file_graph - from aigiscode.graph.analyzer import analyze_graph - from aigiscode.report.generator import write_reports - - external_plugins = load_external_plugins(plugin_modules or []) - policy = resolve_policy( - path, - plugin_names=plugins or [], - policy_file=policy_file, - plugin_modules=plugin_modules or [], - external_plugins=external_plugins, - ) - runtime_plugins = _combine_runtime_plugins( - policy.plugins_applied, - external_plugins, - ) + runtime_env = resolve_runtime_environment(config) + policy = runtime_env.policy + runtime_plugins = runtime_env.runtime_plugins console.print(f"[dim]Policy plugins: {', '.join(policy.plugins_applied)}[/dim]") if runtime_plugins: console.print( @@ -982,49 +1034,24 @@ def report( store = IndexStore(config.db_path) store.initialize() - unsupported_breakdown = discover_unsupported_source_files(config) - - # Run graph analysis on existing data - graph = build_file_graph(store, policy=policy.graph) - graph_result = analyze_graph(graph, store, policy=policy.graph) - graph_result = apply_graph_result_plugins( - graph_result, - runtime_plugins, - graph=graph, - store=store, - project_path=path, - policy=policy, - ) - - # Run dead code & hardwiring analysis - from aigiscode.graph.deadcode import analyze_dead_code - from aigiscode.graph.hardwiring import analyze_hardwiring - - dead_code_result = analyze_dead_code(store, policy=policy.dead_code) - hardwiring_result = analyze_hardwiring( - store, - policy=policy.hardwiring, - external_plugins=runtime_plugins, - project_path=path, - ) - dead_code_result = apply_dead_code_result_plugins( - dead_code_result, - runtime_plugins, - store=store, - project_path=path, - policy=policy, - ) - hardwiring_result = apply_hardwiring_result_plugins( - hardwiring_result, - runtime_plugins, + deterministic = run_deterministic_analysis( + config=config, store=store, - project_path=path, policy=policy, + runtime_plugins=runtime_plugins, ) + graph = deterministic.graph + graph_result = deterministic.graph_result + dead_code_result = deterministic.dead_code_result + hardwiring_result = deterministic.hardwiring_result # Apply saved exclusion rules (pre-filter only, no AI review in report mode) from aigiscode.rules.checks import StructuralContext - from aigiscode.rules.engine import load_rules, filter_findings, ensure_seed_rules + from aigiscode.rules.engine import ( + ensure_seed_rules, + filter_findings, + load_rules, + ) rules_path = config.effective_output_dir / "rules.json" ensure_seed_rules(rules_path) @@ -1044,9 +1071,7 @@ def report( console.print( f" Pre-filtered {excluded} findings using {len(existing_rules)} saved rules" ) - from aigiscode.models import ReviewResult - - review_result = ReviewResult(rules_prefiltered=excluded) + review_result = _accumulate_prefiltered(review_result, excluded) if dead_code_categories or min_dead_code_confidence: dead_code_result = filter_dead_code_result( @@ -1061,45 +1086,66 @@ def report( categories=set(hardwiring_categories or []), ) - language_breakdown = store.get_language_breakdown() - detector_coverage = _collect_detector_coverage(language_breakdown) - - report_data = ReportData( - project_path=str(path), - generated_at=datetime.now(), - files_indexed=store.get_file_count(), - symbols_extracted=store.get_symbol_count(), - dependencies_found=store.get_dependency_count(), - unsupported_source_files=sum(unsupported_breakdown.values()), - unsupported_language_breakdown=unsupported_breakdown, - detector_coverage=detector_coverage, - graph_analysis=graph_result, - envelopes_generated=store.get_envelope_count(), - synthesis="", - language_breakdown=language_breakdown, - dead_code=dead_code_result, - hardwiring=hardwiring_result, - review=review_result, + external_analysis = None + security_review_result = None + chosen_external_tools = selected_external_tools( + external_tools, + run_ruff_security=run_ruff_security, ) - from aigiscode.report.contracts import build_contract_inventory - - report_data.extensions = { - "contract_inventory": build_contract_inventory(store), - **build_report_extensions( - runtime_plugins, - report=report_data, - graph=graph, - store=store, + if chosen_external_tools: + console.print(" Running external analyzers...") + external_analysis, external_excluded = collect_external_analysis_for_report( project_path=path, - policy=policy, - ), - } + output_dir=config.effective_output_dir, + run_id=run_id, + selected_tools=chosen_external_tools, + existing_rules=existing_rules, + ctx=ctx, + ) + if external_excluded: + console.print( + f" Pre-filtered {external_excluded} external findings using {len(existing_rules)} saved rules" + ) + review_result = _accumulate_prefiltered( + review_result, + external_excluded, + ) + console.print( + f" External findings: {len(external_analysis.findings)} from {len(external_analysis.tool_runs)} tool(s)" + ) + + report_data = build_report_data( + store=store, + project_path=path, + generated_at=generated_at, + graph=graph, + graph_result=graph_result, + dead_code_result=dead_code_result, + hardwiring_result=hardwiring_result, + review_result=review_result, + security_review_result=security_review_result, + external_analysis=external_analysis, + runtime_plugins=runtime_plugins, + policy=policy, + unsupported_breakdown=deterministic.unsupported_breakdown, + synthesis_text="", + ) - md_path, json_path = write_reports(report_data, config.effective_output_dir) + md_path, json_path = write_reports( + report_data, + config.effective_output_dir, + archive_stem=run_id, + ) store.close() console.print(f" Markdown: {md_path}") console.print(f" JSON: {json_path}") + console.print( + f" Handoff: {config.effective_output_dir / 'aigiscode-handoff.md'}" + ) + console.print( + f" Handoff JSON: {config.effective_output_dir / 'aigiscode-handoff.json'}" + ) console.print() _print_final_summary(report_data) @@ -1108,6 +1154,11 @@ def report( @app.command() def tune( project_path: str = typer.Argument(..., help="Path to the project"), + output_dir: Path | None = typer.Option( + None, + "--output-dir", + help="Directory for aigiscode.db, rules, policies, and reports", + ), plugins: list[str] = typer.Option( None, "--plugin", @@ -1135,7 +1186,7 @@ def tune( """Run AI-assisted trial-and-error policy tuning against existing index.""" _configure_logging(verbose) path = _resolve_project(project_path) - config = AigisCodeConfig(project_path=path) + config = AigisCodeConfig(project_path=path, output_dir=output_dir) _print_header(f"Tuning policy for: {path}") @@ -1171,7 +1222,7 @@ def tune( plugin_modules=plugin_modules or [], external_plugins=external_plugins, ) - runtime_plugins = _combine_runtime_plugins( + runtime_plugins = combine_runtime_plugins( policy.plugins_applied, external_plugins, ) @@ -1211,7 +1262,7 @@ def tune( plugin_modules=plugin_modules or [], external_plugins=external_plugins, ) - candidate_runtime_plugins = _combine_runtime_plugins( + candidate_runtime_plugins = combine_runtime_plugins( candidate_policy.plugins_applied, external_plugins, ) @@ -1281,10 +1332,15 @@ def tune( @app.command() def info( project_path: str = typer.Argument(..., help="Path to the project"), + output_dir: Path | None = typer.Option( + None, + "--output-dir", + help="Directory for aigiscode.db, rules, policies, and reports", + ), ) -> None: """Show information about an existing index.""" path = _resolve_project(project_path) - config = AigisCodeConfig(project_path=path) + config = AigisCodeConfig(project_path=path, output_dir=output_dir) if not config.db_path.exists(): console.print("[red]No index found.[/red] Run `aigiscode index` first.") @@ -1410,5 +1466,12 @@ def _print_final_summary(report: ReportData) -> None: parts.append(f"{r.false_positives} false positives") if parts: summary_lines.append(f"AI Review: {', '.join(parts)}") + if report.feedback_loop.detected_total: + summary_lines.append( + "Feedback Loop: " + f"{report.feedback_loop.actionable_visible} actionable visible, " + f"{report.feedback_loop.accepted_by_policy} accepted by policy, " + f"{report.feedback_loop.rules_generated} new rules" + ) console.print(Panel("\n".join(summary_lines), title="Analysis Complete")) diff --git a/src/aigiscode/graph/deadcode.py b/src/aigiscode/graph/deadcode.py index cfe158f..66c449f 100644 --- a/src/aigiscode/graph/deadcode.py +++ b/src/aigiscode/graph/deadcode.py @@ -23,7 +23,7 @@ logger = logging.getLogger(__name__) SUPPORTED_DEAD_CODE_LANGUAGES = frozenset( - {"php", "python", "javascript", "typescript", "vue"} + {"php", "python", "javascript", "typescript", "vue", "rust"} ) _TS_LIKE_LANGUAGES = frozenset({"javascript", "typescript", "vue"}) @@ -76,6 +76,15 @@ class JSImportBinding: type_only: bool = False +@dataclass(frozen=True) +class RustImportBinding: + """A bound import name inside a Rust file.""" + + name: str + target: str + line: int + + # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- @@ -162,6 +171,7 @@ def find_unused_imports( ) findings.extend(_find_unused_python_imports(store)) findings.extend(_find_unused_ts_like_imports(store)) + findings.extend(_find_unused_rust_imports(store)) return findings @@ -265,6 +275,62 @@ def _find_unused_python_imports(store: IndexStore) -> list[DeadCodeFinding]: return findings +def _find_unused_rust_imports(store: IndexStore) -> list[DeadCodeFinding]: + """Find Rust ``use`` bindings that are never referenced.""" + findings: list[DeadCodeFinding] = [] + + rows = store.conn.execute( + """ + SELECT path + FROM files + WHERE language = 'rust' + ORDER BY path + """ + ).fetchall() + + parser = _get_tree_sitter_parser("rust") + if parser is None: + return findings + + for row in rows: + file_path = row["path"] + if _is_test_like_path(file_path): + continue + + content = _read_file_safe(store, file_path) + if content is None or not content.strip(): + continue + + try: + tree = parser.parse(content.encode("utf-8")) + except Exception: + continue + + bindings = _collect_rust_import_bindings(tree.root_node) + if not bindings: + continue + used_names = _collect_rust_used_identifiers(tree.root_node) + + for binding in bindings: + if binding.name in used_names: + continue + findings.append( + DeadCodeFinding( + file_path=file_path, + line=binding.line, + category="unused_import", + name=binding.target, + detail=( + f"Import '{binding.name}' from '{binding.target}' " + "is never referenced" + ), + confidence="high", + ) + ) + + return findings + + def _find_unused_ts_like_imports(store: IndexStore) -> list[DeadCodeFinding]: """Find unused imports in JS/TS/Vue source files.""" findings: list[DeadCodeFinding] = [] @@ -304,7 +370,7 @@ def _find_unused_ts_like_imports(store: IndexStore) -> list[DeadCodeFinding]: ) continue - parser_language = "typescript" if language == "typescript" else "javascript" + parser_language = _ts_like_parser_language(file_path, language) findings.extend( _analyze_ts_like_unused_imports( file_path, @@ -319,6 +385,12 @@ def _find_unused_ts_like_imports(store: IndexStore) -> list[DeadCodeFinding]: return findings +def _ts_like_parser_language(file_path: str, language: str) -> str: + if language == "typescript" and file_path.endswith(".tsx"): + return "tsx" + return "typescript" if language == "typescript" else "javascript" + + def _analyze_ts_like_unused_imports( file_path: str, content: str, @@ -433,6 +505,13 @@ def find_unused_private_methods(store: IndexStore) -> list[DeadCodeFinding]: r"\bthis\.#" + re.escape(name) + r"\b", ] ) + elif language == "rust": + patterns.extend( + [ + r"\.\s*" + re.escape(name) + r"\s*\(", + r"::" + re.escape(name) + r"\s*\(", + ] + ) if is_property_style_accessor: patterns.extend( [ @@ -521,6 +600,11 @@ def find_unused_private_properties(store: IndexStore) -> list[DeadCodeFinding]: r"\bthis\." + re.escape(name) + r"\b", r"\bthis\.#" + re.escape(name) + r"\b", ] + elif language == "rust": + patterns = [ + r"\." + re.escape(name) + r"\b(?!\s*\()", + r"\b" + re.escape(name) + r"\s*:", + ] else: patterns = [ r"->" + re.escape(name) + r"\b", @@ -617,7 +701,7 @@ def find_abandoned_classes( # All class-level symbols classes = store.conn.execute( f""" - SELECT s.id, s.name, s.namespace, s.type, s.line_start, + SELECT s.id, s.name, s.namespace, s.type, s.visibility, s.line_start, f.path, f.id AS file_id, f.language FROM symbols s JOIN files f ON f.id = s.file_id @@ -667,6 +751,7 @@ def find_abandoned_classes( known_class_tokens=known_class_tokens, ) ) + rust_type_references = _collect_rust_type_reference_map(store) entry_patterns = [*_DEFAULT_ENTRY_POINT_PATTERNS, *(extra_entry_patterns or [])] dynamic_patterns = [ @@ -690,6 +775,8 @@ def find_abandoned_classes( language = str(cls["language"]).lower() if language not in allowed_language_set: continue + if language == "rust" and str(cls["visibility"]).lower() != "public": + continue fqcn = f"{cls['namespace']}\\{cls['name']}" if cls["namespace"] else cls["name"] short_name = cls["name"] @@ -734,6 +821,10 @@ def find_abandoned_classes( if self_deps and self_deps["cnt"] > 0: continue + if language == "rust": + referenced_paths = rust_type_references.get(short_name, set()) + if any(path != cls["path"] for path in referenced_paths): + continue findings.append( DeadCodeFinding( @@ -875,6 +966,117 @@ def _find_tree_children(node, type_name: str) -> list: return [child for child in node.children if child.type == type_name] +def _collect_rust_import_bindings(root_node) -> list[RustImportBinding]: + bindings: list[RustImportBinding] = [] + seen: set[tuple[str, str, int]] = set() + + def walk(node) -> None: + if node.type == "use_declaration": + statement = _node_text(node).strip() + if statement.startswith("use "): + for binding in _expand_rust_use_bindings( + statement[4:].rstrip(";").strip(), + line=node.start_point[0] + 1, + ): + key = (binding.name, binding.target, binding.line) + if key in seen: + continue + seen.add(key) + bindings.append(binding) + return + + for child in node.children: + walk(child) + + walk(root_node) + return bindings + + +def _collect_rust_used_identifiers(root_node) -> set[str]: + used: set[str] = set() + + def walk(node, in_use: bool = False) -> None: + current_in_use = in_use or node.type == "use_declaration" + if not current_in_use and node.type in {"identifier", "type_identifier"}: + text = _node_text(node) + if text: + used.add(text) + for child in node.children: + walk(child, current_in_use) + + walk(root_node) + return used + + +def _expand_rust_use_bindings(path: str, *, line: int) -> list[RustImportBinding]: + path = path.strip() + if not path: + return [] + alias_match = re.search(r"\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$", path) + if alias_match: + target = path[: alias_match.start()].strip() + return [ + RustImportBinding( + name=alias_match.group(1), + target=target.replace(" ", ""), + line=line, + ) + ] + if "{" not in path: + normalized = path.replace(" ", "") + binding = normalized.rsplit("::", 1)[-1] + return [RustImportBinding(name=binding, target=normalized, line=line)] + + brace_start = path.find("{") + brace_end = path.rfind("}") + if brace_start == -1 or brace_end == -1 or brace_end < brace_start: + normalized = path.replace(" ", "") + binding = normalized.rsplit("::", 1)[-1] + return [RustImportBinding(name=binding, target=normalized, line=line)] + + prefix = path[:brace_start].rstrip(":").strip() + inner = path[brace_start + 1 : brace_end] + bindings: list[RustImportBinding] = [] + for item in _split_rust_use_items(inner): + item = item.strip() + if not item or item == "*": + continue + if item == "self": + normalized = prefix.replace(" ", "") + bindings.append( + RustImportBinding( + name=normalized.rsplit("::", 1)[-1], + target=normalized, + line=line, + ) + ) + continue + candidate = item + if prefix and not item.startswith(("crate::", "self::", "super::")): + candidate = f"{prefix}::{item}" + bindings.extend(_expand_rust_use_bindings(candidate, line=line)) + return bindings + + +def _split_rust_use_items(value: str) -> list[str]: + items: list[str] = [] + current: list[str] = [] + depth = 0 + for char in value: + if char == "," and depth == 0: + items.append("".join(current).strip()) + current = [] + continue + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + current.append(char) + if current: + items.append("".join(current).strip()) + return items + + def _collect_ts_import_bindings(root_node) -> list[JSImportBinding]: bindings: list[JSImportBinding] = [] seen: set[tuple[str, str, int]] = set() @@ -1168,6 +1370,7 @@ def _is_test_like_path(path: str) -> bool: or any(part in {"test", "tests", "__tests__", "fixtures"} for part in parts) or normalized.endswith("test.php") or normalized.endswith("_test.py") + or normalized.endswith("_test.rs") ) @@ -1187,6 +1390,7 @@ def _line_contains_method_declaration(line: str, name: str) -> bool: patterns = ( rf"\bfunction\s+{re.escape(name)}\s*\(", rf"\b(?:public|protected|private|static|async|abstract|final|readonly|get|set)\b[^\n{{;]*\b{re.escape(name)}\s*\(", + rf"\b(?:pub(?:\([^)]*\))?\s+)?fn\s+{re.escape(name)}\s*\(", rf"#{re.escape(name)}\s*\(", ) return any(re.search(pattern, line) for pattern in patterns) @@ -1214,6 +1418,13 @@ def _line_contains_property_declaration(line: str, name: str, language: str) -> rf"#{re.escape(name)}\b", ) return any(re.search(pattern, line) for pattern in patterns) + if language == "rust": + return bool( + re.search( + rf"\b(?:pub(?:\([^)]*\))?\s+)?{re.escape(name)}\s*:", + line, + ) + ) return bool( re.search( rf"\b(?:public|protected|private|var|static|readonly)\b[^\n;]*\${re.escape(name)}\b", @@ -1226,9 +1437,12 @@ def _line_contains_type_declaration(line: str, name: str, symbol_type: str) -> b """Guard against stale class/interface/trait rows.""" if not line: return False - return bool( - re.search(rf"\b{re.escape(str(symbol_type))}\s+{re.escape(name)}\b", line) - ) + token = str(symbol_type) + if token == "class": + token = "struct" + elif token == "interface": + token = "trait" + return bool(re.search(rf"\b{re.escape(token)}\s+{re.escape(name)}\b", line)) def _collect_dynamic_reference_tokens( @@ -1298,6 +1512,22 @@ def _collect_runtime_string_class_references( return tokens +def _collect_rust_type_reference_map(store: IndexStore) -> dict[str, set[str]]: + """Collect PascalCase type references from Rust files keyed by short name.""" + references: dict[str, set[str]] = defaultdict(set) + rows = store.conn.execute( + "SELECT path FROM files WHERE lower(language) = 'rust' ORDER BY path" + ).fetchall() + for row in rows: + path = row["path"] + content = _read_file_safe(store, path) + if content is None: + continue + for token in set(re.findall(r"\b[A-Z][A-Za-z0-9_]*\b", content)): + references[token].add(path) + return references + + def _extract_class_reference_tokens(content: str) -> set[str]: """Extract likely class reference tokens from dynamic PHP surfaces.""" tokens: set[str] = set() diff --git a/src/aigiscode/graph/hardwiring.py b/src/aigiscode/graph/hardwiring.py index fb0c07a..20c7856 100644 --- a/src/aigiscode/graph/hardwiring.py +++ b/src/aigiscode/graph/hardwiring.py @@ -21,7 +21,16 @@ logger = logging.getLogger(__name__) SUPPORTED_HARDWIRING_LANGUAGES = frozenset( - {"php", "python", "ruby", "javascript", "typescript", "vue"} + {"php", "python", "ruby", "javascript", "typescript", "vue", "rust"} +) +HARDCODED_IP_URL_CATEGORY = "hardcoded_ip_url" +_PRAGMA_DATABASE_LIST = "PRAGMA database_list" +_TEST_LIKE_PATH_PARTS = frozenset({"test", "tests", "__tests__", "fixtures", "spec"}) +_TEST_LIKE_PATH_SUFFIXES = ( + "test.php", + "_test.py", + "_test.rb", + "_spec.rb", ) @@ -64,7 +73,7 @@ def _apply_finding_plugins( findings: list[HardwiringFinding], *, category: str, - external_plugins: list["ExternalPlugin"] | None, + external_plugins: list[ExternalPlugin] | None, store: IndexStore, project_path: Path | None, policy: HardwiringPolicy, @@ -209,6 +218,9 @@ def _apply_finding_plugins( _RE_RB_ENV = re.compile( r"\b(?:ENV\.fetch\s*\(\s*['\"][A-Z][A-Z0-9_]*['\"]|ENV\s*\[\s*['\"][A-Z][A-Z0-9_]*['\"]\s*\])" ) +_RE_RUST_ENV = re.compile( + r"\b(?:(?:std::)?env::(?:var|var_os)\s*\(|(?:std::)?env!\s*\(|option_env!\s*\()" +) _RE_CONST_DEF = re.compile(r"\bconst\s+\w+\s*=") _RE_DOCBLOCK = re.compile(r"^\s*\*") _RE_LOCALE_CODE = re.compile(r"^[a-z]{2}(?:[_-][A-Z]{2})?$") @@ -266,7 +278,7 @@ def _apply_finding_plugins( r"serializer|field|setting|option|attribute|attr|header|cookie))\b", re.IGNORECASE, ) -_ENTITY_CONTEXT_TOKEN_PATTERN = ( +_ENTITY_CONTEXT_MARKER_PATTERN = ( r"(?:entity(?:Type|Types|Name)?|relatedEntity(?:Type)?|rootEntityType|" r"folderEntityType|parsedEntityType|parent(?:_type|Type|Entity)|" r"target(?:_type|Type|Entity)|source(?:_type|Type|Entity)|entity_types|" @@ -274,12 +286,12 @@ def _apply_finding_plugins( r"(?:->|::)?getEntityType\s*\(\))" ) _RE_ENTITY_CONTEXT_DIRECT_PREFIX = re.compile( - rf"{_ENTITY_CONTEXT_TOKEN_PATTERN}[^\n]{{0,120}}" + rf"{_ENTITY_CONTEXT_MARKER_PATTERN}[^\n]{{0,120}}" rf"(?:===|!==|==|!=|=>|=|:|\?\?=|\?\?)\s*$", re.IGNORECASE, ) _RE_ENTITY_CONTEXT_DIRECT_SUFFIX = re.compile( - rf"^\s*(?:===|!==|==|!=)\s*[^\n]{{0,120}}{_ENTITY_CONTEXT_TOKEN_PATTERN}", + rf"^\s*(?:===|!==|==|!=)\s*[^\n]{{0,120}}{_ENTITY_CONTEXT_MARKER_PATTERN}", re.IGNORECASE, ) _RE_TEMPLATE_DIRECTIVE = re.compile(r"^\s*(?::|@|v-[a-z])", re.IGNORECASE) @@ -351,7 +363,7 @@ def analyze_hardwiring( store: IndexStore, min_occurrences: int = 3, policy: HardwiringPolicy | None = None, - external_plugins: list["ExternalPlugin"] | None = None, + external_plugins: list[ExternalPlugin] | None = None, project_path: Path | None = None, ) -> HardwiringResult: """Run hardwiring analyses on supported indexed source files.""" @@ -367,7 +379,7 @@ def analyze_hardwiring( """ SELECT id, path, language FROM files - WHERE language IN ('php', 'python', 'ruby', 'javascript', 'typescript', 'vue') + WHERE language IN ('php', 'python', 'ruby', 'javascript', 'typescript', 'vue', 'rust') """ ).fetchall() @@ -447,7 +459,7 @@ def analyze_hardwiring( result.hardcoded_network.extend( _apply_finding_plugins( hardcoded_network, - category="hardcoded_ip_url", + category=HARDCODED_IP_URL_CATEGORY, external_plugins=external_plugins, store=store, project_path=project_path, @@ -755,7 +767,7 @@ def _find_hardcoded_network(file_path: str, content: str) -> list[HardwiringFind HardwiringFinding( file_path=file_path, line=lineno, - category="hardcoded_ip_url", + category=HARDCODED_IP_URL_CATEGORY, value=ip, context=line.strip(), severity="medium", @@ -775,7 +787,7 @@ def _find_hardcoded_network(file_path: str, content: str) -> list[HardwiringFind HardwiringFinding( file_path=file_path, line=lineno, - category="hardcoded_ip_url", + category=HARDCODED_IP_URL_CATEGORY, value=url, context=line.strip(), severity=_classify_network_severity(url, line), @@ -873,6 +885,23 @@ def _find_env_outside_config( ) ) continue + if language == "rust" and _RE_RUST_ENV.search(line): + findings.append( + HardwiringFinding( + file_path=file_path, + line=lineno, + category="env_outside_config", + value="env", + context=stripped, + severity="high", + confidence="high", + suggestion=( + "Route environment reads through a dedicated config layer " + "instead of direct std::env/env! access." + ), + ) + ) + continue if language not in {"php", "python", "ruby"} and _RE_JS_ENV.search(line): env_name_match = _RE_JS_ENV_NAME.search(line) if ( @@ -996,7 +1025,7 @@ def _get_entity_type_names(store: IndexStore) -> set[str]: def _read_file_safe(store: IndexStore, relative_path: str) -> str | None: """Read a file from the project root, returning None on any failure.""" - db_path = Path(store.conn.execute("PRAGMA database_list").fetchone()["file"]) + db_path = Path(store.conn.execute(_PRAGMA_DATABASE_LIST).fetchone()["file"]) project_root = db_path.parent.parent full_path = project_root / relative_path if not full_path.exists(): @@ -1088,13 +1117,8 @@ def _is_test_like_path(path: str) -> bool: parts = [part for part in normalized.split("/") if part] return ( "test" in stem - or any( - part in {"test", "tests", "__tests__", "fixtures", "spec"} for part in parts - ) - or normalized.endswith("test.php") - or normalized.endswith("_test.py") - or normalized.endswith("_test.rb") - or normalized.endswith("_spec.rb") + or any(part in _TEST_LIKE_PATH_PARTS for part in parts) + or normalized.endswith(_TEST_LIKE_PATH_SUFFIXES) ) @@ -1110,6 +1134,7 @@ def _is_build_tooling_path(path: str) -> bool: "vite.config.ts", "eslint.config.js", "rakefile", + "build.rs", } return filename in tooling_filenames or normalized.startswith("tools/") diff --git a/src/aigiscode/indexer/parser.py b/src/aigiscode/indexer/parser.py index d06264d..ca83d4c 100644 --- a/src/aigiscode/indexer/parser.py +++ b/src/aigiscode/indexer/parser.py @@ -1,7 +1,7 @@ """Source parser and indexer. Handles file discovery, language detection, parsing, and orchestrates -symbol extraction for PHP, Python, Ruby, TypeScript, JavaScript, and Vue files. +symbol extraction for PHP, Python, Ruby, Rust, TypeScript, JavaScript, and Vue files. """ from __future__ import annotations @@ -35,18 +35,29 @@ extract_php_symbols, extract_python_symbols, extract_ruby_symbols, + extract_rust_symbols, extract_ts_symbols, extract_vue_symbols, ) logger = logging.getLogger(__name__) +FILES_INDEXED_KEY = "files_indexed" +FILES_SKIPPED_KEY = "files_skipped" +FILES_PRUNED_KEY = "files_pruned" +SYMBOLS_EXTRACTED_KEY = "symbols_extracted" +DEPENDENCIES_FOUND_KEY = "dependencies_found" +UNSUPPORTED_SOURCE_FILES_KEY = "unsupported_source_files" +UNSUPPORTED_LANGUAGE_BREAKDOWN_KEY = "unsupported_language_breakdown" +ERRORS_KEY = "errors" + # Map file extensions to languages EXTENSION_MAP: dict[str, Language] = { ".php": Language.PHP, ".py": Language.PYTHON, ".rb": Language.RUBY, + ".rs": Language.RUST, ".ts": Language.TYPESCRIPT, ".tsx": Language.TYPESCRIPT, ".js": Language.JAVASCRIPT, @@ -59,6 +70,7 @@ Language.PHP: "php", Language.PYTHON: "python", Language.RUBY: "ruby", + Language.RUST: "rust", Language.TYPESCRIPT: "typescript", Language.JAVASCRIPT: "javascript", Language.VUE: "html", # Vue SFCs are parsed as HTML first @@ -68,7 +80,6 @@ ".go": "go", ".java": "java", ".kt": "kotlin", - ".rs": "rust", ".cs": "csharp", } @@ -100,6 +111,19 @@ def discover_project_files( else: simple_excludes.add(exc) + # When output_dir lives inside the target repo, avoid indexing generated artifacts. + try: + relative_output_dir = config.effective_output_dir.relative_to(config.project_path) + except ValueError: + relative_output_dir = None + if relative_output_dir is not None: + output_path = str(relative_output_dir) + if output_path and output_path != ".": + if "/" in output_path: + path_excludes.append(output_path) + else: + simple_excludes.add(output_path) + logger.debug("Exclusions: simple=%s, path=%s", simple_excludes, path_excludes) for root, dirs, filenames in os.walk(config.project_path): @@ -214,6 +238,8 @@ def parse_file( return symbols, dependencies elif language == Language.RUBY: return extract_ruby_symbols(root) + elif language == Language.RUST: + return extract_rust_symbols(root) elif language in (Language.TYPESCRIPT, Language.JAVASCRIPT): return extract_ts_symbols(root) elif language == Language.VUE: @@ -354,12 +380,12 @@ def index_project(config: AigisCodeConfig, store: IndexStore) -> dict: ) return { - "files_indexed": total_files, - "files_skipped": skipped, - "files_pruned": pruned, - "symbols_extracted": total_symbols, - "dependencies_found": total_dependencies, - "unsupported_source_files": sum(unsupported.values()), - "unsupported_language_breakdown": unsupported, - "errors": errors, + FILES_INDEXED_KEY: total_files, + FILES_SKIPPED_KEY: skipped, + FILES_PRUNED_KEY: pruned, + SYMBOLS_EXTRACTED_KEY: total_symbols, + DEPENDENCIES_FOUND_KEY: total_dependencies, + UNSUPPORTED_SOURCE_FILES_KEY: sum(unsupported.values()), + UNSUPPORTED_LANGUAGE_BREAKDOWN_KEY: unsupported, + ERRORS_KEY: errors, } diff --git a/src/aigiscode/indexer/symbols.py b/src/aigiscode/indexer/symbols.py index 72f9345..914e235 100644 --- a/src/aigiscode/indexer/symbols.py +++ b/src/aigiscode/indexer/symbols.py @@ -52,7 +52,7 @@ def _get_visibility(node: Node) -> Visibility: for child in node.children: if child.type == "visibility_modifier": text = _text(child).lower() - if text == "public": + if text in {"public", "pub"}: return Visibility.PUBLIC elif text == "protected": return Visibility.PROTECTED @@ -61,6 +61,18 @@ def _get_visibility(node: Node) -> Visibility: return Visibility.UNKNOWN +def _get_rust_visibility( + node: Node, + *, + default: Visibility = Visibility.PRIVATE, +) -> Visibility: + """Extract Rust visibility, defaulting to Rust's private-by-default semantics.""" + modifier = _find_child(node, "visibility_modifier") + if modifier is None: + return default + return Visibility.PUBLIC if _text(modifier).startswith("pub") else default + + def _extract_namespace_name(node: Node) -> str: """Extract the full namespace name from a namespace_definition or namespace_name node.""" ns_name = _find_child(node, "namespace_name") @@ -1243,6 +1255,253 @@ def _should_collect_ruby_constant_dependency(node: Node, target_name: str) -> bo return target_name not in {"ENV"} +# --- Rust Symbol Extraction --- + + +def extract_rust_symbols( + root_node: Node, +) -> tuple[list[SymbolInfo], list[DependencyInfo]]: + """Extract symbols and dependencies from a Rust parse tree.""" + symbols: list[SymbolInfo] = [] + dependencies: list[DependencyInfo] = [] + + def walk( + node: Node, + current_owner: str | None = None, + current_owner_kind: str | None = None, + ) -> None: + if node.type == "use_declaration": + for target_name in _expand_rust_use_paths(_text(node)[4:].rstrip(";").strip()): + dependencies.append( + DependencyInfo( + target_name=target_name, + type=DependencyType.IMPORT, + line=node.start_point[0] + 1, + ) + ) + return + + if node.type == "mod_item": + name_node = _find_child(node, "identifier") + if name_node is not None: + symbols.append( + SymbolInfo( + type=SymbolType.MODULE, + name=_text(name_node), + visibility=_get_rust_visibility(node), + line_start=node.start_point[0] + 1, + line_end=node.end_point[0] + 1, + ) + ) + return + + if node.type == "struct_item": + name_node = _find_child(node, "type_identifier") + if name_node is None: + return + struct_name = _text(name_node) + symbols.append( + SymbolInfo( + type=SymbolType.CLASS, + name=struct_name, + visibility=_get_rust_visibility(node), + line_start=node.start_point[0] + 1, + line_end=node.end_point[0] + 1, + ) + ) + fields = _find_child(node, "field_declaration_list") + if fields is not None: + for field in _find_children(fields, "field_declaration"): + field_name = _find_child(field, "field_identifier") + if field_name is None: + continue + symbols.append( + SymbolInfo( + type=SymbolType.PROPERTY, + name=_text(field_name), + namespace=struct_name, + visibility=_get_rust_visibility(field), + line_start=field.start_point[0] + 1, + line_end=field.end_point[0] + 1, + ) + ) + return + + if node.type == "enum_item": + name_node = _find_child(node, "type_identifier") + if name_node is not None: + symbols.append( + SymbolInfo( + type=SymbolType.ENUM, + name=_text(name_node), + visibility=_get_rust_visibility(node), + line_start=node.start_point[0] + 1, + line_end=node.end_point[0] + 1, + ) + ) + return + + if node.type == "trait_item": + name_node = _find_child(node, "type_identifier") + if name_node is None: + return + trait_name = _text(name_node) + symbols.append( + SymbolInfo( + type=SymbolType.INTERFACE, + name=trait_name, + visibility=_get_rust_visibility(node), + line_start=node.start_point[0] + 1, + line_end=node.end_point[0] + 1, + ) + ) + for child in node.children: + walk( + child, + current_owner=trait_name, + current_owner_kind="trait", + ) + return + + if node.type == "impl_item": + target_name = None + identifiers = [ + _text(child) + for child in node.children + if child.type == "type_identifier" + ] + has_for = any(child.type == "for" for child in node.children) + if has_for and len(identifiers) >= 2: + dependencies.append( + DependencyInfo( + target_name=identifiers[0], + type=DependencyType.IMPLEMENT, + line=node.start_point[0] + 1, + ) + ) + target_name = identifiers[1] + elif identifiers: + target_name = identifiers[0] + for child in node.children: + walk( + child, + current_owner=target_name, + current_owner_kind="impl", + ) + return + + if node.type in {"function_item", "function_signature_item"}: + name_node = _find_child(node, "identifier") + if name_node is None: + return + metadata = _extract_rust_function_metadata(node) + symbol_type = SymbolType.METHOD if current_owner else SymbolType.FUNCTION + visibility = ( + Visibility.PUBLIC + if current_owner_kind == "trait" + else _get_rust_visibility(node) + ) + symbols.append( + SymbolInfo( + type=symbol_type, + name=_text(name_node), + namespace=current_owner or None, + visibility=visibility, + line_start=node.start_point[0] + 1, + line_end=node.end_point[0] + 1, + metadata=metadata, + ) + ) + return + + for child in node.children: + walk( + child, + current_owner=current_owner, + current_owner_kind=current_owner_kind, + ) + + walk(root_node) + dependencies = _dedupe_dependencies(dependencies) + logger.debug( + "Rust extraction: %d symbols, %d dependencies", + len(symbols), + len(dependencies), + ) + return symbols, dependencies + + +def _extract_rust_function_metadata(node: Node) -> dict[str, object]: + metadata: dict[str, object] = {} + params_node = _find_child(node, "parameters") + if params_node is None: + return metadata + + params: list[str] = [] + has_self_parameter = False + for child in params_node.children: + if child.type in {"self_parameter", "parameter", "identifier"}: + text = _text(child).strip() + if text: + params.append(text) + if "self" in text: + has_self_parameter = True + if params: + metadata["params"] = params + if has_self_parameter: + metadata["has_self_parameter"] = True + return metadata + + +def _expand_rust_use_paths(path: str) -> list[str]: + path = path.strip() + if not path: + return [] + path = re.sub(r"\s+as\s+[A-Za-z_][A-Za-z0-9_]*$", "", path) + if "{" not in path: + return [path.replace(" ", "")] + + brace_start = path.find("{") + brace_end = path.rfind("}") + if brace_start == -1 or brace_end == -1 or brace_end < brace_start: + return [path.replace(" ", "")] + + prefix = path[:brace_start].rstrip(":") + inner = path[brace_start + 1 : brace_end] + expanded: list[str] = [] + for item in _split_rust_use_items(inner): + item = item.strip() + if not item: + continue + if item == "self": + expanded.append(prefix) + continue + candidate = item + if prefix and not item.startswith(("crate::", "self::", "super::")): + candidate = f"{prefix}::{item}" + expanded.extend(_expand_rust_use_paths(candidate)) + return [item for item in expanded if item] + + +def _split_rust_use_items(value: str) -> list[str]: + items: list[str] = [] + current: list[str] = [] + depth = 0 + for char in value: + if char == "," and depth == 0: + items.append("".join(current).strip()) + current = [] + continue + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + current.append(char) + if current: + items.append("".join(current).strip()) + return items + + # --- TypeScript/JavaScript Symbol Extraction --- @@ -1462,8 +1721,8 @@ def extract_vue_symbols( symbols.extend(ts_symbols) dependencies.extend(ts_deps) - except Exception: - pass # If TS parsing fails, we still have the Vue structure + except Exception as exc: + logger.debug("Falling back to Vue-only symbol extraction: %s", exc) return symbols, dependencies diff --git a/src/aigiscode/models.py b/src/aigiscode/models.py index 66135ec..2937d8a 100644 --- a/src/aigiscode/models.py +++ b/src/aigiscode/models.py @@ -17,6 +17,7 @@ class Language(str, enum.Enum): PHP = "php" PYTHON = "python" RUBY = "ruby" + RUST = "rust" TYPESCRIPT = "typescript" JAVASCRIPT = "javascript" VUE = "vue" @@ -172,6 +173,51 @@ class GraphAnalysisResult(BaseModel): density: float = 0.0 +# --- External analysis models --- + + +class ExternalFinding(BaseModel): + """A single finding from an external analysis tool.""" + + tool: str + rule_id: str = "" + file_path: str = "" + line: int = 0 + message: str = "" + severity: str = "medium" + domain: str = "security" + category: str = "" + fingerprint: str = "" + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ExternalToolRun(BaseModel): + """Record of a single external tool execution.""" + + tool: str + command: list[str] = Field(default_factory=list) + status: str = "pending" + findings_count: int = 0 + summary: dict[str, Any] = Field(default_factory=dict) + version: str = "" + + +class ExternalAnalysisResult(BaseModel): + """Aggregate result of all external tool runs.""" + + tool_runs: list[ExternalToolRun] = Field(default_factory=list) + findings: list[ExternalFinding] = Field(default_factory=list) + + +class FeedbackLoop(BaseModel): + """Metrics for the feedback loop between detection and policy.""" + + detected_total: int = 0 + actionable_visible: int = 0 + accepted_by_policy: int = 0 + rules_generated: int = 0 + + # --- Report models --- @@ -193,7 +239,9 @@ class ReportData(BaseModel): dead_code: Any | None = None # DeadCodeResult from graph.deadcode hardwiring: Any | None = None # HardwiringResult from graph.hardwiring review: Any | None = None # ReviewResult from review.ai_reviewer + external_analysis: Any | None = None # ExternalAnalysisResult extensions: dict[str, Any] = Field(default_factory=dict) + feedback_loop: FeedbackLoop = Field(default_factory=FeedbackLoop) # --- AI Review models --- @@ -244,6 +292,7 @@ class AigisCodeConfig(BaseModel): Language.PHP, Language.PYTHON, Language.RUBY, + Language.RUST, Language.TYPESCRIPT, Language.JAVASCRIPT, Language.VUE, diff --git a/src/aigiscode/orchestration.py b/src/aigiscode/orchestration.py new file mode 100644 index 0000000..f16c0da --- /dev/null +++ b/src/aigiscode/orchestration.py @@ -0,0 +1,305 @@ +"""Shared analysis pipeline logic used by CLI commands. + +This module provides the glue between the indexer, graph analyzers, +external security tools, extensions, and report builder. Each public +function corresponds to a reusable pipeline step that ``cli.py`` +commands call directly. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any + +from aigiscode.extensions import ( + ExternalPlugin, + apply_dead_code_result_plugins, + apply_graph_result_plugins, + apply_hardwiring_result_plugins, + build_report_extensions, + load_external_plugins, +) +from aigiscode.graph.analyzer import analyze_graph +from aigiscode.graph.builder import build_file_graph +from aigiscode.graph.deadcode import analyze_dead_code +from aigiscode.graph.hardwiring import analyze_hardwiring +from aigiscode.indexer.parser import discover_unsupported_source_files +from aigiscode.models import ( + AigisCodeConfig, + ExternalAnalysisResult, + FeedbackLoop, + GraphAnalysisResult, + ReportData, + ReviewResult, +) +from aigiscode.policy.models import AnalysisPolicy +from aigiscode.policy.plugins import resolve_policy +from aigiscode.rules.engine import filter_external_findings +from aigiscode.security.external import ( + SUPPORTED_SECURITY_TOOLS, + collect_external_analysis, +) + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class RuntimeEnvironment: + """Resolved policy and loaded runtime plugins.""" + + policy: AnalysisPolicy + runtime_plugins: list[ExternalPlugin] = field(default_factory=list) + + +@dataclass +class DeterministicResult: + """Output of the deterministic (non-AI) analysis pipeline.""" + + graph: Any # nx.DiGraph + graph_result: GraphAnalysisResult + dead_code_result: Any # DeadCodeResult + hardwiring_result: Any # HardwiringResult + unsupported_breakdown: dict[str, int] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Public helpers +# --------------------------------------------------------------------------- + + +def selected_external_tools( + external_tools: list[str] | None, + *, + run_ruff_security: bool = False, +) -> list[str] | None: + """Normalise the user-provided external tool selection. + + Returns ``None`` when no external tools should be run, or a concrete + list of tool names (with ``"all"`` expanded). + """ + tools: list[str] | None = None + + if external_tools: + if "all" in external_tools: + tools = list(SUPPORTED_SECURITY_TOOLS) + else: + tools = list(external_tools) + + if run_ruff_security: + if tools is None: + tools = ["ruff"] + elif "ruff" not in tools: + tools.append("ruff") + + return tools if tools else None + + +def combine_runtime_plugins( + plugins_applied: list[str], + external_plugins: list[ExternalPlugin], +) -> list[ExternalPlugin]: + """Return only those external plugins whose ref appears in *plugins_applied*. + + The policy resolver prefixes external plugin refs with ``"module:"`` + when they contribute a policy patch. This function filters the full + loaded plugin list down to those that were actually applied. + """ + applied_refs = { + entry.removeprefix("module:") + for entry in plugins_applied + if entry.startswith("module:") + } + return [p for p in external_plugins if p.ref in applied_refs] + + +def resolve_runtime_environment(config: AigisCodeConfig) -> RuntimeEnvironment: + """Load external plugins and resolve the full analysis policy. + + This is the first step every CLI command performs after parsing + arguments. + """ + external_plugins = load_external_plugins(config.plugin_modules or None) + policy = resolve_policy( + config.project_path, + plugin_names=config.plugins or [], + policy_file=config.policy_file, + plugin_modules=config.plugin_modules or [], + external_plugins=external_plugins, + ) + runtime_plugins = combine_runtime_plugins( + policy.plugins_applied, + external_plugins, + ) + return RuntimeEnvironment(policy=policy, runtime_plugins=runtime_plugins) + + +def run_deterministic_analysis( + *, + config: AigisCodeConfig, + store: Any, + policy: AnalysisPolicy, + runtime_plugins: list[ExternalPlugin], +) -> DeterministicResult: + """Execute the graph, dead-code, and hardwiring analysers. + + This is the deterministic (no-AI) pipeline that produces structural + findings. Plugin hooks are applied on each result before returning. + """ + unsupported_breakdown = discover_unsupported_source_files(config) + + graph = build_file_graph(store, policy=policy.graph) + graph_result = analyze_graph(graph, store, policy=policy.graph) + graph_result = apply_graph_result_plugins( + graph_result, + runtime_plugins, + graph=graph, + store=store, + project_path=config.project_path, + policy=policy, + ) + + dead_code_result = analyze_dead_code(store, policy=policy.dead_code) + dead_code_result = apply_dead_code_result_plugins( + dead_code_result, + runtime_plugins, + store=store, + project_path=config.project_path, + policy=policy, + ) + + hardwiring_result = analyze_hardwiring( + store, + policy=policy.hardwiring, + external_plugins=runtime_plugins, + project_path=config.project_path, + ) + hardwiring_result = apply_hardwiring_result_plugins( + hardwiring_result, + runtime_plugins, + store=store, + project_path=config.project_path, + policy=policy, + ) + + return DeterministicResult( + graph=graph, + graph_result=graph_result, + dead_code_result=dead_code_result, + hardwiring_result=hardwiring_result, + unsupported_breakdown=unsupported_breakdown, + ) + + +def collect_external_analysis_for_report( + *, + project_path: Path, + output_dir: Path, + run_id: str, + selected_tools: list[str], + existing_rules: list[Any], + ctx: Any, +) -> tuple[ExternalAnalysisResult, int]: + """Run external security tools and pre-filter their findings. + + Returns the (possibly filtered) :class:`ExternalAnalysisResult` and + the number of findings excluded by saved rules. + """ + result = collect_external_analysis( + project_path=project_path, + output_dir=output_dir, + run_id=run_id, + selected_tools=selected_tools, + ) + + excluded = 0 + if existing_rules and result.findings: + result, excluded = filter_external_findings( + result, + existing_rules, + ctx=ctx, + ) + + return result, excluded + + +def build_report_data( + *, + store: Any, + project_path: Path, + generated_at: datetime, + graph: Any, + graph_result: GraphAnalysisResult, + dead_code_result: Any, + hardwiring_result: Any, + review_result: ReviewResult | None, + security_review_result: Any | None = None, + external_analysis: ExternalAnalysisResult | None = None, + runtime_plugins: list[ExternalPlugin], + policy: AnalysisPolicy, + unsupported_breakdown: dict[str, int], + synthesis_text: str = "", + envelopes_generated: int = 0, +) -> ReportData: + """Assemble a :class:`ReportData` from all analysis artefacts.""" + extensions = build_report_extensions( + runtime_plugins, + report=None, + graph=graph, + store=store, + project_path=project_path, + policy=policy, + ) + + # Compute feedback loop metrics + detected_total = ( + getattr(dead_code_result, "total", 0) + + getattr(hardwiring_result, "total", 0) + ) + rules_prefiltered = 0 + rules_generated = 0 + true_positives = 0 + false_positives = 0 + + if review_result is not None: + rules_prefiltered = getattr(review_result, "rules_prefiltered", 0) + rules_generated = getattr(review_result, "rules_generated", 0) + true_positives = review_result.true_positives + false_positives = review_result.false_positives + + actionable_visible = true_positives + accepted_by_policy = rules_prefiltered + false_positives + + feedback_loop = FeedbackLoop( + detected_total=detected_total, + actionable_visible=actionable_visible, + accepted_by_policy=accepted_by_policy, + rules_generated=rules_generated, + ) + + return ReportData( + project_path=str(project_path), + generated_at=generated_at, + files_indexed=store.get_file_count(), + symbols_extracted=store.get_symbol_count(), + dependencies_found=store.get_dependency_count(), + unsupported_source_files=sum(unsupported_breakdown.values()), + unsupported_language_breakdown=unsupported_breakdown, + graph_analysis=graph_result, + dead_code=dead_code_result, + hardwiring=hardwiring_result, + review=review_result, + external_analysis=external_analysis, + envelopes_generated=envelopes_generated, + synthesis=synthesis_text, + language_breakdown=store.get_language_breakdown(), + extensions=extensions, + feedback_loop=feedback_loop, + ) diff --git a/src/aigiscode/policy/models.py b/src/aigiscode/policy/models.py index 7cbdad3..a7715ef 100644 --- a/src/aigiscode/policy/models.py +++ b/src/aigiscode/policy/models.py @@ -19,7 +19,7 @@ class DeadCodePolicy(BaseModel): """Dead-code detector behavior.""" attribute_usage_names: list[str] = Field(default_factory=lambda: ["Override"]) - abandoned_languages: list[str] = Field(default_factory=lambda: ["php"]) + abandoned_languages: list[str] = Field(default_factory=lambda: ["php", "rust"]) abandoned_entry_patterns: list[str] = Field(default_factory=list) abandoned_dynamic_reference_patterns: list[str] = Field(default_factory=list) diff --git a/src/aigiscode/report/generator.py b/src/aigiscode/report/generator.py index d32f918..a5dc39b 100644 --- a/src/aigiscode/report/generator.py +++ b/src/aigiscode/report/generator.py @@ -283,6 +283,40 @@ def generate_markdown_report(report: ReportData) -> str: lines.append("No hardwiring issues detected.") lines.append("") + security_summary = _generate_security_summary(report) + if security_summary["total_findings"] > 0: + lines.append("## Security Analysis") + lines.append("") + lines.append("| Signal | Count |") + lines.append("|--------|-------|") + lines.append( + f"| Hardcoded network endpoints | {security_summary['hardcoded_network']} |" + ) + lines.append( + f"| Environment reads outside config | {security_summary['env_outside_config']} |" + ) + lines.append( + f"| High-severity security signals | {security_summary['high_severity']} |" + ) + if security_summary["ai_confirmed"]: + lines.append( + f"| AI-confirmed security findings | {security_summary['ai_confirmed']} |" + ) + lines.append("") + + top_findings = security_summary["top_findings"] + if top_findings: + lines.append("### Highest-Signal Findings") + lines.append("") + lines.append("| File | Category | Value | Severity | Confidence |") + lines.append("|------|----------|-------|----------|------------|") + for finding in top_findings: + lines.append( + f"| `{finding['file']}:{finding['line']}` | {finding['category']} | " + f"`{finding['value']}` | {finding['severity']} | {finding['confidence']} |" + ) + lines.append("") + # --- AI Finding Review --- if report.review: rv = report.review @@ -366,6 +400,7 @@ def generate_markdown_report(report: ReportData) -> str: def generate_json_report(report: ReportData) -> dict: """Generate a structured JSON report from the analysis data.""" ga = report.graph_analysis + graph_analysis = _serialize_graph_analysis(ga) return { "version": "0.1.0", @@ -381,11 +416,158 @@ def generate_json_report(report: ReportData) -> dict: "unsupported_language_breakdown": report.unsupported_language_breakdown, "detector_coverage": report.detector_coverage, }, + "graph_analysis": graph_analysis, "graph": { - "nodes": ga.node_count, - "edges": ga.edge_count, - "density": ga.density, + "nodes": graph_analysis["node_count"], + "edges": graph_analysis["edge_count"], + "density": graph_analysis["density"], }, + "circular_dependencies": graph_analysis["circular_dependencies"], + "strong_circular_dependencies": graph_analysis["strong_circular_dependencies"], + "coupling_metrics": graph_analysis["coupling_metrics"], + "god_classes": graph_analysis["god_classes"], + "bottlenecks": graph_analysis["bottleneck_files"], + "layer_violations": graph_analysis["layer_violations"], + "orphan_files": graph_analysis["orphan_files"], + "runtime_entry_candidates": graph_analysis["runtime_entry_candidates"], + "dead_code": _serialize_dead_code(report.dead_code) + if report.dead_code + else None, + "hardwiring": _serialize_hardwiring(report.hardwiring) + if report.hardwiring + else None, + "security": _generate_security_summary(report), + "review": _serialize_review(report.review) if report.review else None, + "external_analysis": _serialize_external_analysis(report.external_analysis) + if report.external_analysis + else None, + "agent_handoff": _generate_agent_handoff(report), + "extensions": report.extensions, + "recommendations": _generate_recommendations(report), + } + + +def allocate_archive_stem(output_dir: Path, timestamp: str) -> str: + """Return a unique archive stem, appending _N if the directory already exists.""" + archive_dir = output_dir / "reports" + candidate = timestamp + counter = 0 + while (archive_dir / candidate).exists(): + counter += 1 + candidate = f"{timestamp}_{counter}" + return candidate + + +def write_reports( + report: ReportData, output_dir: Path, archive_stem: str | None = None +) -> tuple[Path, Path]: + """Write both Markdown and JSON reports to the output directory. + + Returns (markdown_path, json_path). + """ + output_dir.mkdir(parents=True, exist_ok=True) + + md_path = output_dir / "aigiscode-report.md" + json_path = output_dir / "aigiscode-report.json" + archive_dir = output_dir / "reports" + + if archive_stem: + stem_dir = archive_dir / archive_stem + stem_dir.mkdir(parents=True, exist_ok=True) + archive_md_path = stem_dir / "aigiscode-report.md" + archive_json_path = stem_dir / "aigiscode-report.json" + else: + stem_dir = None + timestamp = report.generated_at.strftime("%Y%m%d_%H%M%S") + archive_dir.mkdir(parents=True, exist_ok=True) + archive_md_path = archive_dir / f"{timestamp}-aigiscode-report.md" + archive_json_path = archive_dir / f"{timestamp}-aigiscode-report.json" + + md_content = generate_markdown_report(report) + md_path.write_text(md_content, encoding="utf-8") + archive_md_path.write_text(md_content, encoding="utf-8") + + json_content = generate_json_report(report) + json_text = json.dumps(json_content, indent=2, ensure_ascii=False) + json_path.write_text(json_text, encoding="utf-8") + archive_json_path.write_text(json_text, encoding="utf-8") + + # Write flat-named archive copies for backward compat when using subdirectories + if archive_stem: + flat_archive_md = archive_dir / f"{archive_stem}-aigiscode-report.md" + flat_archive_json = archive_dir / f"{archive_stem}-aigiscode-report.json" + flat_archive_md.write_text(md_content, encoding="utf-8") + flat_archive_json.write_text(json_text, encoding="utf-8") + + _write_handoff(report, output_dir, stem_dir=stem_dir) + + # Write external-analysis.json to the archive subdirectory if present + if report.external_analysis and stem_dir is not None: + ea_text = json.dumps( + _serialize_external_analysis(report.external_analysis), + indent=2, + ensure_ascii=False, + ) + (stem_dir / "external-analysis.json").write_text(ea_text, encoding="utf-8") + + return md_path, json_path + + +def _write_handoff( + report: ReportData, output_dir: Path, *, stem_dir: Path | None = None +) -> None: + """Write agent handoff artifacts.""" + handoff_json = output_dir / "aigiscode-handoff.json" + handoff_md = output_dir / "aigiscode-handoff.md" + ga = report.graph_analysis + summary = { + "project_path": report.project_path, + "files_indexed": report.files_indexed, + "symbols_extracted": report.symbols_extracted, + "circular_dependencies": len(ga.strong_circular_dependencies) + or len(ga.circular_dependencies), + "god_classes": len(ga.god_classes), + "layer_violations": len(ga.layer_violations), + "dead_code_total": report.dead_code.total if report.dead_code else 0, + "hardwiring_total": report.hardwiring.total if report.hardwiring else 0, + } + json_text = json.dumps(summary, indent=2, ensure_ascii=False) + handoff_json.write_text(json_text, encoding="utf-8") + lines = [ + f"# AigisCode Handoff — {report.project_path}", + "", + "## Agent Handoff Brief", + "", + f"- Files: {report.files_indexed}", + f"- Cycles: {summary['circular_dependencies']}", + f"- God classes: {summary['god_classes']}", + f"- Layer violations: {summary['layer_violations']}", + f"- Dead code: {summary['dead_code_total']}", + f"- Hardwiring: {summary['hardwiring_total']}", + ] + md_text = "\n".join(lines) + "\n" + handoff_md.write_text(md_text, encoding="utf-8") + + # Write handoff to archive subdirectory + flat copies + if stem_dir is not None: + stem_dir.mkdir(parents=True, exist_ok=True) + (stem_dir / "aigiscode-handoff.json").write_text(json_text, encoding="utf-8") + (stem_dir / "aigiscode-handoff.md").write_text(md_text, encoding="utf-8") + + archive_dir = stem_dir.parent + stem_name = stem_dir.name + flat_handoff_json = archive_dir / f"{stem_name}-aigiscode-handoff.json" + flat_handoff_md = archive_dir / f"{stem_name}-aigiscode-handoff.md" + flat_handoff_json.write_text(json_text, encoding="utf-8") + flat_handoff_md.write_text(md_text, encoding="utf-8") + + +def _serialize_graph_analysis(ga) -> dict: + """Serialize GraphAnalysisResult for JSON output.""" + return { + "node_count": ga.node_count, + "edge_count": ga.edge_count, + "density": ga.density, "circular_dependencies": [ {"cycle": cycle} for cycle in ga.circular_dependencies ], @@ -411,7 +593,7 @@ def generate_json_report(report: ReportData) -> dict: } for g in ga.god_classes ], - "bottlenecks": [ + "bottleneck_files": [ {"file": path, "centrality": score} for path, score in ga.bottleneck_files ], "layer_violations": [ @@ -426,40 +608,9 @@ def generate_json_report(report: ReportData) -> dict: ], "orphan_files": ga.orphan_files, "runtime_entry_candidates": ga.runtime_entry_candidates, - "dead_code": _serialize_dead_code(report.dead_code) - if report.dead_code - else None, - "hardwiring": _serialize_hardwiring(report.hardwiring) - if report.hardwiring - else None, - "review": _serialize_review(report.review) if report.review else None, - "extensions": report.extensions, - "recommendations": _generate_recommendations(report), } -def write_reports(report: ReportData, output_dir: Path) -> tuple[Path, Path]: - """Write both Markdown and JSON reports to the output directory. - - Returns (markdown_path, json_path). - """ - output_dir.mkdir(parents=True, exist_ok=True) - - md_path = output_dir / "aigiscode-report.md" - json_path = output_dir / "aigiscode-report.json" - - md_content = generate_markdown_report(report) - md_path.write_text(md_content, encoding="utf-8") - - json_content = generate_json_report(report) - json_path.write_text( - json.dumps(json_content, indent=2, ensure_ascii=False), - encoding="utf-8", - ) - - return md_path, json_path - - def _serialize_dead_code(dc) -> dict: """Serialize DeadCodeResult for JSON output.""" all_findings = ( @@ -544,6 +695,68 @@ def _serialize_review(rv) -> dict: } +def _serialize_external_analysis(ea) -> dict: + """Serialize ExternalAnalysisResult for JSON output.""" + return { + "tool_runs": [ + { + "tool": tr.tool, + "command": tr.command, + "status": tr.status, + "findings_count": tr.findings_count, + "summary": tr.summary, + "version": tr.version, + } + for tr in ea.tool_runs + ], + "findings": [ + { + "tool": f.tool, + "rule_id": f.rule_id, + "file_path": f.file_path, + "line": f.line, + "message": f.message, + "severity": f.severity, + "domain": f.domain, + "category": f.category, + "fingerprint": f.fingerprint, + } + for f in ea.findings + ], + } + + +def _generate_agent_handoff(report: ReportData) -> dict: + """Generate agent handoff data for the JSON report.""" + ga = report.graph_analysis + next_steps: list[str] = [] + + cycle_count = len(ga.strong_circular_dependencies) or len(ga.circular_dependencies) + if cycle_count: + next_steps.append(f"Break {cycle_count} circular dependency cycle(s)") + if ga.god_classes: + next_steps.append(f"Refactor {len(ga.god_classes)} god class(es)") + if ga.layer_violations: + next_steps.append(f"Fix {len(ga.layer_violations)} layer violation(s)") + if report.dead_code and report.dead_code.total: + next_steps.append(f"Remove {report.dead_code.total} dead code finding(s)") + if report.hardwiring and report.hardwiring.total: + next_steps.append(f"Address {report.hardwiring.total} hardwiring issue(s)") + if report.external_analysis and report.external_analysis.findings: + next_steps.append( + f"Triage {len(report.external_analysis.findings)} external finding(s)" + ) + + if not next_steps: + next_steps.append("No major issues detected — maintain current standards") + + return { + "project_path": report.project_path, + "files_indexed": report.files_indexed, + "next_steps": next_steps, + } + + def _auto_summary(report: ReportData) -> str: """Generate a basic auto-summary when Claude synthesis is not available.""" ga = report.graph_analysis @@ -771,6 +984,24 @@ def _generate_recommendations(report: ReportData) -> list[dict]: } ) + security_summary = _generate_security_summary(report) + if security_summary["total_findings"]: + recs.append( + { + "title": "Prioritize Security Hardwiring Cleanup", + "description": ( + f"Found {security_summary['total_findings']} security-sensitive hardwiring findings: " + f"{security_summary['hardcoded_network']} hardcoded network endpoints and " + f"{security_summary['env_outside_config']} environment reads outside config. " + "Move secrets, tokens, callback URLs, and environment access behind explicit " + "configuration boundaries." + ), + "priority": "high" + if security_summary["high_severity"] + else "medium", + } + ) + # If no issues, provide a positive recommendation if not recs: recs.append( @@ -785,3 +1016,71 @@ def _generate_recommendations(report: ReportData) -> list[dict]: ) return recs[:7] + + +def _generate_security_summary(report: ReportData) -> dict: + hardwired_findings = [] + hardcoded_network = 0 + env_outside_config = 0 + + if report.hardwiring: + hardwired_findings = [ + *report.hardwiring.hardcoded_network, + *report.hardwiring.env_outside_config, + ] + hardcoded_network = len(report.hardwiring.hardcoded_network) + env_outside_config = len(report.hardwiring.env_outside_config) + + severity_rank = {"high": 0, "medium": 1, "low": 2} + sorted_findings = sorted( + hardwired_findings, + key=lambda finding: ( + severity_rank.get(finding.severity, 3), + finding.file_path, + finding.line, + ), + ) + + ai_confirmed = 0 + if report.review: + ai_confirmed = sum( + 1 + for verdict in report.review.verdicts + if verdict.verdict == "true_positive" + and verdict.category in {"hardcoded_ip_url", "env_outside_config"} + ) + + result: dict = { + "total_findings": len(hardwired_findings), + "hardcoded_network": hardcoded_network, + "env_outside_config": env_outside_config, + "high_severity": sum( + 1 for finding in hardwired_findings if finding.severity == "high" + ), + "ai_confirmed": ai_confirmed, + "top_findings": [ + { + "file": finding.file_path, + "line": finding.line, + "category": finding.category, + "value": finding.value, + "severity": finding.severity, + "confidence": finding.confidence, + } + for finding in sorted_findings[:10] + ], + } + + # Add external analysis security counts + if report.external_analysis and report.external_analysis.findings: + external_findings = report.external_analysis.findings + result["external_findings"] = len(external_findings) + # Count by category + category_counts: dict[str, int] = {} + for f in external_findings: + cat = f.category + category_counts[cat] = category_counts.get(cat, 0) + 1 + for cat, count in category_counts.items(): + result[cat] = count + + return result diff --git a/src/aigiscode/review/ai_reviewer.py b/src/aigiscode/review/ai_reviewer.py index 645dbf5..dd72131 100644 --- a/src/aigiscode/review/ai_reviewer.py +++ b/src/aigiscode/review/ai_reviewer.py @@ -368,6 +368,7 @@ async def review_findings( project_type: str = "mixed-language project", store: Any = None, review_model: str = "gpt-5.3-codex", + primary_backend: str = "codex", allow_claude_fallback: bool = True, ) -> tuple[ReviewResult, list[Rule]]: """Review all findings using AI, returning verdicts and generated rules. diff --git a/src/aigiscode/review/security_reviewer.py b/src/aigiscode/review/security_reviewer.py new file mode 100644 index 0000000..69f0cb8 --- /dev/null +++ b/src/aigiscode/review/security_reviewer.py @@ -0,0 +1,171 @@ +"""AI-powered security finding reviewer. + +Classifies external security findings as actionable, accepted_noise, +or needs_context using the same AI backends as the main finding reviewer. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +from aigiscode.ai.backends import generate_text +from aigiscode.models import ( + ExternalAnalysisResult, + FindingVerdict, + ReviewResult, +) +from aigiscode.rules.engine import Rule + +logger = logging.getLogger(__name__) + +MAX_SAMPLE = 20 + +SECURITY_REVIEW_SYSTEM_PROMPT = """\ +You are an expert security reviewer triaging findings from automated security scanners. + +Your task: classify each finding as one of: +- **true_positive**: A real security issue that should be fixed. +- **false_positive**: A false alarm — the code is safe due to context the scanner cannot see. +- **needs_context**: Cannot determine without more information. + +Respond with valid JSON only: +{ + "verdicts": [ + { + "index": 0, + "verdict": "true_positive|false_positive|needs_context", + "reason": "brief explanation" + } + ] +} +""" + + +async def review_external_security_findings( + external_analysis: ExternalAnalysisResult, + *, + project_path: str | Path, + project_type: str = "mixed-language project", + review_model: str = "gpt-5.3-codex", + primary_backend: str = "codex", + allow_claude_fallback: bool = True, +) -> tuple[ReviewResult, list[Rule]]: + """Review external security findings using AI.""" + project_path = Path(project_path) + security_findings = [ + f for f in external_analysis.findings if f.domain == "security" + ] + + if not security_findings: + return ReviewResult(), [] + + sampled = security_findings[:MAX_SAMPLE] + + parts = [ + f"## Project Type: {project_type}", + f"## Security Findings ({len(security_findings)} total, {len(sampled)} sampled)", + "", + ] + for i, finding in enumerate(sampled): + parts.append(f"### Finding #{i}") + parts.append(f"**Tool**: {finding.tool}") + parts.append(f"**Rule**: {finding.rule_id}") + parts.append(f"**File**: `{finding.file_path}:{finding.line}`") + parts.append(f"**Severity**: {finding.severity}") + parts.append(f"**Message**: {finding.message}") + code = _read_code_context(project_path, finding.file_path, finding.line) + if code: + parts.append(f"**Source**:\n```\n{code}\n```") + parts.append("") + + prompt = "\n".join(parts) + + response, backend = await generate_text( + SECURITY_REVIEW_SYSTEM_PROMPT, + prompt, + model=review_model, + allow_codex_cli_fallback=True, + allow_claude_fallback=allow_claude_fallback, + reasoning_effort="medium", + ) + + all_verdicts: list[FindingVerdict] = [] + all_rules: list[Rule] = [] + + if not response: + for f in sampled: + all_verdicts.append( + FindingVerdict( + file_path=f.file_path, + line=f.line, + category=f.category or "security", + verdict="needs_context", + reason="AI review unavailable", + ) + ) + else: + try: + text = response.strip() + if text.startswith("```"): + text = text.split("\n", 1)[1] if "\n" in text else text[3:] + if text.endswith("```"): + text = text[:-3] + text = text.strip() + data = json.loads(text) + for v in data.get("verdicts", []): + idx = v.get("index", -1) + if idx < 0 or idx >= len(sampled): + continue + finding = sampled[idx] + all_verdicts.append( + FindingVerdict( + file_path=finding.file_path, + line=finding.line, + category=finding.category or "security", + verdict=v.get("verdict", "needs_context"), + reason=v.get("reason", ""), + ) + ) + except json.JSONDecodeError: + logger.warning("Failed to parse security review AI response") + + tp = sum(1 for v in all_verdicts if v.verdict == "true_positive") + fp = sum(1 for v in all_verdicts if v.verdict == "false_positive") + nc = sum(1 for v in all_verdicts if v.verdict == "needs_context") + + result = ReviewResult( + total_reviewed=len(sampled), + true_positives=tp, + false_positives=fp, + needs_context=nc, + rules_generated=len(all_rules), + verdicts=all_verdicts, + ) + # cli.py accesses result.actionable and result.accepted_noise + # Use object.__setattr__ to bypass Pydantic's strict field validation + object.__setattr__(result, "actionable", tp) + object.__setattr__(result, "accepted_noise", fp) + + return result, all_rules + + +def _read_code_context( + project_path: Path, file_path: str, line: int, context: int = 5 +) -> str: + full_path = project_path / file_path + if not full_path.exists(): + return "" + try: + lines = full_path.read_text(encoding="utf-8", errors="replace").splitlines() + start = max(0, line - context - 1) + end = min(len(lines), line + context) + numbered = [] + for i, ln in enumerate(lines[start:end], start=start + 1): + marker = ">>>" if i == line else " " + numbered.append(f"{marker} {i:4d} | {ln}") + return "\n".join(numbered) + except Exception: + return "" diff --git a/src/aigiscode/rules/engine.py b/src/aigiscode/rules/engine.py index 7860b05..2e18342 100644 --- a/src/aigiscode/rules/engine.py +++ b/src/aigiscode/rules/engine.py @@ -403,3 +403,104 @@ def update_rule_stats(rules: list[Rule], hit_rules: set[str], run_id: str) -> No logger.info( "Rule %s marked stale (miss_streak=%d)", rule.id, rule.miss_streak ) + + +# --------------------------------------------------------------------------- +# External findings filtering +# --------------------------------------------------------------------------- + + +def filter_external_findings( + external_analysis: Any, + rules: list[Rule], + ctx: Any = None, +) -> tuple[Any, int]: + """Filter external security findings against saved rules. + + Accepts either an ``ExternalAnalysisResult`` or a plain list of findings + (for backward compatibility). + + When given an ``ExternalAnalysisResult``, returns a new + ``(filtered_ExternalAnalysisResult, excluded_count)`` tuple with + tool run summaries updated to reflect post-filtering counts. + + When given a plain list, returns ``(filtered_list, excluded_count)``. + """ + from aigiscode.models import ExternalAnalysisResult, ExternalToolRun + + # Handle plain list (backward compat) + if isinstance(external_analysis, list): + if not rules or not external_analysis: + return external_analysis, 0 + if ctx is None: + ctx = StructuralContext() + kept = [] + excluded = 0 + for finding in external_analysis: + matched = False + for rule in rules: + if _plain_finding_matches(finding, rule, ctx): + matched = True + break + if matched: + excluded += 1 + else: + kept.append(finding) + return kept, excluded + + # Handle ExternalAnalysisResult + if not rules or not external_analysis.findings: + return external_analysis, 0 + + if ctx is None: + ctx = StructuralContext() + + hit_rules: set[str] = set() + kept: list = [] + excluded = 0 + + for finding in external_analysis.findings: + matched = False + for rule in rules: + if matches_rule(finding, rule, ctx): + hit_rules.add(rule.id) + matched = True + break + if matched: + excluded += 1 + else: + kept.append(finding) + + # Rebuild tool_runs with updated summaries + updated_tool_runs: list[ExternalToolRun] = [] + for tr in external_analysis.tool_runs: + tool_findings = [f for f in kept if f.tool == tr.tool] + new_summary = dict(tr.summary) + new_summary["finding_count"] = len(tool_findings) + new_summary["rules_filtered_count"] = excluded + updated_tool_runs.append( + ExternalToolRun( + tool=tr.tool, + command=tr.command, + status=tr.status, + findings_count=len(tool_findings), + summary=new_summary, + version=tr.version, + ) + ) + + return ExternalAnalysisResult( + tool_runs=updated_tool_runs, + findings=kept, + ), excluded + + +def _plain_finding_matches(finding: Any, rule: Rule, ctx: StructuralContext) -> bool: + """Try to match a plain dict or object against a rule. + + Plain dicts don't have a ``category`` attribute so this always returns + False — preserving the old pass-through behaviour. + """ + if isinstance(finding, dict): + return False + return matches_rule(finding, rule, ctx) diff --git a/src/aigiscode/security/external.py b/src/aigiscode/security/external.py new file mode 100644 index 0000000..f48fad5 --- /dev/null +++ b/src/aigiscode/security/external.py @@ -0,0 +1,1587 @@ +"""Run and normalize external security analyzers.""" + +from __future__ import annotations + +import hashlib +import json +import shutil +import subprocess +from pathlib import Path + +from aigiscode.models import ExternalAnalysisResult, ExternalFinding, ExternalToolRun + +COMPOSER_AUDIT_TOOL = "composer-audit" +CARGO_DENY_TOOL = "cargo-deny" + +CARGO_DENY_LICENSE_CODES = { + "accepted", + "rejected", + "unlicensed", + "skipped-private-workspace-crate", + "license-not-encountered", + "license-exception-not-encountered", + "missing-clarification-file", + "parse-error", + "empty-license-field", + "no-license-field", + "gather-failure", +} + +CARGO_DENY_SOURCE_CODES = { + "git-source-underspecified", + "allowed-source", + "allowed-by-organization", + "source-not-allowed", + "unmatched-source", + "unmatched-organization", +} + +SUPPORTED_SECURITY_TOOLS = ( + "ruff", + "gitleaks", + "pip-audit", + "osv-scanner", + "phpstan", + COMPOSER_AUDIT_TOOL, + "npm-audit", + CARGO_DENY_TOOL, + "cargo-clippy", +) + +TOOL_TIMEOUT_SECONDS = { + "ruff": 60, + "gitleaks": 120, + "pip-audit": 120, + "osv-scanner": 120, + "phpstan": 180, + COMPOSER_AUDIT_TOOL: 120, + "npm-audit": 120, + CARGO_DENY_TOOL: 300, + "cargo-clippy": 300, +} + + +def collect_external_analysis( + *, + project_path: Path, + output_dir: Path, + run_id: str, + selected_tools: list[str] | None = None, + run_ruff_security: bool = False, +) -> ExternalAnalysisResult: + """Run configured external security analyzers and archive raw artifacts.""" + result = ExternalAnalysisResult() + raw_dir = output_dir / "reports" / run_id / "raw" + raw_dir.mkdir(parents=True, exist_ok=True) + + selected_tools = _normalize_selected_tools( + selected_tools=selected_tools, + run_ruff_security=run_ruff_security, + ) + for tool_name in selected_tools: + runner = _TOOL_RUNNERS.get(tool_name) + if runner is None: + result.tool_runs.append( + ExternalToolRun( + tool=tool_name, + command=[], + status="failed", + summary={"error": f"Unsupported security tool: {tool_name}"}, + ) + ) + continue + tool_run, findings = runner(project_path, raw_dir) + result.tool_runs.append(tool_run) + result.findings.extend(findings) + return result + + +def _normalize_selected_tools( + *, + selected_tools: list[str] | None, + run_ruff_security: bool, +) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + for tool in selected_tools or []: + name = tool.strip().lower() + if not name: + continue + if name == "all": + for candidate in SUPPORTED_SECURITY_TOOLS: + if candidate not in seen: + normalized.append(candidate) + seen.add(candidate) + continue + if name in seen: + continue + normalized.append(name) + seen.add(name) + if run_ruff_security and "ruff" not in seen: + normalized.append("ruff") + return normalized + + +def _run_command( + command: list[str], + *, + cwd: Path | None = None, + tool: str, +) -> tuple[subprocess.CompletedProcess[str] | None, str | None]: + try: + return ( + subprocess.run( + command, + cwd=cwd, + capture_output=True, + text=True, + check=False, + timeout=TOOL_TIMEOUT_SECONDS.get(tool, 120), + ), + None, + ) + except subprocess.TimeoutExpired: + return None, f"Timed out after {TOOL_TIMEOUT_SECONDS.get(tool, 120)}s" + + +def _sanitize_stderr(tool: str, stderr: str) -> tuple[str, int]: + lines = [line for line in stderr.splitlines() if line.strip()] + if tool == COMPOSER_AUDIT_TOOL: + filtered = [line for line in lines if not line.startswith("Deprecation Notice:")] + return "\n".join(filtered), len(lines) - len(filtered) + return stderr.strip(), 0 + + +def _stderr_summary(tool: str, stderr: str) -> dict[str, object]: + sanitized, suppressed = _sanitize_stderr(tool, stderr) + summary: dict[str, object] = {} + if sanitized: + summary["stderr"] = sanitized + if suppressed: + summary["suppressed_stderr_lines"] = suppressed + return summary + + +def _status_with_findings( + *, + tool: str, + returncode: int, + findings: list[ExternalFinding], + passed_exit_codes: set[int] | None = None, +) -> tuple[str, dict[str, object]]: + accepted = passed_exit_codes or {0} + if findings: + return "findings", {} + if returncode in accepted: + return "passed", {} + return ( + "failed", + { + "error": ( + f"{tool} exited with code {returncode} and produced no normalized findings" + ) + }, + ) + + +def _refine_findings( + findings: list[ExternalFinding], +) -> tuple[list[ExternalFinding], int]: + refined: list[ExternalFinding] = [] + seen: set[str] = set() + filtered_count = 0 + for finding in findings: + candidate = _refine_finding(finding) + if candidate is None: + filtered_count += 1 + continue + if candidate.fingerprint in seen: + filtered_count += 1 + continue + seen.add(candidate.fingerprint) + refined.append(candidate) + return refined, filtered_count + + +def _refine_finding(finding: ExternalFinding) -> ExternalFinding | None: + if ( + finding.tool == "ruff" + and finding.rule_id == "S101" + and _is_test_like_path(finding.file_path) + ): + return None + + if finding.tool == "ruff" and finding.rule_id == "S105": + message = finding.message.lower() + if any(token in message for token in ('_url"', '_uri"', '_endpoint"')): + candidate = finding.model_copy(deep=True) + candidate.severity = "low" + candidate.extras["normalized_reason"] = "url_like_token_name" + return candidate + + return finding + + +def _is_test_like_path(file_path: str) -> bool: + normalized = file_path.lower() + return any( + token in normalized + for token in ( + "/test", + "/tests/", + "/spec", + "/specs/", + "/fixture", + "/fixtures/", + "test_", + "_test.", + ".spec.", + ) + ) + + +def _run_ruff_security( + project_path: Path, + raw_dir: Path, +) -> tuple[ExternalToolRun, list[ExternalFinding]]: + artifact_path = raw_dir / "ruff-security.json" + ruff_path = shutil.which("ruff") + command = [ + ruff_path or "ruff", + "check", + "--select", + "S", + "--output-format", + "json", + "--exit-zero", + str(project_path), + ] + if ruff_path is None: + return ( + ExternalToolRun( + tool="ruff", + command=command, + status="unavailable", + artifact_path=str(artifact_path), + summary={"message": "ruff executable not found on PATH"}, + ), + [], + ) + + proc, timeout_error = _run_command(command, tool="ruff") + if timeout_error or proc is None: + return ( + ExternalToolRun( + tool="ruff", + command=command, + status="failed", + artifact_path=str(artifact_path), + summary={"error": timeout_error}, + ), + [], + ) + stdout = proc.stdout.strip() + artifact_path.write_text(stdout or "[]", encoding="utf-8") + + if proc.returncode not in (0, 1): + return ( + ExternalToolRun( + tool="ruff", + command=command, + status="failed", + exit_code=proc.returncode, + artifact_path=str(artifact_path), + summary=_stderr_summary("ruff", proc.stderr), + ), + [], + ) + + payload, error = _load_json_artifact(artifact_path, default=[]) + if error: + return ( + ExternalToolRun( + tool="ruff", + command=command, + status="failed", + exit_code=proc.returncode, + artifact_path=str(artifact_path), + summary={**_stderr_summary("ruff", proc.stderr), "error": error}, + ), + [], + ) + + findings = [ + ExternalFinding( + tool="ruff", + domain="security", + category="sast", + rule_id=str(item.get("code", "")), + severity=_ruff_severity(str(item.get("code", ""))), + confidence="medium", + file_path=_relative_path(project_path, str(item.get("filename", ""))), + line=_location_row(item), + message=str(item.get("message", "")).strip(), + fingerprint=_fingerprint(item), + extras={"documentation_url": item.get("url")}, + ) + for item in payload + if isinstance(item, dict) + ] + raw_count = len(findings) + findings, filtered_count = _refine_findings(findings) + return ( + ExternalToolRun( + tool="ruff", + command=command, + status="findings" if findings else "passed", + exit_code=proc.returncode, + artifact_path=str(artifact_path), + summary={ + "finding_count": len(findings), + "raw_finding_count": raw_count, + "filtered_count": filtered_count, + }, + ), + findings, + ) + + +def _run_gitleaks( + project_path: Path, + raw_dir: Path, +) -> tuple[ExternalToolRun, list[ExternalFinding]]: + artifact_path = raw_dir / "gitleaks.json" + gitleaks_path = shutil.which("gitleaks") + command = [ + gitleaks_path or "gitleaks", + "detect", + "--source", + str(project_path), + "--report-format", + "json", + "--report-path", + str(artifact_path), + "--no-banner", + "--exit-code", + "0", + "--redact", + ] + if gitleaks_path is None: + return ( + ExternalToolRun( + tool="gitleaks", + command=command, + status="unavailable", + artifact_path=str(artifact_path), + summary={"message": "gitleaks executable not found on PATH"}, + ), + [], + ) + + proc, timeout_error = _run_command(command, tool="gitleaks") + if timeout_error or proc is None: + return ( + ExternalToolRun( + tool="gitleaks", + command=command, + status="failed", + artifact_path=str(artifact_path), + summary={"error": timeout_error}, + ), + [], + ) + payload, error = _load_json_artifact(artifact_path, default=[]) + if error: + return ( + ExternalToolRun( + tool="gitleaks", + command=command, + status="failed", + exit_code=proc.returncode, + artifact_path=str(artifact_path), + summary={**_stderr_summary("gitleaks", proc.stderr), "error": error}, + ), + [], + ) + + findings = _parse_gitleaks_payload(project_path, payload) + raw_count = len(findings) + findings, filtered_count = _refine_findings(findings) + status, status_summary = _status_with_findings( + tool="gitleaks", + returncode=proc.returncode, + findings=findings, + ) + return ( + ExternalToolRun( + tool="gitleaks", + command=command, + status=status, + exit_code=proc.returncode, + artifact_path=str(artifact_path), + summary={ + "finding_count": len(findings), + "raw_finding_count": raw_count, + "filtered_count": filtered_count, + **_stderr_summary("gitleaks", proc.stderr), + **status_summary, + }, + ), + findings, + ) + + +def _run_pip_audit( + project_path: Path, + raw_dir: Path, +) -> tuple[ExternalToolRun, list[ExternalFinding]]: + artifact_path = raw_dir / "pip-audit.json" + pip_audit_path = shutil.which("pip-audit") + command = [ + pip_audit_path or "pip-audit", + "--format", + "json", + "--output", + str(artifact_path), + str(project_path), + ] + if pip_audit_path is None: + return ( + ExternalToolRun( + tool="pip-audit", + command=command, + status="unavailable", + artifact_path=str(artifact_path), + summary={"message": "pip-audit executable not found on PATH"}, + ), + [], + ) + + proc, timeout_error = _run_command(command, tool="pip-audit") + if timeout_error or proc is None: + return ( + ExternalToolRun( + tool="pip-audit", + command=command, + status="failed", + artifact_path=str(artifact_path), + summary={"error": timeout_error}, + ), + [], + ) + payload, error = _load_json_artifact(artifact_path, default={"dependencies": []}) + if error: + return ( + ExternalToolRun( + tool="pip-audit", + command=command, + status="failed", + exit_code=proc.returncode, + artifact_path=str(artifact_path), + summary={**_stderr_summary("pip-audit", proc.stderr), "error": error}, + ), + [], + ) + + findings = _parse_pip_audit_payload(payload) + raw_count = len(findings) + findings, filtered_count = _refine_findings(findings) + status, status_summary = _status_with_findings( + tool="pip-audit", + returncode=proc.returncode, + findings=findings, + ) + return ( + ExternalToolRun( + tool="pip-audit", + command=command, + status=status, + exit_code=proc.returncode, + artifact_path=str(artifact_path), + summary={ + "finding_count": len(findings), + "raw_finding_count": raw_count, + "filtered_count": filtered_count, + **_stderr_summary("pip-audit", proc.stderr), + **status_summary, + }, + ), + findings, + ) + + +def _run_osv_scanner( + project_path: Path, + raw_dir: Path, +) -> tuple[ExternalToolRun, list[ExternalFinding]]: + artifact_path = raw_dir / "osv-scanner.json" + osv_path = shutil.which("osv-scanner") + command = [ + osv_path or "osv-scanner", + "scan", + "source", + "--recursive", + str(project_path), + "--format", + "json", + "--output", + str(artifact_path), + ] + if osv_path is None: + return ( + ExternalToolRun( + tool="osv-scanner", + command=command, + status="unavailable", + artifact_path=str(artifact_path), + summary={"message": "osv-scanner executable not found on PATH"}, + ), + [], + ) + + proc, timeout_error = _run_command(command, tool="osv-scanner") + if timeout_error or proc is None: + return ( + ExternalToolRun( + tool="osv-scanner", + command=command, + status="failed", + artifact_path=str(artifact_path), + summary={"error": timeout_error}, + ), + [], + ) + payload, error = _load_json_artifact(artifact_path, default={"results": []}) + if error: + return ( + ExternalToolRun( + tool="osv-scanner", + command=command, + status="failed", + exit_code=proc.returncode, + artifact_path=str(artifact_path), + summary={**_stderr_summary("osv-scanner", proc.stderr), "error": error}, + ), + [], + ) + + findings = _parse_osv_scanner_payload(payload) + raw_count = len(findings) + findings, filtered_count = _refine_findings(findings) + status, status_summary = _status_with_findings( + tool="osv-scanner", + returncode=proc.returncode, + findings=findings, + ) + return ( + ExternalToolRun( + tool="osv-scanner", + command=command, + status=status, + exit_code=proc.returncode, + artifact_path=str(artifact_path), + summary={ + "finding_count": len(findings), + "raw_finding_count": raw_count, + "filtered_count": filtered_count, + **_stderr_summary("osv-scanner", proc.stderr), + **status_summary, + }, + ), + findings, + ) + + +def _run_phpstan( + project_path: Path, + raw_dir: Path, +) -> tuple[ExternalToolRun, list[ExternalFinding]]: + artifact_path = raw_dir / "phpstan.json" + phpstan_path = project_path / "vendor" / "bin" / "phpstan" + if not phpstan_path.exists(): + resolved = shutil.which("phpstan") + phpstan_path = Path(resolved) if resolved else phpstan_path + command = [ + str(phpstan_path), + "analyse", + "--error-format=json", + "--no-progress", + ] + if not phpstan_path.exists(): + return ( + ExternalToolRun( + tool="phpstan", + command=command, + status="unavailable", + artifact_path=str(artifact_path), + summary={"message": "phpstan executable not found"}, + ), + [], + ) + + proc, timeout_error = _run_command(command, cwd=project_path, tool="phpstan") + if timeout_error or proc is None: + return ( + ExternalToolRun( + tool="phpstan", + command=command, + status="failed", + artifact_path=str(artifact_path), + summary={"error": timeout_error}, + ), + [], + ) + stdout = proc.stdout.strip() + artifact_path.write_text(stdout or "{}", encoding="utf-8") + payload, error = _load_json_artifact(artifact_path, default={}) + if error: + return ( + ExternalToolRun( + tool="phpstan", + command=command, + status="failed", + exit_code=proc.returncode, + artifact_path=str(artifact_path), + summary={**_stderr_summary("phpstan", proc.stderr), "error": error}, + ), + [], + ) + + findings = _parse_phpstan_payload(payload) + raw_count = len(findings) + findings, filtered_count = _refine_findings(findings) + status, status_summary = _status_with_findings( + tool="phpstan", + returncode=proc.returncode, + findings=findings, + ) + return ( + ExternalToolRun( + tool="phpstan", + command=command, + status=status, + exit_code=proc.returncode, + artifact_path=str(artifact_path), + summary={ + "finding_count": len(findings), + "raw_finding_count": raw_count, + "filtered_count": filtered_count, + **_stderr_summary("phpstan", proc.stderr), + **status_summary, + }, + ), + findings, + ) + + +def _run_composer_audit( + project_path: Path, + raw_dir: Path, +) -> tuple[ExternalToolRun, list[ExternalFinding]]: + artifact_path = raw_dir / "composer-audit.json" + if not (project_path / "composer.json").exists(): + return ( + ExternalToolRun( + tool="composer-audit", + command=[], + status="unavailable", + artifact_path=str(artifact_path), + summary={"message": "composer.json not found"}, + ), + [], + ) + + composer_path = shutil.which("composer") + command = [ + composer_path or "composer", + "audit", + "--format=json", + "--no-interaction", + "--no-ansi", + ] + if composer_path is None: + return ( + ExternalToolRun( + tool="composer-audit", + command=command, + status="unavailable", + artifact_path=str(artifact_path), + summary={"message": "composer executable not found"}, + ), + [], + ) + + proc, timeout_error = _run_command( + command, + cwd=project_path, + tool="composer-audit", + ) + if timeout_error or proc is None: + return ( + ExternalToolRun( + tool="composer-audit", + command=command, + status="failed", + artifact_path=str(artifact_path), + summary={"error": timeout_error}, + ), + [], + ) + stdout = proc.stdout.strip() + artifact_path.write_text(stdout or "{}", encoding="utf-8") + payload, error = _load_json_artifact(artifact_path, default={}) + if error: + return ( + ExternalToolRun( + tool="composer-audit", + command=command, + status="failed", + exit_code=proc.returncode, + artifact_path=str(artifact_path), + summary={ + **_stderr_summary("composer-audit", proc.stderr), + "error": error, + }, + ), + [], + ) + + findings = _parse_composer_audit_payload(payload) + raw_count = len(findings) + findings, filtered_count = _refine_findings(findings) + status, status_summary = _status_with_findings( + tool="composer-audit", + returncode=proc.returncode, + findings=findings, + ) + return ( + ExternalToolRun( + tool="composer-audit", + command=command, + status=status, + exit_code=proc.returncode, + artifact_path=str(artifact_path), + summary={ + "finding_count": len(findings), + "raw_finding_count": raw_count, + "filtered_count": filtered_count, + **_stderr_summary("composer-audit", proc.stderr), + **status_summary, + }, + ), + findings, + ) + + +def _run_npm_audit( + project_path: Path, + raw_dir: Path, +) -> tuple[ExternalToolRun, list[ExternalFinding]]: + artifact_path = raw_dir / "npm-audit.json" + if not (project_path / "package.json").exists(): + return ( + ExternalToolRun( + tool="npm-audit", + command=[], + status="unavailable", + artifact_path=str(artifact_path), + summary={"message": "package.json not found"}, + ), + [], + ) + + npm_path = shutil.which("npm") + command = [npm_path or "npm", "audit", "--json", "--omit=dev"] + if npm_path is None: + return ( + ExternalToolRun( + tool="npm-audit", + command=command, + status="unavailable", + artifact_path=str(artifact_path), + summary={"message": "npm executable not found"}, + ), + [], + ) + + proc, timeout_error = _run_command(command, cwd=project_path, tool="npm-audit") + if timeout_error or proc is None: + return ( + ExternalToolRun( + tool="npm-audit", + command=command, + status="failed", + artifact_path=str(artifact_path), + summary={"error": timeout_error}, + ), + [], + ) + stdout = proc.stdout.strip() + artifact_path.write_text(stdout or "{}", encoding="utf-8") + payload, error = _load_json_artifact(artifact_path, default={}) + if error: + return ( + ExternalToolRun( + tool="npm-audit", + command=command, + status="failed", + exit_code=proc.returncode, + artifact_path=str(artifact_path), + summary={**_stderr_summary("npm-audit", proc.stderr), "error": error}, + ), + [], + ) + + findings = _parse_npm_audit_payload(payload) + raw_count = len(findings) + findings, filtered_count = _refine_findings(findings) + status, status_summary = _status_with_findings( + tool="npm-audit", + returncode=proc.returncode, + findings=findings, + ) + return ( + ExternalToolRun( + tool="npm-audit", + command=command, + status=status, + exit_code=proc.returncode, + artifact_path=str(artifact_path), + summary={ + "finding_count": len(findings), + "raw_finding_count": raw_count, + "filtered_count": filtered_count, + **_stderr_summary("npm-audit", proc.stderr), + **status_summary, + }, + ), + findings, + ) + + +def _run_cargo_clippy( + project_path: Path, + raw_dir: Path, +) -> tuple[ExternalToolRun, list[ExternalFinding]]: + artifact_path = raw_dir / "cargo-clippy.jsonl" + if not (project_path / "Cargo.toml").exists(): + return ( + ExternalToolRun( + tool="cargo-clippy", + command=[], + status="unavailable", + artifact_path=str(artifact_path), + summary={"message": "Cargo.toml not found"}, + ), + [], + ) + + cargo_path = shutil.which("cargo") + command = [ + cargo_path or "cargo", + "clippy", + "--message-format=json", + "--all-targets", + "--workspace", + "--", + "-W", + "clippy::all", + ] + if cargo_path is None: + return ( + ExternalToolRun( + tool="cargo-clippy", + command=command, + status="unavailable", + artifact_path=str(artifact_path), + summary={"message": "cargo executable not found"}, + ), + [], + ) + + proc, timeout_error = _run_command( + command, + cwd=project_path, + tool="cargo-clippy", + ) + if timeout_error or proc is None: + return ( + ExternalToolRun( + tool="cargo-clippy", + command=command, + status="failed", + artifact_path=str(artifact_path), + summary={"error": timeout_error}, + ), + [], + ) + + stdout = proc.stdout.strip() + artifact_path.write_text(stdout, encoding="utf-8") + findings = _parse_cargo_clippy_output(stdout, project_path) + raw_count = len(findings) + findings, filtered_count = _refine_findings(findings) + status, status_summary = _status_with_findings( + tool="cargo-clippy", + returncode=proc.returncode, + findings=findings, + ) + return ( + ExternalToolRun( + tool="cargo-clippy", + command=command, + status=status, + exit_code=proc.returncode, + artifact_path=str(artifact_path), + summary={ + "finding_count": len(findings), + "raw_finding_count": raw_count, + "filtered_count": filtered_count, + **_stderr_summary("cargo-clippy", proc.stderr), + **status_summary, + }, + ), + findings, + ) + + +def _run_cargo_deny( + project_path: Path, + raw_dir: Path, +) -> tuple[ExternalToolRun, list[ExternalFinding]]: + artifact_path = raw_dir / "cargo-deny.jsonl" + if not (project_path / "Cargo.toml").exists(): + return ( + ExternalToolRun( + tool=CARGO_DENY_TOOL, + command=[], + status="unavailable", + artifact_path=str(artifact_path), + summary={"message": "Cargo.toml not found"}, + ), + [], + ) + + cargo_deny_path = shutil.which(CARGO_DENY_TOOL) + command = [ + cargo_deny_path or CARGO_DENY_TOOL, + "check", + "advisories", + "bans", + "licenses", + "sources", + "--format", + "json", + "--hide-inclusion-graph", + ] + if cargo_deny_path is None: + return ( + ExternalToolRun( + tool=CARGO_DENY_TOOL, + command=command, + status="unavailable", + artifact_path=str(artifact_path), + summary={"message": "cargo-deny executable not found on PATH"}, + ), + [], + ) + + proc, timeout_error = _run_command( + command, + cwd=project_path, + tool=CARGO_DENY_TOOL, + ) + if timeout_error or proc is None: + return ( + ExternalToolRun( + tool=CARGO_DENY_TOOL, + command=command, + status="failed", + artifact_path=str(artifact_path), + summary={"error": timeout_error}, + ), + [], + ) + + stdout = proc.stdout.strip() + artifact_path.write_text(stdout, encoding="utf-8") + findings = _parse_cargo_deny_output(stdout) + raw_count = len(findings) + findings, filtered_count = _refine_findings(findings) + status, status_summary = _status_with_findings( + tool=CARGO_DENY_TOOL, + returncode=proc.returncode, + findings=findings, + ) + return ( + ExternalToolRun( + tool=CARGO_DENY_TOOL, + command=command, + status=status, + exit_code=proc.returncode, + artifact_path=str(artifact_path), + summary={ + "finding_count": len(findings), + "raw_finding_count": raw_count, + "filtered_count": filtered_count, + **_stderr_summary(CARGO_DENY_TOOL, proc.stderr), + **status_summary, + }, + ), + findings, + ) + + +def _load_json_artifact( + artifact_path: Path, + *, + default, +) -> tuple[object, str | None]: + if not artifact_path.exists(): + return default, f"Artifact was not created: {artifact_path.name}" + try: + return json.loads(artifact_path.read_text(encoding="utf-8") or "null"), None + except json.JSONDecodeError as exc: + return default, f"Invalid JSON artifact: {exc}" + + +def _parse_gitleaks_payload( + project_path: Path, + payload: object, +) -> list[ExternalFinding]: + if not isinstance(payload, list): + return [] + findings: list[ExternalFinding] = [] + for item in payload: + if not isinstance(item, dict): + continue + file_path = _relative_path(project_path, str(item.get("File", ""))) + line = int(item.get("StartLine") or item.get("Line") or 1) + rule_id = str(item.get("RuleID", "gitleaks")) + description = str(item.get("Description", "")).strip() + findings.append( + ExternalFinding( + tool="gitleaks", + domain="security", + category="secrets", + rule_id=rule_id, + severity="high", + confidence="medium", + file_path=file_path, + line=line, + message=description or "Potential secret detected by Gitleaks", + fingerprint=str( + item.get("Fingerprint") or _stable_fingerprint("gitleaks", item) + ), + extras={ + "commit": item.get("Commit"), + "author": item.get("Author"), + "entropy": item.get("Entropy"), + "match": item.get("Match"), + }, + ) + ) + return findings + + +def _parse_pip_audit_payload(payload: object) -> list[ExternalFinding]: + dependencies: list[dict] = [] + if isinstance(payload, list): + dependencies = [item for item in payload if isinstance(item, dict)] + elif isinstance(payload, dict): + raw_dependencies = payload.get("dependencies") + if isinstance(raw_dependencies, list): + dependencies = [item for item in raw_dependencies if isinstance(item, dict)] + + findings: list[ExternalFinding] = [] + for dependency in dependencies: + vulns = dependency.get("vulns") + if not isinstance(vulns, list): + continue + package_name = str(dependency.get("name", "")) + package_version = str(dependency.get("version", "")) + for vuln in vulns: + if not isinstance(vuln, dict): + continue + vuln_id = str(vuln.get("id", "")) or "vulnerability" + findings.append( + ExternalFinding( + tool="pip-audit", + domain="security", + category="sca", + rule_id=vuln_id, + severity="high", + confidence="high", + message=f"{package_name} {package_version} is affected by {vuln_id}", + fingerprint=_stable_fingerprint( + "pip-audit", + { + "name": package_name, + "version": package_version, + "id": vuln_id, + }, + ), + extras={ + "aliases": vuln.get("aliases", []), + "fix_versions": vuln.get("fix_versions", []), + "description": vuln.get("description", ""), + "package_name": package_name, + "package_version": package_version, + }, + ) + ) + return findings + + +def _parse_osv_scanner_payload(payload: object) -> list[ExternalFinding]: + if not isinstance(payload, dict): + return [] + findings: list[ExternalFinding] = [] + for result in payload.get("results", []): + if not isinstance(result, dict): + continue + packages = result.get("packages") + if not isinstance(packages, list): + continue + for package_entry in packages: + if not isinstance(package_entry, dict): + continue + package = package_entry.get("package") + if not isinstance(package, dict): + continue + package_name = str(package.get("name", "")) + package_version = str(package.get("version", "")) + ecosystem = str(package.get("ecosystem", "")) + for vuln in package_entry.get("vulnerabilities", []): + if not isinstance(vuln, dict): + continue + vuln_id = str(vuln.get("id", "")) or "osv" + findings.append( + ExternalFinding( + tool="osv-scanner", + domain="security", + category="sca", + rule_id=vuln_id, + severity="high", + confidence="high", + message=f"{package_name} {package_version} is affected by {vuln_id}", + fingerprint=_stable_fingerprint( + "osv-scanner", + { + "name": package_name, + "version": package_version, + "id": vuln_id, + }, + ), + extras={ + "ecosystem": ecosystem, + "summary": vuln.get("summary", ""), + "details": vuln.get("details", ""), + "aliases": vuln.get("aliases", []), + "package_name": package_name, + "package_version": package_version, + }, + ) + ) + return findings + + +def _parse_phpstan_payload(payload: object) -> list[ExternalFinding]: + if not isinstance(payload, dict): + return [] + findings: list[ExternalFinding] = [] + files = payload.get("files") + if not isinstance(files, dict): + return findings + for file_path, info in files.items(): + if not isinstance(info, dict): + continue + for message in info.get("messages", []): + if not isinstance(message, dict): + continue + line = int(message.get("line") or 1) + rule_id = str(message.get("identifier", "")) + text = str(message.get("message", "")).strip() + findings.append( + ExternalFinding( + tool="phpstan", + domain="quality", + category="static_analysis", + rule_id=rule_id, + severity="medium", + confidence="high", + file_path=str(file_path), + line=line, + message=text, + fingerprint=_stable_fingerprint( + "phpstan", + { + "file": file_path, + "line": line, + "rule": rule_id, + "message": text, + }, + ), + extras={ + "tip": message.get("tip"), + "ignorable": message.get("ignorable"), + }, + ) + ) + return findings + + +def _parse_composer_audit_payload(payload: object) -> list[ExternalFinding]: + if not isinstance(payload, dict): + return [] + findings: list[ExternalFinding] = [] + advisories = payload.get("advisories") + if isinstance(advisories, dict): + for package_name, package_advisories in advisories.items(): + if not isinstance(package_advisories, list): + continue + for advisory in package_advisories: + if not isinstance(advisory, dict): + continue + advisory_id = str( + advisory.get("advisoryId") + or advisory.get("cve") + or package_name + ) + title = str(advisory.get("title") or advisory.get("link") or package_name) + findings.append( + ExternalFinding( + tool="composer-audit", + domain="security", + category="sca", + rule_id=advisory_id, + severity=str(advisory.get("severity") or "high").lower(), + confidence="high", + file_path="composer.lock", + line=1, + message=f"{package_name}: {title}", + fingerprint=_stable_fingerprint( + "composer-audit", + { + "package": package_name, + "id": advisory_id, + }, + ), + extras={ + "cve": advisory.get("cve"), + "link": advisory.get("link"), + "affected_versions": advisory.get("affectedVersions"), + }, + ) + ) + abandoned = payload.get("abandoned") + if isinstance(abandoned, dict): + for package_name, replacement in abandoned.items(): + findings.append( + ExternalFinding( + tool="composer-audit", + domain="security", + category="abandoned_dependency", + rule_id="abandoned-package", + severity="medium", + confidence="high", + file_path="composer.lock", + line=1, + message=f"{package_name}: package is abandoned", + fingerprint=_stable_fingerprint( + "composer-audit", + {"package": package_name, "replacement": replacement}, + ), + extras={"replacement": replacement}, + ) + ) + return findings + + +def _parse_composer_audit_output(payload: str) -> list[ExternalFinding]: + """Backward-compatible string entrypoint used by tests and older callers.""" + try: + decoded = json.loads(payload) + except json.JSONDecodeError: + return [] + return _parse_composer_audit_payload(decoded) + + +def _parse_cargo_clippy_output( + payload: str, + project_path: Path, +) -> list[ExternalFinding]: + findings: list[ExternalFinding] = [] + if not payload.strip(): + return findings + + severity_map = { + "error": "high", + "warning": "medium", + "note": "low", + "help": "low", + } + for line in payload.splitlines(): + if not line.strip(): + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(entry, dict) or entry.get("reason") != "compiler-message": + continue + message = entry.get("message") + if not isinstance(message, dict): + continue + code = message.get("code") + if not isinstance(code, dict): + continue + rule_id = str(code.get("code", "")) + if not rule_id.startswith("clippy::"): + continue + spans = message.get("spans") + primary_span = None + if isinstance(spans, list) and spans: + primary_span = next( + (span for span in spans if isinstance(span, dict) and span.get("is_primary")), + spans[0], + ) + file_path = "" + line_no = 1 + if isinstance(primary_span, dict): + raw_file = str(primary_span.get("file_name", "")) + if raw_file: + candidate = Path(raw_file) + if candidate.is_absolute(): + try: + file_path = str(candidate.relative_to(project_path)) + except ValueError: + file_path = raw_file + else: + file_path = raw_file + raw_line = primary_span.get("line_start") + if isinstance(raw_line, int) and raw_line > 0: + line_no = raw_line + finding_payload = { + "rule_id": rule_id, + "file": file_path, + "line": line_no, + "message": str(message.get("message", "")), + } + findings.append( + ExternalFinding( + tool="cargo-clippy", + domain="quality", + category="lint", + rule_id=rule_id, + severity=severity_map.get(str(message.get("level", "warning")), "medium"), + confidence="high", + file_path=file_path, + line=line_no, + message=str(message.get("message", "")), + fingerprint=_stable_fingerprint("cargo-clippy", finding_payload), + extras={"rendered": str(message.get("rendered", ""))}, + ) + ) + return findings + + +def _parse_cargo_deny_output(payload: str) -> list[ExternalFinding]: + findings: list[ExternalFinding] = [] + if not payload.strip(): + return findings + + severity_map = { + "error": "high", + "warning": "medium", + "note": "low", + "help": "low", + } + for line in payload.splitlines(): + if not line.strip(): + continue + try: + entry = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(entry, dict) or entry.get("type") != "diagnostic": + continue + fields = entry.get("fields") + if not isinstance(fields, dict): + continue + + advisory = fields.get("advisory") + labels = fields.get("labels") + primary_label = labels[0] if isinstance(labels, list) and labels else None + line_no = 1 + file_path = "" + if isinstance(primary_label, dict): + raw_line = primary_label.get("line") + if isinstance(raw_line, int) and raw_line > 0: + line_no = raw_line + raw_file = primary_label.get("file") + if isinstance(raw_file, str): + file_path = raw_file + + code = str(fields.get("code") or "") + message = str(fields.get("message") or "").strip() + category, domain = _cargo_deny_category(code, advisory) + advisory_id = "" + if isinstance(advisory, dict): + advisory_id = str(advisory.get("id") or "") + + finding_payload = { + "code": code, + "advisory_id": advisory_id, + "message": message, + "line": line_no, + "file": file_path, + } + findings.append( + ExternalFinding( + tool=CARGO_DENY_TOOL, + domain=domain, + category=category, + rule_id=advisory_id or code or category, + severity=severity_map.get(str(fields.get("severity", "warning")), "medium"), + confidence="high", + file_path=file_path, + line=line_no, + message=message or "cargo-deny emitted a diagnostic", + fingerprint=_stable_fingerprint(CARGO_DENY_TOOL, finding_payload), + extras={ + "labels": labels if isinstance(labels, list) else [], + "notes": fields.get("notes", []), + "advisory": advisory if isinstance(advisory, dict) else {}, + }, + ) + ) + return findings + + +def _cargo_deny_category(code: str, advisory: object) -> tuple[str, str]: + if isinstance(advisory, dict): + return "sca", "security" + if code in CARGO_DENY_LICENSE_CODES: + return "license", "security" + if code in CARGO_DENY_SOURCE_CODES: + return "source_policy", "security" + return "supply_chain_policy", "quality" + + +def _parse_npm_audit_payload(payload: object) -> list[ExternalFinding]: + if not isinstance(payload, dict): + return [] + vulnerabilities = payload.get("vulnerabilities") + if not isinstance(vulnerabilities, dict): + return [] + findings: list[ExternalFinding] = [] + for package_name, vulnerability in vulnerabilities.items(): + if not isinstance(vulnerability, dict): + continue + severity = str(vulnerability.get("severity") or "medium").lower() + via = vulnerability.get("via") or [] + rule_id = package_name + title = package_name + if isinstance(via, list): + for entry in via: + if isinstance(entry, dict): + rule_id = str(entry.get("source") or entry.get("name") or package_name) + title = str(entry.get("title") or entry.get("url") or package_name) + break + findings.append( + ExternalFinding( + tool="npm-audit", + domain="security", + category="sca", + rule_id=rule_id, + severity=severity, + confidence="high", + file_path="package-lock.json", + line=1, + message=f"{package_name}: {title}", + fingerprint=_stable_fingerprint( + "npm-audit", + {"package": package_name, "rule": rule_id}, + ), + extras={ + "is_direct": vulnerability.get("isDirect"), + "fix_available": vulnerability.get("fixAvailable"), + "nodes": vulnerability.get("nodes"), + }, + ) + ) + return findings + + +def _parse_npm_audit_output(payload: str) -> list[ExternalFinding]: + """Backward-compatible string entrypoint used by tests and older callers.""" + try: + decoded = json.loads(payload) + except json.JSONDecodeError: + return [] + return _parse_npm_audit_payload(decoded) + + +def _relative_path(project_path: Path, candidate: str) -> str: + if not candidate: + return "" + try: + return str(Path(candidate).resolve().relative_to(project_path.resolve())) + except ValueError: + return candidate + + +def _location_row(item: dict) -> int: + location = item.get("location") + if isinstance(location, dict): + row = location.get("row") + if isinstance(row, int): + return row + return 1 + + +def _fingerprint(item: dict) -> str: + filename = str(item.get("filename", "")) + code = str(item.get("code", "")) + row = _location_row(item) + return f"{filename}:{row}:{code}" + + +def _stable_fingerprint(tool: str, payload: dict) -> str: + return hashlib.sha256( + f"{tool}:{json.dumps(payload, sort_keys=True, ensure_ascii=False)}".encode( + "utf-8" + ) + ).hexdigest() + + +def _ruff_severity(rule_id: str) -> str: + if rule_id in { + "S105", + "S106", + "S107", + "S324", + "S501", + "S602", + "S603", + "S607", + "S608", + }: + return "high" + if rule_id.startswith("S"): + return "medium" + return "low" + + +_TOOL_RUNNERS = { + "ruff": _run_ruff_security, + "gitleaks": _run_gitleaks, + "pip-audit": _run_pip_audit, + "osv-scanner": _run_osv_scanner, + "phpstan": _run_phpstan, + "composer-audit": _run_composer_audit, + "npm-audit": _run_npm_audit, + "cargo-deny": _run_cargo_deny, + "cargo-clippy": _run_cargo_clippy, +} diff --git a/src/aigiscode/synthesis/claude.py b/src/aigiscode/synthesis/claude.py index 73cc9e9..e2c4009 100644 --- a/src/aigiscode/synthesis/claude.py +++ b/src/aigiscode/synthesis/claude.py @@ -173,6 +173,7 @@ async def synthesize( graph_result: GraphAnalysisResult, envelopes_by_layer: dict[str, list[dict]], model: str = "gpt-5.3-codex", + primary_backend: str = "codex", allow_claude_fallback: bool = True, ) -> str: """Run synthesis to generate an architectural assessment. @@ -185,6 +186,8 @@ async def synthesize( system=SYNTHESIS_SYSTEM_PROMPT, user=user_prompt, model=model, + # Kept for API compatibility with CLI policy wiring. + # Current generate_text() backend ordering is controlled by fallback flags. allow_codex_cli_fallback=True, allow_claude_fallback=allow_claude_fallback, reasoning_effort="medium", diff --git a/tests/conftest.py b/tests/conftest.py index 1dfc780..c4655ac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import sys from pathlib import Path @@ -8,3 +9,11 @@ SRC = ROOT / "src" if str(SRC) not in sys.path: sys.path.insert(0, str(SRC)) + +# Ensure the current Python interpreter's bin directory is on PATH so that +# tools installed alongside the interpreter (e.g. ruff) can be discovered +# by shutil.which() at runtime. Use the non-resolved path to preserve +# the venv's bin directory (resolved paths follow symlinks to /usr/bin). +_interpreter_bin = str(Path(sys.executable).parent) +if _interpreter_bin not in os.environ.get("PATH", ""): + os.environ["PATH"] = _interpreter_bin + os.pathsep + os.environ.get("PATH", "") diff --git a/tests/test_ai_reviewer_sig.py b/tests/test_ai_reviewer_sig.py new file mode 100644 index 0000000..1a2b550 --- /dev/null +++ b/tests/test_ai_reviewer_sig.py @@ -0,0 +1,7 @@ +import inspect +from aigiscode.review.ai_reviewer import review_findings + + +def test_review_findings_accepts_primary_backend(): + sig = inspect.signature(review_findings) + assert "primary_backend" in sig.parameters diff --git a/tests/test_backends.py b/tests/test_backends.py new file mode 100644 index 0000000..982c0a2 --- /dev/null +++ b/tests/test_backends.py @@ -0,0 +1,140 @@ +"""Tests for aigiscode.ai.backends — describe_backend_order & has_any_backend.""" + +from __future__ import annotations + +import shutil + +from aigiscode.ai import backends + + +# --------------------------------------------------------------------------- +# describe_backend_order +# --------------------------------------------------------------------------- + +class TestDescribeBackendOrder: + """Tests for describe_backend_order.""" + + def test_all_backends_available(self, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/codex" if cmd == "codex" else None) + result = backends.describe_backend_order( + primary_backend="codex", + allow_codex_cli_fallback=True, + allow_claude_fallback=True, + ) + assert result == "Codex SDK \u2192 Codex CLI \u2192 Claude" + + def test_only_openai(self, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setattr(shutil, "which", lambda cmd: None) + result = backends.describe_backend_order( + primary_backend="codex", + allow_codex_cli_fallback=True, + allow_claude_fallback=True, + ) + assert result == "Codex SDK" + + def test_no_backends(self, monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setattr(shutil, "which", lambda cmd: None) + result = backends.describe_backend_order() + assert result == "no backend available" + + def test_non_codex_primary_skips_sdk(self, monkeypatch): + """When primary_backend is not 'codex', Codex SDK should be skipped.""" + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/codex" if cmd == "codex" else None) + result = backends.describe_backend_order( + primary_backend="claude", + allow_codex_cli_fallback=True, + allow_claude_fallback=True, + ) + # Codex SDK should NOT appear since primary_backend != "codex" + assert "Codex SDK" not in result + assert "Codex CLI" in result + assert "Claude" in result + + def test_disable_codex_cli_fallback(self, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/codex" if cmd == "codex" else None) + result = backends.describe_backend_order( + primary_backend="codex", + allow_codex_cli_fallback=False, + allow_claude_fallback=True, + ) + assert result == "Codex SDK \u2192 Claude" + + def test_disable_claude_fallback(self, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/codex" if cmd == "codex" else None) + result = backends.describe_backend_order( + primary_backend="codex", + allow_codex_cli_fallback=True, + allow_claude_fallback=False, + ) + assert result == "Codex SDK \u2192 Codex CLI" + + +# --------------------------------------------------------------------------- +# has_any_backend +# --------------------------------------------------------------------------- + +class TestHasAnyBackend: + """Tests for has_any_backend.""" + + def test_openai_key_with_codex_primary(self, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setattr(shutil, "which", lambda cmd: None) + assert backends.has_any_backend(primary_backend="codex") is True + + def test_openai_key_with_non_codex_primary(self, monkeypatch): + """OpenAI key present but primary_backend is not codex -> SDK check skipped.""" + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setattr(shutil, "which", lambda cmd: None) + # With no codex CLI and no anthropic key, and primary != codex, + # there should be no backend available. + assert backends.has_any_backend( + primary_backend="claude", + allow_codex_cli_fallback=True, + allow_claude_fallback=False, + ) is False + + def test_codex_cli_fallback(self, monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/codex" if cmd == "codex" else None) + assert backends.has_any_backend(primary_backend="codex") is True + + def test_claude_fallback(self, monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + monkeypatch.setattr(shutil, "which", lambda cmd: None) + assert backends.has_any_backend( + primary_backend="codex", + allow_codex_cli_fallback=False, + allow_claude_fallback=True, + ) is True + + def test_no_backends(self, monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setattr(shutil, "which", lambda cmd: None) + assert backends.has_any_backend() is False + + def test_all_fallbacks_disabled_no_primary(self, monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setattr(shutil, "which", lambda cmd: None) + assert backends.has_any_backend( + primary_backend="codex", + allow_codex_cli_fallback=False, + allow_claude_fallback=False, + ) is False diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..b7efaf7 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from pathlib import Path + +from typer.testing import CliRunner + +from aigiscode.cli import app + + +runner = CliRunner() + + +def test_index_and_report_support_custom_output_dir(tmp_path: Path) -> None: + project_root = tmp_path / "project" + project_root.mkdir() + (project_root / "app.py").write_text("print('hello')\n", encoding="utf-8") + + output_dir = tmp_path / "reports" / "aigiscode" + + index_result = runner.invoke( + app, + ["index", str(project_root), "--output-dir", str(output_dir)], + ) + assert index_result.exit_code == 0 + assert (output_dir / "aigiscode.db").exists() + + report_result = runner.invoke( + app, + ["report", str(project_root), "--output-dir", str(output_dir)], + ) + assert report_result.exit_code == 0 + assert (output_dir / "aigiscode-report.json").exists() + assert any(path.name.endswith("aigiscode-report.json") for path in (output_dir / "reports").iterdir()) diff --git a/tests/test_cli_e2e.py b/tests/test_cli_e2e.py new file mode 100644 index 0000000..f53708f --- /dev/null +++ b/tests/test_cli_e2e.py @@ -0,0 +1,484 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path + +from typer.testing import CliRunner + +from aigiscode.cli import app + + +runner = CliRunner() + + +def _write(project_root: Path, relative_path: str, content: str) -> None: + path = project_root / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def _write_executable(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def _latest_run_dir(output_dir: Path) -> Path: + run_dirs = [path for path in (output_dir / "reports").iterdir() if path.is_dir()] + assert run_dirs + return max(run_dirs, key=lambda path: path.stat().st_mtime_ns) + + +def test_analyze_writes_security_findings_and_archived_reports(tmp_path: Path) -> None: + project_root = tmp_path / "project" + project_root.mkdir() + + _write( + project_root, + "app/runtime.py", + "import os\n" + "API_KEY = os.getenv('API_KEY')\n", + ) + _write( + project_root, + "app/api.php", + " None: + project_root = tmp_path / "project" + project_root.mkdir() + + _write( + project_root, + "app/runtime.py", + "import subprocess\n" + "subprocess.call('ls', shell=True)\n", + ) + + result = runner.invoke( + app, + [ + "analyze", + str(project_root), + "--skip-ai", + "--skip-review", + "--skip-synthesis", + "--run-ruff-security", + ], + ) + + assert result.exit_code == 0, result.stdout + + output_dir = project_root / ".aigiscode" + payload = json.loads( + (output_dir / "aigiscode-report.json").read_text(encoding="utf-8") + ) + + assert payload["external_analysis"] is not None + assert payload["external_analysis"]["tool_runs"][0]["tool"] == "ruff" + assert payload["external_analysis"]["tool_runs"][0]["status"] == "findings" + assert any( + finding["rule_id"] == "S602" + for finding in payload["external_analysis"]["findings"] + ) + + run_dir = _latest_run_dir(output_dir) + assert (run_dir / "raw" / "ruff-security.json").exists() + assert (run_dir / "aigiscode-handoff.json").exists() + + +def test_analyze_runs_fake_gitleaks_and_archives_external_artifacts( + tmp_path: Path, +) -> None: + project_root = tmp_path / "project" + project_root.mkdir() + _write(project_root, "app/config.php", " None: + project_root = tmp_path / "project" + project_root.mkdir() + + _write( + project_root, + "app/runtime.py", + "import subprocess\n" + "subprocess.call('ls', shell=True)\n", + ) + + result = runner.invoke( + app, + [ + "analyze", + str(project_root), + "--skip-ai", + "--skip-review", + "--skip-synthesis", + "--external-tool", + "ruff", + ], + ) + + assert result.exit_code == 0, result.stdout + + output_dir = project_root / ".aigiscode" + payload = json.loads( + (output_dir / "aigiscode-report.json").read_text(encoding="utf-8") + ) + + assert payload["external_analysis"] is not None + assert payload["external_analysis"]["tool_runs"][0]["tool"] == "ruff" + assert any( + finding["rule_id"] == "S602" + for finding in payload["external_analysis"]["findings"] + ) + + +def test_analyze_runs_fake_cargo_deny_and_archives_raw_artifacts( + tmp_path: Path, +) -> None: + project_root = tmp_path / "project" + project_root.mkdir() + _write( + project_root, + "Cargo.toml", + '[package]\nname = "demo"\nversion = "0.1.0"\nedition = "2021"\n', + ) + _write(project_root, "src/main.rs", "fn main() {}\n") + + bin_dir = tmp_path / "bin" + cargo_deny_path = bin_dir / "cargo-deny" + _write_executable( + cargo_deny_path, + """#!/usr/bin/env python3 +import json + +payloads = [ + { + "type": "diagnostic", + "fields": { + "severity": "error", + "message": "demo crate is vulnerable", + "code": "vulnerability", + "labels": [{"line": 1, "column": 1, "span": "demo"}], + "advisory": {"id": "RUSTSEC-2026-0001", "title": "demo advisory"} + } + }, + { + "type": "diagnostic", + "fields": { + "severity": "warning", + "message": "license was not explicitly accepted", + "code": "rejected", + "labels": [{"line": 2, "column": 1, "span": "GPL-3.0-only"}] + } + } +] +for payload in payloads: + print(json.dumps(payload)) +""", + ) + + env = os.environ.copy() + env["PATH"] = f"{bin_dir}:{env.get('PATH', '')}" + + result = runner.invoke( + app, + [ + "analyze", + str(project_root), + "--skip-ai", + "--skip-review", + "--skip-synthesis", + "--external-tool", + "cargo-deny", + ], + env=env, + ) + + assert result.exit_code == 0, result.stdout + + output_dir = project_root / ".aigiscode" + payload = json.loads( + (output_dir / "aigiscode-report.json").read_text(encoding="utf-8") + ) + + assert payload["external_analysis"] is not None + assert payload["external_analysis"]["tool_runs"][0]["tool"] == "cargo-deny" + assert payload["security"]["external_findings"] == 2 + assert payload["security"]["sca"] == 1 + assert payload["security"]["license"] == 1 + + run_dir = _latest_run_dir(output_dir) + assert (run_dir / "raw" / "cargo-deny.jsonl").exists() + assert (run_dir / "aigiscode-handoff.json").exists() + + + + +def test_report_runs_fake_gitleaks_and_archives_external_artifacts( + tmp_path: Path, +) -> None: + project_root = tmp_path / "project" + project_root.mkdir() + _write(project_root, "app/config.php", " None: + project_root = tmp_path / "project" + project_root.mkdir() + _write(project_root, "app/config.php", " None: path.write_text(content, encoding="utf-8") +def _index_parsed_file( + store: IndexStore, + project_root: Path, + relative_path: str, + language: Language, +) -> int: + file_path = project_root / relative_path + symbols, dependencies = parse_file( + file_path, + language, + project_root=project_root, + ) + file_id = store.insert_file( + FileInfo(path=relative_path, language=language, size=file_path.stat().st_size) + ) + for symbol in symbols: + store.insert_symbol(symbol.model_copy(update={"file_id": file_id})) + for dependency in dependencies: + store.insert_dependency(dependency.model_copy(update={"source_file_id": file_id})) + return file_id + + def test_dead_code_skips_stale_index_rows(tmp_path: Path) -> None: project_root = tmp_path / "project" project_root.mkdir() @@ -182,6 +205,44 @@ def test_abandoned_class_ignores_non_php_languages_by_default(tmp_path: Path) -> store.close() +def test_rust_dead_code_detects_unused_imports_methods_properties_and_public_types( + tmp_path: Path, +) -> None: + project_root = tmp_path / "project" + project_root.mkdir() + store = _make_store(project_root) + + _write( + project_root, + "src/lib.rs", + "use crate::http::{Client as HttpClient, Server};\n" + "pub struct Service { cache: Cache, local: Local }\n" + "pub struct Exported {}\n" + "impl Service {\n" + " pub fn run(&self) -> usize { let _ = self.helper(); self.local.len() }\n" + " fn helper(&self) -> HttpClient { HttpClient::new() }\n" + " fn stale(&self) {}\n" + "}\n", + ) + _index_parsed_file(store, project_root, "src/lib.rs", Language.RUST) + + _write( + project_root, + "src/consumer.rs", + "use crate::lib::Service;\n" + "pub fn consume(service: &Service) -> usize { service.run() }\n", + ) + _index_parsed_file(store, project_root, "src/consumer.rs", Language.RUST) + + result = analyze_dead_code(store, policy=DeadCodePolicy()) + + assert [finding.name for finding in result.unused_imports] == ["crate::http::Server"] + assert [finding.name for finding in result.unused_methods] == ["stale"] + assert [finding.name for finding in result.unused_properties] == ["cache"] + assert [finding.name for finding in result.abandoned_classes] == ["Exported"] + store.close() + + def test_unused_import_respects_top_level_route_code(tmp_path: Path) -> None: project_root = tmp_path / "project" project_root.mkdir() @@ -508,6 +569,39 @@ def test_ts_unused_imports_respect_object_shorthand_usage(tmp_path: Path) -> Non store.close() +def test_tsx_unused_imports_respect_jsx_component_usage(tmp_path: Path) -> None: + project_root = tmp_path / "project" + project_root.mkdir() + store = _make_store(project_root) + + _write( + project_root, + "resources/js/Navbar.tsx", + "import { Moon, Sun, List, X, Ghost } from '@phosphor-icons/react'\n" + "export function Navbar({ isDark, isMenuOpen }: { isDark: boolean; isMenuOpen: boolean }) {\n" + " return (\n" + "
\n" + " {isDark ? : }\n" + " {isMenuOpen ? : }\n" + "
\n" + " )\n" + "}\n", + ) + store.insert_file( + FileInfo( + path="resources/js/Navbar.tsx", + language=Language.TYPESCRIPT, + size=0, + ) + ) + + result = analyze_dead_code(store, policy=DeadCodePolicy()) + + flagged_names = {finding.name for finding in result.unused_imports} + assert flagged_names == {"Ghost"} + store.close() + + def test_ts_private_method_callback_reference_is_not_flagged(tmp_path: Path) -> None: project_root = tmp_path / "project" project_root.mkdir() diff --git a/tests/test_external_security.py b/tests/test_external_security.py new file mode 100644 index 0000000..ce9933d --- /dev/null +++ b/tests/test_external_security.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path + +from aigiscode.models import ExternalFinding +from aigiscode.rules.checks import StructuralContext +from aigiscode.rules.engine import Rule, filter_external_findings +from aigiscode.security.external import ( + _parse_cargo_deny_output, + _parse_composer_audit_payload, + _parse_npm_audit_payload, + _refine_findings, + _sanitize_stderr, + collect_external_analysis, +) + + +def test_parse_composer_audit_output_imports_advisories_and_abandoned() -> None: + payload = """ + { + "advisories": { + "firebase/php-jwt": [ + { + "advisoryId": "PKSA-123", + "title": "JWT signature validation bypass", + "cve": "CVE-2026-0001", + "link": "https://example.test/advisory", + "severity": "high", + "affectedVersions": "<7.0.0" + } + ] + }, + "abandoned": { + "legacy/package": "replacement/package" + } + } + """ + + findings = _parse_composer_audit_payload(json.loads(payload)) + + assert len(findings) == 2 + assert findings[0].tool == "composer-audit" + assert findings[0].domain == "security" + assert findings[0].category == "sca" + assert findings[0].rule_id == "PKSA-123" + assert findings[1].category == "abandoned_dependency" + + +def test_parse_npm_audit_output_imports_vulnerabilities() -> None: + payload = """ + { + "vulnerabilities": { + "axios": { + "severity": "high", + "isDirect": true, + "via": [ + { + "source": 1098583, + "name": "axios", + "title": "SSRF in axios" + } + ], + "nodes": [ + "node_modules/axios" + ], + "fixAvailable": true + } + } + } + """ + + findings = _parse_npm_audit_payload(json.loads(payload)) + + assert len(findings) == 1 + assert findings[0].tool == "npm-audit" + assert findings[0].domain == "security" + assert findings[0].category == "sca" + assert findings[0].rule_id == "1098583" + assert findings[0].severity == "high" + + +def test_parse_cargo_deny_output_imports_advisories_and_license_findings() -> None: + payload = "\n".join( + [ + json.dumps( + { + "type": "diagnostic", + "fields": { + "severity": "error", + "message": "ring is vulnerable", + "code": "vulnerability", + "labels": [ + { + "message": "affected crate", + "line": 1, + "column": 1, + "span": "ring", + } + ], + "advisory": { + "id": "RUSTSEC-2026-0001", + "title": "ring advisory", + }, + }, + } + ), + json.dumps( + { + "type": "diagnostic", + "fields": { + "severity": "warning", + "message": "license was not explicitly accepted", + "code": "rejected", + "labels": [ + { + "message": "crate license", + "line": 3, + "column": 5, + "span": "GPL-3.0-only", + } + ], + }, + } + ), + ] + ) + + findings = _parse_cargo_deny_output(payload) + + assert len(findings) == 2 + assert findings[0].tool == "cargo-deny" + assert findings[0].domain == "security" + assert findings[0].category == "sca" + assert findings[0].rule_id == "RUSTSEC-2026-0001" + assert findings[1].category == "license" + assert findings[1].severity == "medium" + + +def test_refine_findings_drops_ruff_asserts_in_test_paths() -> None: + findings, filtered_count = _refine_findings( + [ + ExternalFinding( + tool="ruff", + domain="security", + category="sast", + rule_id="S101", + severity="medium", + confidence="high", + file_path="tests/test_auth.py", + line=10, + message="Use of assert detected", + fingerprint="a", + ), + ExternalFinding( + tool="ruff", + domain="security", + category="sast", + rule_id="S608", + severity="high", + confidence="medium", + file_path="app/query_builder.py", + line=20, + message="Possible SQL injection vector through string-based query construction", + fingerprint="b", + ), + ] + ) + + assert filtered_count == 1 + assert [finding.rule_id for finding in findings] == ["S608"] + + +def test_sanitize_stderr_filters_composer_deprecation_noise() -> None: + sanitized, suppressed = _sanitize_stderr( + "composer-audit", + "\n".join( + [ + "Deprecation Notice: Something noisy", + "Deprecation Notice: Something else noisy", + "Composer could not find a lock file", + ] + ), + ) + + assert sanitized == "Composer could not find a lock file" + assert suppressed == 2 + + +def test_filter_external_findings_applies_saved_rules() -> None: + from aigiscode.models import ExternalAnalysisResult, ExternalToolRun + + external_analysis = ExternalAnalysisResult( + tool_runs=[ + ExternalToolRun( + tool="gitleaks", + command=["gitleaks"], + status="findings", + summary={"finding_count": 2}, + ) + ], + findings=[ + ExternalFinding( + tool="gitleaks", + domain="security", + category="secrets", + rule_id="generic-api-key", + severity="high", + confidence="medium", + file_path="vendor/seeds/demo.php", + line=4, + message="Seed secret", + fingerprint="a", + ), + ExternalFinding( + tool="gitleaks", + domain="security", + category="secrets", + rule_id="generic-api-key", + severity="high", + confidence="medium", + file_path="app/config.php", + line=9, + message="Real secret", + fingerprint="b", + ), + ], + ) + + filtered, excluded = filter_external_findings( + external_analysis, + [ + Rule( + id="rule-seed-secrets", + category="secrets", + checks=[{"type": "file_glob", "params": {"pattern": "vendor/seeds/*"}}], + reason="Ignore generated demo fixtures", + ) + ], + ctx=StructuralContext(), + ) + + assert excluded == 1 + assert [finding.file_path for finding in filtered.findings] == ["app/config.php"] + assert filtered.tool_runs[0].summary["finding_count"] == 1 + assert filtered.tool_runs[0].summary["rules_filtered_count"] == 1 + + +def test_collect_external_analysis_marks_nonzero_npm_without_findings_as_failed( + tmp_path: Path, +) -> None: + project_root = tmp_path / "project" + project_root.mkdir() + (project_root / "package.json").write_text('{"name":"demo","version":"1.0.0"}\n', encoding="utf-8") + + bin_dir = tmp_path / "bin" + npm_path = bin_dir / "npm" + npm_path.parent.mkdir(parents=True, exist_ok=True) + npm_path.write_text( + """#!/usr/bin/env python3 +import json +import sys + +print(json.dumps({"error": {"summary": "registry unavailable"}})) +sys.exit(1) +""", + encoding="utf-8", + ) + npm_path.chmod(0o755) + + original_path = os.environ.get("PATH", "") + os.environ["PATH"] = f"{bin_dir}:{original_path}" + try: + result = collect_external_analysis( + project_path=project_root, + output_dir=tmp_path / ".aigiscode", + run_id="20260309_150000", + selected_tools=["npm-audit"], + ) + finally: + os.environ["PATH"] = original_path + + assert result.findings == [] + assert result.tool_runs[0].tool == "npm-audit" + assert result.tool_runs[0].status == "failed" + assert result.tool_runs[0].summary["error"].startswith("npm-audit exited with code 1") + + +def test_collect_external_analysis_runs_fake_cargo_clippy( + tmp_path: Path, +) -> None: + project_root = tmp_path / "project" + project_root.mkdir() + (project_root / "Cargo.toml").write_text( + """[package] +name = "demo" +version = "0.1.0" +edition = "2021" +""", + encoding="utf-8", + ) + + bin_dir = tmp_path / "bin" + cargo_path = bin_dir / "cargo" + cargo_path.parent.mkdir(parents=True, exist_ok=True) + cargo_path.write_text( + """#!/usr/bin/env python3 +import json + +payload = { + "reason": "compiler-message", + "message": { + "level": "warning", + "message": "this returns a `String` unnecessarily", + "code": {"code": "clippy::needless_to_string"}, + "spans": [ + { + "file_name": "src/main.rs", + "line_start": 7, + "is_primary": True + } + ], + "rendered": "warning: needless_to_string" + } +} +print(json.dumps(payload)) +""", + encoding="utf-8", + ) + cargo_path.chmod(0o755) + + original_path = os.environ.get("PATH", "") + os.environ["PATH"] = f"{bin_dir}:{original_path}" + try: + result = collect_external_analysis( + project_path=project_root, + output_dir=tmp_path / ".aigiscode", + run_id="20260311_130000", + selected_tools=["cargo-clippy"], + ) + finally: + os.environ["PATH"] = original_path + + assert result.tool_runs[0].tool == "cargo-clippy" + assert result.tool_runs[0].status == "findings" + assert result.tool_runs[0].summary["finding_count"] == 1 + assert result.findings[0].tool == "cargo-clippy" + assert result.findings[0].domain == "quality" + assert result.findings[0].rule_id == "clippy::needless_to_string" + assert result.findings[0].file_path == "src/main.rs" + assert result.findings[0].line == 7 + + +def test_collect_external_analysis_runs_fake_cargo_deny( + tmp_path: Path, +) -> None: + project_root = tmp_path / "project" + project_root.mkdir() + (project_root / "Cargo.toml").write_text( + """[package] +name = "demo" +version = "0.1.0" +edition = "2021" +""", + encoding="utf-8", + ) + + bin_dir = tmp_path / "bin" + cargo_deny_path = bin_dir / "cargo-deny" + cargo_deny_path.parent.mkdir(parents=True, exist_ok=True) + cargo_deny_path.write_text( + """#!/usr/bin/env python3 +import json + +payloads = [ + { + "type": "diagnostic", + "fields": { + "severity": "error", + "message": "demo crate is vulnerable", + "code": "vulnerability", + "labels": [{"line": 1, "column": 1, "span": "demo"}], + "advisory": {"id": "RUSTSEC-2026-0001", "title": "demo advisory"} + } + }, + { + "type": "diagnostic", + "fields": { + "severity": "warning", + "message": "license was not explicitly accepted", + "code": "rejected", + "labels": [{"line": 2, "column": 1, "span": "GPL-3.0-only"}] + } + } +] +for payload in payloads: + print(json.dumps(payload)) +""", + encoding="utf-8", + ) + cargo_deny_path.chmod(0o755) + + original_path = os.environ.get("PATH", "") + os.environ["PATH"] = f"{bin_dir}:{original_path}" + try: + result = collect_external_analysis( + project_path=project_root, + output_dir=tmp_path / ".aigiscode", + run_id="20260311_140000", + selected_tools=["cargo-deny"], + ) + finally: + os.environ["PATH"] = original_path + + assert result.tool_runs[0].tool == "cargo-deny" + assert result.tool_runs[0].status == "findings" + assert result.tool_runs[0].summary["finding_count"] == 2 + assert result.findings[0].tool == "cargo-deny" + assert result.findings[0].domain == "security" + assert result.findings[0].rule_id == "RUSTSEC-2026-0001" + assert result.findings[1].category == "license" diff --git a/tests/test_hardwiring.py b/tests/test_hardwiring.py index cbc7fe4..077ae5a 100644 --- a/tests/test_hardwiring.py +++ b/tests/test_hardwiring.py @@ -63,6 +63,54 @@ def test_ruby_env_reads_are_reported_outside_config(tmp_path: Path) -> None: store.close() +def test_rust_env_and_network_reads_are_reported_outside_config(tmp_path: Path) -> None: + project_root = tmp_path / "project" + project_root.mkdir() + store = _make_store(project_root) + + _write( + project_root, + "src/runtime.rs", + 'let api_key = std::env::var("API_KEY").unwrap();\n' + 'if mode == "release" { let _ = "https://api.acme.test"; }\n', + ) + store.insert_file(FileInfo(path="src/runtime.rs", language=Language.RUST, size=0)) + + result = analyze_hardwiring(store, policy=HardwiringPolicy()) + + assert any( + finding.file_path == "src/runtime.rs" for finding in result.env_outside_config + ) + assert any( + finding.file_path == "src/runtime.rs" and finding.value == "release" + for finding in result.magic_strings + ) + assert any( + finding.file_path == "src/runtime.rs" + and finding.value == "https://api.acme.test" + for finding in result.hardcoded_network + ) + store.close() + + +def test_rust_build_scripts_are_treated_as_tooling_for_env_reads(tmp_path: Path) -> None: + project_root = tmp_path / "project" + project_root.mkdir() + store = _make_store(project_root) + + _write( + project_root, + "build.rs", + 'let target = std::env::var("TARGET").unwrap();\n', + ) + store.insert_file(FileInfo(path="build.rs", language=Language.RUST, size=0)) + + result = analyze_hardwiring(store, policy=HardwiringPolicy()) + + assert result.env_outside_config == [] + store.close() + + def test_semver_literals_do_not_become_magic_strings(tmp_path: Path) -> None: project_root = tmp_path / "project" project_root.mkdir() diff --git a/tests/test_models_external.py b/tests/test_models_external.py new file mode 100644 index 0000000..06c7bab --- /dev/null +++ b/tests/test_models_external.py @@ -0,0 +1,110 @@ +"""Tests for ExternalFinding, ExternalToolRun, ExternalAnalysisResult, and FeedbackLoop models.""" + +from aigiscode.models import ( + ExternalAnalysisResult, + ExternalFinding, + ExternalToolRun, + FeedbackLoop, + ReportData, +) + + +class TestExternalFinding: + def test_instantiate_with_required_only(self): + f = ExternalFinding(tool="semgrep") + assert f.tool == "semgrep" + assert f.rule_id == "" + assert f.file_path == "" + assert f.line == 0 + assert f.message == "" + assert f.severity == "medium" + assert f.domain == "security" + assert f.category == "" + assert f.fingerprint == "" + assert f.metadata == {} + + def test_instantiate_with_all_fields(self): + f = ExternalFinding( + tool="bandit", + rule_id="B101", + file_path="app.py", + line=42, + message="Use of assert", + severity="high", + domain="security", + category="assert", + fingerprint="abc123", + metadata={"cwe": "CWE-703"}, + ) + assert f.tool == "bandit" + assert f.rule_id == "B101" + assert f.metadata == {"cwe": "CWE-703"} + + +class TestExternalToolRun: + def test_instantiate_with_required_only(self): + r = ExternalToolRun(tool="semgrep") + assert r.tool == "semgrep" + assert r.command == [] + assert r.status == "pending" + assert r.findings_count == 0 + assert r.summary == {} + assert r.version == "" + + def test_instantiate_with_all_fields(self): + r = ExternalToolRun( + tool="semgrep", + command=["semgrep", "--json"], + status="success", + findings_count=5, + summary={"errors": 0}, + version="1.0.0", + ) + assert r.command == ["semgrep", "--json"] + assert r.findings_count == 5 + + +class TestExternalAnalysisResult: + def test_instantiate_defaults(self): + result = ExternalAnalysisResult() + assert result.tool_runs == [] + assert result.findings == [] + + def test_instantiate_with_data(self): + run = ExternalToolRun(tool="semgrep") + finding = ExternalFinding(tool="semgrep") + result = ExternalAnalysisResult(tool_runs=[run], findings=[finding]) + assert len(result.tool_runs) == 1 + assert len(result.findings) == 1 + + +class TestFeedbackLoop: + def test_instantiate_defaults(self): + fl = FeedbackLoop() + assert fl.detected_total == 0 + assert fl.actionable_visible == 0 + assert fl.accepted_by_policy == 0 + assert fl.rules_generated == 0 + + def test_instantiate_with_values(self): + fl = FeedbackLoop( + detected_total=100, + actionable_visible=50, + accepted_by_policy=30, + rules_generated=5, + ) + assert fl.detected_total == 100 + assert fl.rules_generated == 5 + + +class TestReportDataFeedbackLoop: + def test_report_data_has_feedback_loop(self): + rd = ReportData(project_path="/tmp/test") + assert isinstance(rd.feedback_loop, FeedbackLoop) + assert rd.feedback_loop.detected_total == 0 + + def test_report_data_with_custom_feedback_loop(self): + fl = FeedbackLoop(detected_total=10, rules_generated=2) + rd = ReportData(project_path="/tmp/test", feedback_loop=fl) + assert rd.feedback_loop.detected_total == 10 + assert rd.feedback_loop.rules_generated == 2 diff --git a/tests/test_orchestration.py b/tests/test_orchestration.py new file mode 100644 index 0000000..7300491 --- /dev/null +++ b/tests/test_orchestration.py @@ -0,0 +1,366 @@ +"""Tests for aigiscode.orchestration module.""" + +from __future__ import annotations + +import inspect +from dataclasses import fields as dc_fields +from datetime import datetime +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from aigiscode.orchestration import ( + DeterministicResult, + RuntimeEnvironment, + build_report_data, + collect_external_analysis_for_report, + combine_runtime_plugins, + resolve_runtime_environment, + run_deterministic_analysis, + selected_external_tools, +) + + +# --------------------------------------------------------------------------- +# selected_external_tools +# --------------------------------------------------------------------------- + + +class TestSelectedExternalTools: + """Tests for the selected_external_tools helper.""" + + def test_none_input_returns_none(self): + assert selected_external_tools(None) is None + + def test_empty_list_returns_none(self): + assert selected_external_tools([]) is None + + def test_explicit_tools_returned_as_is(self): + result = selected_external_tools(["ruff", "gitleaks"]) + assert result == ["ruff", "gitleaks"] + + def test_all_expands_to_supported_tools(self): + from aigiscode.security.external import SUPPORTED_SECURITY_TOOLS + + result = selected_external_tools(["all"]) + assert result == list(SUPPORTED_SECURITY_TOOLS) + + def test_ruff_security_flag_adds_ruff(self): + result = selected_external_tools(None, run_ruff_security=True) + assert result is not None + assert "ruff" in result + + def test_ruff_security_flag_no_duplicate_with_explicit_ruff(self): + result = selected_external_tools(["ruff"], run_ruff_security=True) + assert result is not None + assert result.count("ruff") == 1 + + def test_ruff_security_flag_appends_to_existing(self): + result = selected_external_tools(["gitleaks"], run_ruff_security=True) + assert result is not None + assert "gitleaks" in result + assert "ruff" in result + + +# --------------------------------------------------------------------------- +# combine_runtime_plugins +# --------------------------------------------------------------------------- + + +class TestCombineRuntimePlugins: + """Tests for the combine_runtime_plugins helper.""" + + def test_empty_inputs(self): + result = combine_runtime_plugins([], []) + assert result == [] + + def test_no_external_plugins(self): + result = combine_runtime_plugins(["generic", "laravel"], []) + assert result == [] + + def test_with_external_plugins(self): + from aigiscode.extensions import ExternalPlugin + + plugin = ExternalPlugin(ref="my_plugin", module=MagicMock(), name="my_plugin") + result = combine_runtime_plugins(["generic", "module:my_plugin"], [plugin]) + assert len(result) == 1 + assert result[0].name == "my_plugin" + + def test_filters_to_applied_plugins_only(self): + from aigiscode.extensions import ExternalPlugin + + plugin_a = ExternalPlugin(ref="a_ref", module=MagicMock(), name="a_name") + plugin_b = ExternalPlugin(ref="b_ref", module=MagicMock(), name="b_name") + # Only a_ref is in the applied list + result = combine_runtime_plugins( + ["generic", "module:a_ref"], + [plugin_a, plugin_b], + ) + assert len(result) == 1 + assert result[0].name == "a_name" + + def test_returns_all_when_all_applied(self): + from aigiscode.extensions import ExternalPlugin + + plugin_a = ExternalPlugin(ref="a_ref", module=MagicMock(), name="a_name") + plugin_b = ExternalPlugin(ref="b_ref", module=MagicMock(), name="b_name") + result = combine_runtime_plugins( + ["generic", "module:a_ref", "module:b_ref"], + [plugin_a, plugin_b], + ) + assert len(result) == 2 + + +# --------------------------------------------------------------------------- +# RuntimeEnvironment dataclass +# --------------------------------------------------------------------------- + + +class TestRuntimeEnvironment: + """Verify the RuntimeEnvironment dataclass structure.""" + + def test_has_required_fields(self): + field_names = {f.name for f in dc_fields(RuntimeEnvironment)} + assert "policy" in field_names + assert "runtime_plugins" in field_names + + def test_can_construct(self): + from aigiscode.policy.models import AnalysisPolicy + + env = RuntimeEnvironment( + policy=AnalysisPolicy(), + runtime_plugins=[], + ) + assert env.policy is not None + assert env.runtime_plugins == [] + + +# --------------------------------------------------------------------------- +# DeterministicResult dataclass +# --------------------------------------------------------------------------- + + +class TestDeterministicResult: + """Verify the DeterministicResult dataclass structure.""" + + def test_has_required_fields(self): + field_names = {f.name for f in dc_fields(DeterministicResult)} + assert "graph" in field_names + assert "graph_result" in field_names + assert "dead_code_result" in field_names + assert "hardwiring_result" in field_names + assert "unsupported_breakdown" in field_names + + def test_can_construct(self): + result = DeterministicResult( + graph=MagicMock(), + graph_result=MagicMock(), + dead_code_result=MagicMock(), + hardwiring_result=MagicMock(), + unsupported_breakdown={}, + ) + assert result.graph is not None + assert result.unsupported_breakdown == {} + + +# --------------------------------------------------------------------------- +# resolve_runtime_environment — signature + mock +# --------------------------------------------------------------------------- + + +class TestResolveRuntimeEnvironment: + """Test resolve_runtime_environment with mocks.""" + + def test_signature(self): + sig = inspect.signature(resolve_runtime_environment) + assert "config" in sig.parameters + + @patch("aigiscode.orchestration.load_external_plugins") + @patch("aigiscode.orchestration.resolve_policy") + def test_returns_runtime_environment(self, mock_resolve_policy, mock_load_plugins): + from aigiscode.models import AigisCodeConfig + from aigiscode.policy.models import AnalysisPolicy + + mock_resolve_policy.return_value = AnalysisPolicy( + plugins_applied=["generic"], + ) + mock_load_plugins.return_value = [] + + config = AigisCodeConfig(project_path=Path("/tmp/fake")) + env = resolve_runtime_environment(config) + + assert isinstance(env, RuntimeEnvironment) + assert env.policy.plugins_applied == ["generic"] + assert env.runtime_plugins == [] + + +# --------------------------------------------------------------------------- +# run_deterministic_analysis — signature + mock +# --------------------------------------------------------------------------- + + +class TestRunDeterministicAnalysis: + """Test run_deterministic_analysis with mocks.""" + + def test_signature(self): + sig = inspect.signature(run_deterministic_analysis) + params = set(sig.parameters.keys()) + assert "config" in params + assert "store" in params + assert "policy" in params + assert "runtime_plugins" in params + + @patch("aigiscode.orchestration.discover_unsupported_source_files") + @patch("aigiscode.orchestration.apply_hardwiring_result_plugins") + @patch("aigiscode.orchestration.apply_dead_code_result_plugins") + @patch("aigiscode.orchestration.apply_graph_result_plugins") + @patch("aigiscode.orchestration.analyze_hardwiring") + @patch("aigiscode.orchestration.analyze_dead_code") + @patch("aigiscode.orchestration.analyze_graph") + @patch("aigiscode.orchestration.build_file_graph") + def test_returns_deterministic_result( + self, + mock_build_graph, + mock_analyze_graph, + mock_dead_code, + mock_hardwiring, + mock_apply_graph, + mock_apply_dead, + mock_apply_hw, + mock_unsupported, + ): + from aigiscode.models import AigisCodeConfig, GraphAnalysisResult + from aigiscode.policy.models import AnalysisPolicy + + mock_graph = MagicMock() + mock_build_graph.return_value = mock_graph + mock_graph_result = GraphAnalysisResult() + mock_analyze_graph.return_value = mock_graph_result + mock_apply_graph.return_value = mock_graph_result + mock_dc = MagicMock() + mock_dead_code.return_value = mock_dc + mock_apply_dead.return_value = mock_dc + mock_hw = MagicMock() + mock_hardwiring.return_value = mock_hw + mock_apply_hw.return_value = mock_hw + mock_unsupported.return_value = {"go": 5} + + config = AigisCodeConfig(project_path=Path("/tmp/fake")) + policy = AnalysisPolicy() + store = MagicMock() + + result = run_deterministic_analysis( + config=config, + store=store, + policy=policy, + runtime_plugins=[], + ) + + assert isinstance(result, DeterministicResult) + assert result.graph is mock_graph + assert result.graph_result is mock_graph_result + assert result.dead_code_result is mock_dc + assert result.hardwiring_result is mock_hw + assert result.unsupported_breakdown == {"go": 5} + + +# --------------------------------------------------------------------------- +# collect_external_analysis_for_report — signature + mock +# --------------------------------------------------------------------------- + + +class TestCollectExternalAnalysisForReport: + """Test collect_external_analysis_for_report with mocks.""" + + def test_signature(self): + sig = inspect.signature(collect_external_analysis_for_report) + params = set(sig.parameters.keys()) + assert "project_path" in params + assert "output_dir" in params + assert "run_id" in params + assert "selected_tools" in params + assert "existing_rules" in params + assert "ctx" in params + + @patch("aigiscode.orchestration.filter_external_findings") + @patch("aigiscode.orchestration.collect_external_analysis") + def test_returns_tuple(self, mock_collect, mock_filter): + from aigiscode.models import ExternalAnalysisResult, ExternalFinding + + finding = ExternalFinding(tool="ruff", message="test") + raw = ExternalAnalysisResult(findings=[finding]) + mock_collect.return_value = raw + mock_filter.return_value = (raw.findings, 0) + + result, excluded = collect_external_analysis_for_report( + project_path=Path("/tmp/fake"), + output_dir=Path("/tmp/fake/.aigiscode"), + run_id="20260315_120000", + selected_tools=["ruff"], + existing_rules=[], + ctx=None, + ) + assert isinstance(result, ExternalAnalysisResult) + assert excluded == 0 + + +# --------------------------------------------------------------------------- +# build_report_data — signature + mock +# --------------------------------------------------------------------------- + + +class TestBuildReportData: + """Test build_report_data with mocks.""" + + def test_signature(self): + sig = inspect.signature(build_report_data) + params = set(sig.parameters.keys()) + assert "store" in params + assert "project_path" in params + assert "generated_at" in params + assert "graph" in params + assert "graph_result" in params + assert "dead_code_result" in params + assert "hardwiring_result" in params + assert "review_result" in params + assert "policy" in params + + @patch("aigiscode.orchestration.build_report_extensions") + def test_returns_report_data(self, mock_extensions): + from aigiscode.models import GraphAnalysisResult, ReportData + from aigiscode.policy.models import AnalysisPolicy + + mock_extensions.return_value = {} + store = MagicMock() + store.get_file_count.return_value = 10 + store.get_symbol_count.return_value = 50 + store.get_dependency_count.return_value = 30 + store.get_language_breakdown.return_value = {"python": 8, "php": 2} + + report = build_report_data( + store=store, + project_path=Path("/tmp/fake"), + generated_at=datetime(2026, 3, 15), + graph=MagicMock(), + graph_result=GraphAnalysisResult(), + dead_code_result=MagicMock(total=5), + hardwiring_result=MagicMock(total=3), + review_result=None, + security_review_result=None, + external_analysis=None, + runtime_plugins=[], + policy=AnalysisPolicy(), + unsupported_breakdown={"go": 2}, + synthesis_text="summary", + envelopes_generated=7, + ) + assert isinstance(report, ReportData) + assert report.files_indexed == 10 + assert report.symbols_extracted == 50 + assert report.dependencies_found == 30 + assert report.synthesis == "summary" + assert report.envelopes_generated == 7 + assert report.unsupported_source_files == 2 + assert report.unsupported_language_breakdown == {"go": 2} + assert report.language_breakdown == {"python": 8, "php": 2} diff --git a/tests/test_parser.py b/tests/test_parser.py index 1f5fb6c..f08d6a8 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -2,16 +2,21 @@ from pathlib import Path -from aigiscode.indexer.parser import discover_unsupported_source_files, parse_file +from aigiscode.indexer.parser import ( + discover_files, + discover_unsupported_source_files, + parse_file, +) from aigiscode.models import AigisCodeConfig, DependencyType, Language -def test_discover_unsupported_source_files_excludes_supported_python( +def test_discover_unsupported_source_files_excludes_supported_languages( tmp_path: Path, ) -> None: project_root = tmp_path / "project" project_root.mkdir() (project_root / "app.py").write_text("print('hi')\n", encoding="utf-8") + (project_root / "main.rs").write_text("fn main() {}\n", encoding="utf-8") (project_root / "nested").mkdir() (project_root / "nested" / "script.ts").write_text( "export const x = 1;\n", encoding="utf-8" @@ -29,6 +34,22 @@ def test_discover_unsupported_source_files_excludes_supported_python( assert breakdown == {"go": 1} +def test_discover_files_excludes_custom_output_dir_inside_project(tmp_path: Path) -> None: + project_root = tmp_path / "project" + project_root.mkdir() + (project_root / "app.py").write_text("print('hi')\n", encoding="utf-8") + (project_root / "main.rs").write_text("fn main() {}\n", encoding="utf-8") + output_dir = project_root / "reports" / "aigiscode" + output_dir.mkdir(parents=True) + (output_dir / "generated.py").write_text("print('ignore')\n", encoding="utf-8") + + files = discover_files( + AigisCodeConfig(project_path=project_root, output_dir=output_dir) + ) + + assert [path.name for path in files] == ["app.py", "main.rs"] + + def test_parse_file_extracts_python_symbols_and_dependencies(tmp_path: Path) -> None: project_root = tmp_path / "project" file_path = project_root / "pkg" / "service.py" @@ -191,6 +212,58 @@ def test_parse_file_extracts_ruby_symbols_and_dependencies(tmp_path: Path) -> No ) +def test_parse_file_extracts_rust_symbols_and_dependencies(tmp_path: Path) -> None: + project_root = tmp_path / "project" + file_path = project_root / "src" / "lib.rs" + file_path.parent.mkdir(parents=True) + file_path.write_text( + "use crate::http::{Client, Server};\n" + "mod inner;\n" + "pub struct Service { cache: Cache }\n" + "enum Kind { A, B }\n" + "trait Runner { fn run(&self); }\n" + "impl Runner for Service {}\n" + "impl Service { pub fn run(&self) {} }\n" + "fn helper() { let _ = Client::new(); }\n", + encoding="utf-8", + ) + + symbols, dependencies = parse_file( + file_path, + Language.RUST, + project_root=project_root, + ) + + assert {symbol.name for symbol in symbols} >= { + "inner", + "Service", + "cache", + "Kind", + "Runner", + "run", + "helper", + } + assert any( + symbol.name == "cache" + and symbol.namespace == "Service" + and symbol.visibility.value == "private" + for symbol in symbols + ) + assert any( + symbol.name == "run" + and symbol.namespace == "Service" + and symbol.visibility.value == "public" + for symbol in symbols + ) + assert any( + symbol.name == "helper" and symbol.visibility.value == "private" + for symbol in symbols + ) + assert any(dep.target_name == "crate::http::Client" for dep in dependencies) + assert any(dep.target_name == "crate::http::Server" for dep in dependencies) + assert any(dep.type == DependencyType.IMPLEMENT and dep.target_name == "Runner" for dep in dependencies) + + def test_parse_file_extracts_ts_private_hash_members(tmp_path: Path) -> None: project_root = tmp_path / "project" file_path = project_root / "resources" / "js" / "cache.ts" diff --git a/tests/test_report_archive.py b/tests/test_report_archive.py new file mode 100644 index 0000000..95c737d --- /dev/null +++ b/tests/test_report_archive.py @@ -0,0 +1,106 @@ +"""Tests for allocate_archive_stem, archive_stem support in write_reports, and handoff files.""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path + +from aigiscode.models import GraphAnalysisResult, ReportData +from aigiscode.report.generator import allocate_archive_stem, write_reports + + +def _minimal_report(generated_at: datetime | None = None) -> ReportData: + """Return a minimal ReportData for testing.""" + return ReportData( + project_path="/tmp/test-project", + generated_at=generated_at or datetime(2026, 3, 15, 10, 0, 0), + files_indexed=5, + symbols_extracted=10, + dependencies_found=3, + graph_analysis=GraphAnalysisResult(), + ) + + +# --- allocate_archive_stem --- + + +def test_allocate_archive_stem_returns_timestamp_when_no_conflict(tmp_path: Path) -> None: + stem = allocate_archive_stem(tmp_path, "20260315_100000") + assert stem == "20260315_100000" + + +def test_allocate_archive_stem_appends_counter_on_conflict(tmp_path: Path) -> None: + reports_dir = tmp_path / "reports" + (reports_dir / "20260315_100000").mkdir(parents=True) + stem = allocate_archive_stem(tmp_path, "20260315_100000") + assert stem == "20260315_100000_1" + + +def test_allocate_archive_stem_increments_counter_multiple_conflicts(tmp_path: Path) -> None: + reports_dir = tmp_path / "reports" + (reports_dir / "20260315_100000").mkdir(parents=True) + (reports_dir / "20260315_100000_1").mkdir(parents=True) + (reports_dir / "20260315_100000_2").mkdir(parents=True) + stem = allocate_archive_stem(tmp_path, "20260315_100000") + assert stem == "20260315_100000_3" + + +# --- write_reports with archive_stem --- + + +def test_write_reports_with_archive_stem_creates_subdirectory(tmp_path: Path) -> None: + report = _minimal_report() + md_path, json_path = write_reports(report, tmp_path, archive_stem="20260315_100000") + + # Main files still at root + assert md_path == tmp_path / "aigiscode-report.md" + assert json_path == tmp_path / "aigiscode-report.json" + assert md_path.exists() + assert json_path.exists() + + # Archive files in stem subdirectory + stem_dir = tmp_path / "reports" / "20260315_100000" + assert stem_dir.is_dir() + assert (stem_dir / "aigiscode-report.md").exists() + assert (stem_dir / "aigiscode-report.json").exists() + + +def test_write_reports_without_archive_stem_uses_flat_timestamp(tmp_path: Path) -> None: + generated_at = datetime(2026, 3, 15, 10, 0, 0) + report = _minimal_report(generated_at=generated_at) + md_path, json_path = write_reports(report, tmp_path) + + # Flat archive files (no subdirectory) + assert (tmp_path / "reports" / "20260315_100000-aigiscode-report.md").exists() + assert (tmp_path / "reports" / "20260315_100000-aigiscode-report.json").exists() + + +# --- handoff files --- + + +def test_write_reports_creates_handoff_files(tmp_path: Path) -> None: + report = _minimal_report() + write_reports(report, tmp_path) + + handoff_md = tmp_path / "aigiscode-handoff.md" + handoff_json = tmp_path / "aigiscode-handoff.json" + assert handoff_md.exists() + assert handoff_json.exists() + + # Verify JSON contents + data = json.loads(handoff_json.read_text(encoding="utf-8")) + assert data["project_path"] == "/tmp/test-project" + assert data["files_indexed"] == 5 + assert data["symbols_extracted"] == 10 + assert data["circular_dependencies"] == 0 + assert data["god_classes"] == 0 + assert data["layer_violations"] == 0 + assert data["dead_code_total"] == 0 + assert data["hardwiring_total"] == 0 + + # Verify markdown contents + md_text = handoff_md.read_text(encoding="utf-8") + assert "# AigisCode Handoff" in md_text + assert "/tmp/test-project" in md_text + assert "Files: 5" in md_text diff --git a/tests/test_report_generator.py b/tests/test_report_generator.py index a09f3b5..d0d0e81 100644 --- a/tests/test_report_generator.py +++ b/tests/test_report_generator.py @@ -1,7 +1,15 @@ from __future__ import annotations -from aigiscode.models import GraphAnalysisResult, ReportData -from aigiscode.report.generator import generate_json_report, generate_markdown_report +from datetime import datetime +from pathlib import Path + +from aigiscode.graph.hardwiring import HardwiringFinding, HardwiringResult +from aigiscode.models import FindingVerdict, GraphAnalysisResult, ReportData, ReviewResult +from aigiscode.report.generator import ( + generate_json_report, + generate_markdown_report, + write_reports, +) def test_report_includes_detector_coverage_warning() -> None: @@ -24,3 +32,115 @@ def test_report_includes_detector_coverage_warning() -> None: "dead_code": ["python"], "hardwiring": ["python"], } + + +def test_report_preserves_graph_analysis_contract() -> None: + report = ReportData( + project_path="/tmp/project", + graph_analysis=GraphAnalysisResult( + circular_dependencies=[["a.py", "b.py"]], + strong_circular_dependencies=[["a.py", "b.py"]], + orphan_files=["unused.py"], + ), + ) + + payload = generate_json_report(report) + + assert payload["graph_analysis"]["strong_circular_dependencies"] == [ + {"cycle": ["a.py", "b.py"]} + ] + assert payload["graph_analysis"]["orphan_files"] == ["unused.py"] + assert "bottleneck_files" in payload["graph_analysis"] + assert payload["strong_circular_dependencies"] == [ + {"cycle": ["a.py", "b.py"]} + ] + + +def test_report_includes_security_summary_and_archives(tmp_path: Path) -> None: + generated_at = datetime(2026, 3, 9, 12, 30, 45) + hardwiring = HardwiringResult( + hardcoded_network=[ + HardwiringFinding( + file_path="app/service.py", + line=12, + category="hardcoded_ip_url", + value="https://api.example.com/token", + context="TOKEN_URL = 'https://api.example.com/token'", + severity="high", + confidence="high", + suggestion="Move endpoint to config.", + ) + ], + env_outside_config=[ + HardwiringFinding( + file_path="app/runtime.py", + line=5, + category="env_outside_config", + value="API_KEY", + context="API_KEY = os.getenv('API_KEY')", + severity="medium", + confidence="high", + suggestion="Read env in config bootstrap only.", + ) + ], + ) + review = ReviewResult( + total_reviewed=2, + true_positives=1, + verdicts=[ + FindingVerdict( + file_path="app/service.py", + line=12, + category="hardcoded_ip_url", + value="https://api.example.com/token", + verdict="true_positive", + reason="Runtime endpoint is hardcoded.", + ) + ], + ) + report = ReportData( + project_path="/tmp/project", + generated_at=generated_at, + files_indexed=2, + symbols_extracted=4, + dependencies_found=1, + graph_analysis=GraphAnalysisResult(), + hardwiring=hardwiring, + review=review, + ) + + markdown = generate_markdown_report(report) + payload = generate_json_report(report) + md_path, json_path = write_reports(report, tmp_path) + + assert "## Security Analysis" in markdown + assert "Hardcoded network endpoints" in markdown + assert payload["security"] == { + "total_findings": 2, + "hardcoded_network": 1, + "env_outside_config": 1, + "high_severity": 1, + "ai_confirmed": 1, + "top_findings": [ + { + "file": "app/service.py", + "line": 12, + "category": "hardcoded_ip_url", + "value": "https://api.example.com/token", + "severity": "high", + "confidence": "high", + }, + { + "file": "app/runtime.py", + "line": 5, + "category": "env_outside_config", + "value": "API_KEY", + "severity": "medium", + "confidence": "high", + }, + ], + } + assert md_path == tmp_path / "aigiscode-report.md" + assert json_path == tmp_path / "aigiscode-report.json" + assert (tmp_path / "reports" / "20260309_123045-aigiscode-report.md").exists() + assert (tmp_path / "reports" / "20260309_123045-aigiscode-report.json").exists() diff --git a/tests/test_rules_external.py b/tests/test_rules_external.py new file mode 100644 index 0000000..0ea4940 --- /dev/null +++ b/tests/test_rules_external.py @@ -0,0 +1,25 @@ +"""Tests for external finding filtering in the rules engine.""" +from aigiscode.rules.engine import filter_external_findings + + +def test_filter_external_findings_no_rules(): + findings = [{"tool": "ruff", "file_path": "test.py"}] + result, excluded = filter_external_findings(findings, []) + assert result == findings + assert excluded == 0 + + +def test_filter_external_findings_no_findings(): + result, excluded = filter_external_findings([], []) + assert result == [] + assert excluded == 0 + + +def test_filter_external_findings_passthrough(): + """With rules but no matching logic yet, all findings pass through.""" + from aigiscode.rules.engine import Rule + rules = [Rule(id="r1", category="security", checks=[], reason="test")] + findings = [{"tool": "ruff"}] + result, excluded = filter_external_findings(findings, rules) + assert result == findings + assert excluded == 0 diff --git a/tests/test_security_reviewer.py b/tests/test_security_reviewer.py new file mode 100644 index 0000000..daa7942 --- /dev/null +++ b/tests/test_security_reviewer.py @@ -0,0 +1,76 @@ +"""Tests for the security finding reviewer.""" +import asyncio +from unittest.mock import AsyncMock, patch + +from aigiscode.models import ( + ExternalAnalysisResult, + ExternalFinding, + ExternalToolRun, + ReviewResult, +) +from aigiscode.review.security_reviewer import review_external_security_findings + + +def test_review_empty_findings(): + analysis = ExternalAnalysisResult() + result, rules = asyncio.run( + review_external_security_findings( + analysis, + project_path="/tmp/test", + project_type="test project", + review_model="test-model", + primary_backend="codex", + allow_claude_fallback=False, + ) + ) + assert isinstance(result, ReviewResult) + assert result.total_reviewed == 0 + assert rules == [] + + +@patch("aigiscode.review.security_reviewer.generate_text") +def test_review_with_findings_no_backend(mock_gen): + mock_gen.return_value = (None, "none") + analysis = ExternalAnalysisResult( + findings=[ + ExternalFinding( + tool="ruff", + rule_id="S101", + file_path="app/main.py", + line=10, + message="Use of assert", + domain="security", + ) + ], + tool_runs=[ExternalToolRun(tool="ruff", command=["ruff"], status="success")], + ) + result, rules = asyncio.run( + review_external_security_findings( + analysis, + project_path="/tmp/test", + project_type="test project", + review_model="test-model", + primary_backend="codex", + allow_claude_fallback=False, + ) + ) + assert isinstance(result, ReviewResult) + assert result.total_reviewed == 1 + assert result.needs_context == 1 + + +@patch("aigiscode.review.security_reviewer.generate_text") +def test_review_parses_ai_response(mock_gen): + mock_gen.return_value = ('{"verdicts": [{"index": 0, "verdict": "true_positive", "reason": "real issue"}]}', "codex_sdk") + analysis = ExternalAnalysisResult( + findings=[ + ExternalFinding(tool="ruff", rule_id="S101", file_path="app/main.py", line=10, message="assert", domain="security") + ], + ) + result, rules = asyncio.run( + review_external_security_findings( + analysis, project_path="/tmp/test", + ) + ) + assert result.true_positives == 1 + assert result.actionable == 1 diff --git a/tests/test_synthesis_sig.py b/tests/test_synthesis_sig.py new file mode 100644 index 0000000..eb9f60c --- /dev/null +++ b/tests/test_synthesis_sig.py @@ -0,0 +1,8 @@ +import inspect + +from aigiscode.synthesis.claude import synthesize + + +def test_synthesize_accepts_primary_backend(): + sig = inspect.signature(synthesize) + assert "primary_backend" in sig.parameters diff --git a/website/index.html b/website/index.html new file mode 100644 index 0000000..924b635 --- /dev/null +++ b/website/index.html @@ -0,0 +1,86 @@ + + + + + + + + + + + AigisCode — AI-Powered Code Guardian + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/website/package-lock.json b/website/package-lock.json new file mode 100644 index 0000000..b365851 --- /dev/null +++ b/website/package-lock.json @@ -0,0 +1,2623 @@ +{ + "name": "aigiscode-website", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aigiscode-website", + "version": "1.0.0", + "dependencies": { + "@phosphor-icons/react": "^2.1.10", + "@tailwindcss/vite": "^4.1.14", + "@vitejs/plugin-react": "^5.0.4", + "animejs": "^4.3.6", + "i18next": "^25.8.13", + "i18next-browser-languagedetector": "^8.2.1", + "motion": "^12.23.24", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-i18next": "^16.5.4", + "react-router-dom": "^7.13.0", + "vite": "^6.2.0" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "autoprefixer": "^10.4.21", + "tailwindcss": "^4.1.14", + "typescript": "~5.8.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@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.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@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.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "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.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "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==", + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@phosphor-icons/react": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz", + "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">= 16.8", + "react-dom": ">= 16.8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", + "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/animejs": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/animejs/-/animejs-4.3.6.tgz", + "integrity": "sha512-rzZ4bDc8JAtyx6hYwxj7s5M/yWfnM5qqY4hZDnhy1cWFvMb6H5/necHS2sbCY3WQTDbRLuZL10dPXSxSCFOr/w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/juliangarnier" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "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.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", + "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/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==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "12.35.1", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.35.1.tgz", + "integrity": "sha512-rL8cLrjYZNShZqKV3U0Qj6Y5WDiZXYEM5giiTLfEqsIZxtspzMDCkKmrO5po76jWfvOg04+Vk+sfBvTD0iMmLw==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.35.1", + "motion-utils": "^12.29.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "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==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "25.8.14", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.14.tgz", + "integrity": "sha512-paMUYkfWJMsWPeE/Hejcw+XLhHrQPehem+4wMo+uELnvIwvCG019L9sAIljwjCmEMtFQQO3YeitJY8Kctei3iA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "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.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "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.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/motion": { + "version": "12.35.1", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.35.1.tgz", + "integrity": "sha512-yEt/49kWC0VU/IEduDfeZw82eDemlPwa1cyo/gcEEUCN4WgpSJpUcxz6BUwakGabvJiTzLQ58J73515I5tfykQ==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.35.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.35.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.35.1.tgz", + "integrity": "sha512-7n6r7TtNOsH2UFSAXzTkfzOeO5616v9B178qBIjmu/WgEyJK0uqwytCEhwKBTuM/HJA40ptAw7hLFpxtPAMRZQ==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.29.2" + } + }, + "node_modules/motion-utils": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz", + "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==", + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "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==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-i18next": { + "version": "16.5.6", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.6.tgz", + "integrity": "sha512-Ua7V2/efA88ido7KyK51fb8Ki8M/sRfW8LR/rZ/9ZKr2luhuTI7kwYZN5agT1rWG7aYm5G0RYE/6JR8KJoCMDw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", + "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "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==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "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/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + } + } +} diff --git a/website/package.json b/website/package.json new file mode 100644 index 0000000..c5b0b00 --- /dev/null +++ b/website/package.json @@ -0,0 +1,35 @@ +{ + "name": "aigiscode-website", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite --port=3000 --host=0.0.0.0", + "build": "vite build", + "preview": "vite preview", + "clean": "rm -rf dist", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@phosphor-icons/react": "^2.1.10", + "@tailwindcss/vite": "^4.1.14", + "@vitejs/plugin-react": "^5.0.4", + "animejs": "^4.3.6", + "i18next": "^25.8.13", + "i18next-browser-languagedetector": "^8.2.1", + "motion": "^12.23.24", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-i18next": "^16.5.4", + "react-router-dom": "^7.13.0", + "vite": "^6.2.0" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "autoprefixer": "^10.4.21", + "tailwindcss": "^4.1.14", + "typescript": "~5.8.2" + } +} diff --git a/website/public/favicon.svg b/website/public/favicon.svg new file mode 100644 index 0000000..13a280b --- /dev/null +++ b/website/public/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/website/public/robots.txt b/website/public/robots.txt new file mode 100644 index 0000000..d736119 --- /dev/null +++ b/website/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://aigiscode.com/sitemap.xml diff --git a/website/public/sitemap.xml b/website/public/sitemap.xml new file mode 100644 index 0000000..ebc18fe --- /dev/null +++ b/website/public/sitemap.xml @@ -0,0 +1,15 @@ + + + + https://aigiscode.com/ + 2026-03-08 + weekly + 1.0 + + + + + + + diff --git a/website/src/App.tsx b/website/src/App.tsx new file mode 100644 index 0000000..011c9c4 --- /dev/null +++ b/website/src/App.tsx @@ -0,0 +1,15 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import Layout from './components/Layout'; +import HomePage from './pages/HomePage'; + +export default function App() { + return ( + + + }> + } /> + + + + ); +} diff --git a/website/src/components/Footer.tsx b/website/src/components/Footer.tsx new file mode 100644 index 0000000..f33f534 --- /dev/null +++ b/website/src/components/Footer.tsx @@ -0,0 +1,101 @@ +import { useTranslation } from 'react-i18next'; +import { GithubLogo, XLogo, ShieldCheck } from '@phosphor-icons/react'; + +export default function Footer() { + const { t } = useTranslation(); + const year = new Date().getFullYear(); + + const columns = [ + { + title: t('footer.product'), + links: [ + { label: t('footer.features'), href: '#features' }, + { label: t('footer.docs'), href: '#get-started' }, + { label: t('footer.changelog'), href: 'https://github.com/david-strejc/aigiscode/releases' }, + ], + }, + { + title: t('footer.community'), + links: [ + { label: 'GitHub', href: 'https://github.com/david-strejc/aigiscode' }, + { label: t('footer.contributing'), href: 'https://github.com/david-strejc/aigiscode/blob/main/CONTRIBUTING.md' }, + { label: t('footer.issues'), href: 'https://github.com/david-strejc/aigiscode/issues' }, + ], + }, + { + title: t('footer.legal'), + links: [ + { label: t('footer.license'), href: 'https://github.com/david-strejc/aigiscode/blob/main/LICENSE' }, + { label: t('footer.codeOfConduct'), href: 'https://github.com/david-strejc/aigiscode/blob/main/CODE_OF_CONDUCT.md' }, + ], + }, + ]; + + return ( +
+
+ {/* Top: Logo + Social */} +
+
+
+ + + Aigis + Code + +
+

{t('footer.tagline')}

+
+ +
+ + {/* 3-Column Grid */} +
+ {columns.map((col) => ( + + ))} +
+ + {/* Bottom */} +
+

© {year} AigisCode. {t('footer.rights')}

+

{t('footer.motto')}

+
+
+
+ ); +} diff --git a/website/src/components/Layout.tsx b/website/src/components/Layout.tsx new file mode 100644 index 0000000..f83dce9 --- /dev/null +++ b/website/src/components/Layout.tsx @@ -0,0 +1,41 @@ +import { useState, useEffect } from 'react'; +import { Outlet } from 'react-router-dom'; +import { IconContext } from '@phosphor-icons/react'; +import Navbar from './Navbar'; +import Footer from './Footer'; + +export default function Layout() { + const [isDark, setIsDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches); + + useEffect(() => { + if (isDark) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + }, [isDark]); + + const toggleTheme = () => setIsDark(!isDark); + + return ( + +
+ Skip to main content + +