commit 0adf8ef5d9d5ec1a7dc9d03c0548c2e1a3d1ce50 Author: Public Source Publisher Date: Thu Oct 1 01:34:52 2026 +0200 Initial curated RepoPulse source from 2524ccc3c1c27739996dccb2b7992989f88cd26d diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a5a54e9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.gitea +.env +.venv +__pycache__ +.pytest_cache +node_modules +frontend/node_modules +frontend/dist +data +*.db +*.sqlite* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fbabd2e --- /dev/null +++ b/.env.example @@ -0,0 +1,40 @@ +# RepoPulse UI port. The registered Unraid deployment publishes 1254 -> 8000. +REPOPULSE_PORT=1254 + +# Directory containing repositories. From a repository checkout, '..' scans sibling repositories. +# On the Unraid deploy runner this resolves to /mnt/cache/appdata/gitea-deploy-runners/sources. +REPOPULSE_SOURCE_ROOT=.. + +# Local Compose uses a checkout-relative bind mount. The production Compose +# deployment defaults to the named `repopulse_data` volume when this is unset, +# keeping SQLite state outside the exact-source tree that deployments replace. +REPOPULSE_DATA_ROOT=./data + +# Keep only one repository scan active per application process by default. +REPOPULSE_MAX_CONCURRENT_SCANS=1 +REPOPULSE_JOB_LEASE_SECONDS=900 + +# Transparent bounded analysis limits. Values are clamped by the backend. +REPOPULSE_SCAN_MAX_FILES=10000 +REPOPULSE_SCAN_MAX_TEXT_BYTES=2000000 +REPOPULSE_SCAN_MAX_TOTAL_TEXT_BYTES=50000000 +REPOPULSE_SCAN_MAX_FINDINGS=2000 +REPOPULSE_SCAN_MAX_SECRET_FINDINGS=20 + +# Retain compact history while limiting detailed report payload retention. +REPOPULSE_HISTORY_PER_REPOSITORY=200 +REPOPULSE_FULL_REPORTS_PER_REPOSITORY=30 +REPOPULSE_REPORT_RETENTION_DAYS=365 + +# Optional server-side Gitea connector. Never put this token in frontend code. +REPOPULSE_GITEA_URL= +REPOPULSE_GITEA_TOKEN= +REPOPULSE_GITEA_BRANCH_WORKERS=4 +REPOPULSE_GITEA_WEBHOOK_SECRET= +REPOPULSE_GITEA_SYNC_INTERVAL_SECONDS=300 +REPOPULSE_REPOSITORY_CACHE=/data/repos +REPOPULSE_MAX_QUEUED_SCANS=100 + +# Optional mutation boundary for deployments that are not already protected by +# an authenticated reverse proxy. Send the same value as X-RepoPulse-Token. +REPOPULSE_OPERATOR_TOKEN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..097a45a --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +.pytest_cache/ +.ruff_cache/ + +# Node +node_modules/ +dist/ +.vite/ +test-results/ +playwright-report/ + +# Local scan state +*.db +*.sqlite +*.sqlite3 +data/*.bak +.env +.env.* +!.env.example + +# IDE / OS +.idea/ +.vscode/ +.DS_Store +Thumbs.db diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..ede93a9 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,9 @@ +[extend] +useDefault = true + +[[allowlists]] +description = "Intentional credential detector fixture; not an operational credential" +condition = "AND" +paths = ['backend/tests/test_signals_v11\.py$'] +regexTarget = "line" +regexes = ['abcdefghijk1234'] diff --git a/Dockerfile.unraid b/Dockerfile.unraid new file mode 100644 index 0000000..921b3c6 --- /dev/null +++ b/Dockerfile.unraid @@ -0,0 +1,29 @@ +FROM node:22-alpine AS frontend-build + +WORKDIR /frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci --no-audit --no-fund +COPY frontend/ ./ +RUN npm run build + +FROM python:3.12-slim + +WORKDIR /app +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + REPOPULSE_DB=/data/repopulse.db \ + REPOPULSE_SCAN_ROOT=/scan \ + REPOPULSE_STATIC_DIR=/app/static + +RUN apt-get update \ + && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* + +COPY backend/pyproject.toml ./pyproject.toml +COPY backend/app ./app +RUN pip install --no-cache-dir . +COPY --from=frontend-build /frontend/dist ./static + +VOLUME ["/data", "/scan"] +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md new file mode 100644 index 0000000..145102a --- /dev/null +++ b/PUBLIC_SOURCE_EXPORT.md @@ -0,0 +1,3 @@ +# Public source export + +This source snapshot was generated from the private canonical repository at revision `2524ccc3c1c27739996dccb2b7992989f88cd26d`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json new file mode 100644 index 0000000..98c8280 --- /dev/null +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -0,0 +1,446 @@ +{ + "schemaVersion": 1, + "sourceRevision": "2524ccc3c1c27739996dccb2b7992989f88cd26d", + "files": [ + { + "path": ".dockerignore", + "sha256": "fcd8146da6fd8900a8513541c5925d3a3efc71a7a0670e5f7fd0ccf947899bd4", + "bytes": 130 + }, + { + "path": ".env.example", + "sha256": "c42f86ef0802bff819144099a69f89c678905bd4af4c9da9b9e801b3bc06943f", + "bytes": 1694 + }, + { + "path": ".gitignore", + "sha256": "0e9ad55f2380d7bb57d8c72c99d79362a837eea9efb2c8c9530e7118cd94d9cb", + "bytes": 292 + }, + { + "path": ".gitleaks.toml", + "sha256": "14d325c51a68f2153ea45af0ef2b6aff7cd503c5540f4197d774d7d3db268495", + "bytes": 256 + }, + { + "path": "Dockerfile.unraid", + "sha256": "3d4e6da865479357527d88e2d766890d86a3c1877de7a004a037e5ef2cd139b0", + "bytes": 788 + }, + { + "path": "README.md", + "sha256": "daf72438ac9243a092c38f5d3b8d7131245142d4a800dae91ac84fe874ab4814", + "bytes": 5271 + }, + { + "path": "backend/Dockerfile", + "sha256": "b5ebf1f12895c50890cb533e09a7e213c2331717027821a2545f4fd0330403c7", + "bytes": 505 + }, + { + "path": "backend/app/__init__.py", + "sha256": "de10a39a27b9866c1f2ee16fadc50be1002d3edb151fdf74a1c7eb9e0f129a08", + "bytes": 34 + }, + { + "path": "backend/app/acquisition.py", + "sha256": "7c2cc8cb7b8ce7e14d47398b18135f30d486bd0bcfe28b0da8707cc5058b0f3b", + "bytes": 11052 + }, + { + "path": "backend/app/architecture.py", + "sha256": "37fc9680b7ae27638c88552696e9692dcdff180ab8b4b0dfe322acad60179161", + "bytes": 13252 + }, + { + "path": "backend/app/git_analysis.py", + "sha256": "e5fc4778c7288884837d3cb89ca74046308b70432f72742174b5bb2afdcb19ce", + "bytes": 3864 + }, + { + "path": "backend/app/gitea.py", + "sha256": "af5bc71c699c428b0c79a0c57ddda919838dbd37e2aa1c931b0e0c9ea4ce0061", + "bytes": 14276 + }, + { + "path": "backend/app/jobs.py", + "sha256": "0d845ff11cd5fc32fa7350eafeb4757a17d403e70bcc1312a295dc2793263dca", + "bytes": 12780 + }, + { + "path": "backend/app/main.py", + "sha256": "897a75de65118c978e2d861f535be2ac269ccb2bd81b658af54a44eb5736c352", + "bytes": 19644 + }, + { + "path": "backend/app/models.py", + "sha256": "6ac96bb4cf25d9e5851bc811569d5e896587c502b58b798aaf494b8fc3a46edd", + "bytes": 11240 + }, + { + "path": "backend/app/policy.py", + "sha256": "99d53303ae7bff2156bd748059885ce2621b651a96bb10b8b7f1783d01c88469", + "bytes": 3282 + }, + { + "path": "backend/app/release_gate.py", + "sha256": "1f17f8863956b6a52f880c20314bd8a5e2df2685494c4675b732873ef35cfca8", + "bytes": 5772 + }, + { + "path": "backend/app/reporting.py", + "sha256": "8e9e0e77df8c3950559d353021a6ac0622e4da2dd2c5a9e03c2ab8c80ef6938f", + "bytes": 5359 + }, + { + "path": "backend/app/rules.py", + "sha256": "031c62ba9875dfa2a371dfc8dae9b40890eae446a725b587854128e6fbe49242", + "bytes": 3484 + }, + { + "path": "backend/app/scanner.py", + "sha256": "9dfc2de46b49168d73bd9afeae30c104addd783eb1f310fc5dfcedc4434430e0", + "bytes": 26442 + }, + { + "path": "backend/app/signals.py", + "sha256": "c83e017a5a7c750c9ece3d53db2671a895c5d563fde5a9d2451381e633e822a6", + "bytes": 20351 + }, + { + "path": "backend/app/store.py", + "sha256": "01782cd4f171446b8e53a993cf8ef4b697941f4681d29fd2547de7f9410b5fac", + "bytes": 70318 + }, + { + "path": "backend/pyproject.toml", + "sha256": "2cf2e3f4fbf41cbc679de9ffe395109a40a8703a19e1095993d90385fb04d84f", + "bytes": 469 + }, + { + "path": "backend/tests/test_api.py", + "sha256": "c2b7a9c3c6b65e957a7aa55d4fa1aa5bca1ee24b9a7cea888346ad51523ef57f", + "bytes": 4895 + }, + { + "path": "backend/tests/test_api_v11.py", + "sha256": "c9d26544553a9282321d751c53c138b1f076e63ad1aefa37f3b5729a36e9c17c", + "bytes": 4940 + }, + { + "path": "backend/tests/test_architecture.py", + "sha256": "7ef1f1fbb92ead95e9e4be69a22b7d84a276bb5177a7f5929644bc290fa69437", + "bytes": 3749 + }, + { + "path": "backend/tests/test_gitea.py", + "sha256": "94e08c1ce45dc0a0c6890c51ede8e900f60a176b760fd3d27866265642b45629", + "bytes": 8220 + }, + { + "path": "backend/tests/test_jobs.py", + "sha256": "7e82af630918c523a1d9d6d0e64b924ff4bed335ce3a565ee76bb9150a8ba295", + "bytes": 9632 + }, + { + "path": "backend/tests/test_maintenance.py", + "sha256": "a83497cff1c9c4f06ec5da1e396bb2816974f36bfa0cbc7c77d63b8d7411c143", + "bytes": 7494 + }, + { + "path": "backend/tests/test_migrations.py", + "sha256": "e9bdf365e1c580b68e2f3f0426aed189869f7c8620b2eb17fdd36da96df0d76c", + "bytes": 2872 + }, + { + "path": "backend/tests/test_reporting.py", + "sha256": "6b91210461eb560a28515008fe35575f09fc12377fbf51bd430b6c760250ede3", + "bytes": 475 + }, + { + "path": "backend/tests/test_scanner.py", + "sha256": "8373156460d71990219cf0f73fa47ba7645c7643b874be0ab96ff09fc7b37630", + "bytes": 9852 + }, + { + "path": "backend/tests/test_signals.py", + "sha256": "c990c4002ee89c7fb0484fc2c54af75a0f8158b4f585dde8d4d0eb3ea1bb1842", + "bytes": 4351 + }, + { + "path": "backend/tests/test_signals_v11.py", + "sha256": "eaf0c0d852bbfa9cf58fa895c34fe7a30e6d44527bcb69c70e46432a45a31add", + "bytes": 3051 + }, + { + "path": "backend/tests/test_store.py", + "sha256": "c2594f5b86e0094434c16be28bf656c79320c0d216790cbc302b2057102ff09e", + "bytes": 6898 + }, + { + "path": "compose.yaml", + "sha256": "a168455adaf981152221e41e9efd57439c67df9838570ce22ae10afb08bada74", + "bytes": 3000 + }, + { + "path": "docker-compose.yml", + "sha256": "55077c9147f0d48a601b688bfd1a2690f3d252deae49811fb53a439cad814bb1", + "bytes": 2495 + }, + { + "path": "docs/ARCHITECTURE.md", + "sha256": "0f766e94aaeb1bc3b396599b549db2a82ffe882036c3a6f0fddab7ef36577f09", + "bytes": 13428 + }, + { + "path": "docs/screenshots/demo-explorer.png", + "sha256": "74d102b7ab96973a38e6c190f36fae7ffa546c14482f82c1e912ea2e03eac0e8", + "bytes": 183006 + }, + { + "path": "docs/screenshots/demo-portfolio.png", + "sha256": "95f22065e74ee3698e7f0ec9d8a6dbf0e4e37f7a9662353ca864febc459f5c6c", + "bytes": 785643 + }, + { + "path": "frontend/Dockerfile", + "sha256": "c22443a8c260bab6f73dfbb0db0fe84a63d0b05638bae07c0597b800409f297d", + "bytes": 320 + }, + { + "path": "frontend/e2e/mock-api.mjs", + "sha256": "d7d6edaa29df35e6bcb0607ee0371f7679dcfbce04f9d9d1c484a79a590f65cf", + "bytes": 8871 + }, + { + "path": "frontend/e2e/repopulse.spec.ts", + "sha256": "5676b76de5a4ac95ff7b619e43fa9fcc43ba0ab62946958ecead68570db3b28a", + "bytes": 2815 + }, + { + "path": "frontend/e2e/start-vite.mjs", + "sha256": "0189002d9a1da6ad5006f38d503588e419b2f4b0089ac59210a0da6532fd57b2", + "bytes": 462 + }, + { + "path": "frontend/index.html", + "sha256": "19bf66c0e00656b483b90ba07f616227aa9755a1109424b77c57dc5ff682493b", + "bytes": 668 + }, + { + "path": "frontend/nginx.conf", + "sha256": "c07a3ce7936beef0d3f124f18878b46c87693150e83cf571cebbf3f6f6635885", + "bytes": 520 + }, + { + "path": "frontend/package-lock.json", + "sha256": "43f9a45011d41977cca8eda3987ccbe2409f687db2e690105b01e90d9b088d9b", + "bytes": 116420 + }, + { + "path": "frontend/package.json", + "sha256": "c84753f99331918ce8f97115c7e6a800260ea15d774412fe468985a45cdde0c8", + "bytes": 754 + }, + { + "path": "frontend/playwright.config.ts", + "sha256": "f56a666f9b23c63e87add11d4c1dea994597a56c5e5a8f4d0a8d7dd0ced1934a", + "bytes": 1313 + }, + { + "path": "frontend/public/brand/repopulse-logo-light.svg", + "sha256": "86e97e67ec6371bf18400b0380d1a93880581ee06e3bc4cb8417caced1a4d414", + "bytes": 825 + }, + { + "path": "frontend/public/brand/repopulse-logo.svg", + "sha256": "a882e6d762a4019527b1e9cb8110afdd86549ca46e430507ca618cf67446765a", + "bytes": 825 + }, + { + "path": "frontend/public/brand/repopulse-mark-mono.svg", + "sha256": "140954a6d337e590adb0c8a8d455d34219f4776a26cb74b103fa43f451a03195", + "bytes": 426 + }, + { + "path": "frontend/public/brand/repopulse-mark.svg", + "sha256": "7492784108d6de42363ea2b767647d062eb45fd98020b8749e7123f5eb7ad293", + "bytes": 465 + }, + { + "path": "frontend/public/favicon.svg", + "sha256": "7492784108d6de42363ea2b767647d062eb45fd98020b8749e7123f5eb7ad293", + "bytes": 465 + }, + { + "path": "frontend/public/site.webmanifest", + "sha256": "5ced74fc20879c6f096730534aaf9331abe7b16a62c6274de548f976b2153977", + "bytes": 374 + }, + { + "path": "frontend/src/App.test.tsx", + "sha256": "108c3e2190aba3b7125e2c1911eb5b2fa708f95f989d2d25e6e32ee15c77199f", + "bytes": 5893 + }, + { + "path": "frontend/src/App.tsx", + "sha256": "83d9b1b0fe9d35b5ea75370105b80343aca7aecbe6f7dbd16cd103160e69c015", + "bytes": 46615 + }, + { + "path": "frontend/src/FindingsView.test.tsx", + "sha256": "86fdea7d89d3b0fa0e2f8edd93c98414d432b56fc7bc1b2a55e70bacea477ea6", + "bytes": 3996 + }, + { + "path": "frontend/src/OverviewView.test.tsx", + "sha256": "048e07c6691b6b8acd7623389d38298cc8c8cfba3169e188baf529c1a4b0eee4", + "bytes": 1612 + }, + { + "path": "frontend/src/api.test.ts", + "sha256": "361784096733107ed43281d0401baa208285de1edfd19ca62caaf3e85f3b474c", + "bytes": 2578 + }, + { + "path": "frontend/src/api.ts", + "sha256": "c74a07618e9fc27c60cad6707e481f87731423eb2265082cb9921f58bdff397e", + "bytes": 10726 + }, + { + "path": "frontend/src/catalog.test.ts", + "sha256": "7a1d9689a57a2cf9208f03770a86bf8c8ffc3bd0dfbbe4f42fd3c3d714d2a03e", + "bytes": 3201 + }, + { + "path": "frontend/src/catalog.ts", + "sha256": "97b3aa927261317aaceb2fc3072d42380c7968ca681011f7c967039d189ffce0", + "bytes": 5508 + }, + { + "path": "frontend/src/components/ArchitectureView.tsx", + "sha256": "c57e08a6ab7d8a60627ec57425b2f5c882d78ff9653a285b873e26016b4d528f", + "bytes": 4707 + }, + { + "path": "frontend/src/components/Brand.tsx", + "sha256": "2d97a7fec8d1ba55a19113a9d592a6c6b00378ab66c8f8028d259c8105031983", + "bytes": 1593 + }, + { + "path": "frontend/src/components/DashboardView.test.tsx", + "sha256": "32654d5c4db5475f451fd0fa90032d68b25d29d94909550aee9b3b19f8169b0b", + "bytes": 2404 + }, + { + "path": "frontend/src/components/DashboardView.tsx", + "sha256": "61548df575c977ac94deb81516c2e4281f9bb6eb52899689e7e5f8fdc781b9b7", + "bytes": 11005 + }, + { + "path": "frontend/src/components/DependenciesView.tsx", + "sha256": "cb0d4d855ba32794770c048e48c1788836dbbdc5cfcc7bc1fa19d301f02eca15", + "bytes": 1823 + }, + { + "path": "frontend/src/components/FindingsView.tsx", + "sha256": "16a6ebf6f19aa4141b0d025f5a517993ca90163965752287b079e5ca4b69b97d", + "bytes": 4358 + }, + { + "path": "frontend/src/components/HistoryView.tsx", + "sha256": "d17d7078d99de3103a6390927bf8daf0b096873df1eb253e24b80d18bcd2f592", + "bytes": 4304 + }, + { + "path": "frontend/src/components/HotspotsView.tsx", + "sha256": "829dc513d951744481ce2ddfa3ef9ef0478e16500bb1c486dd7dcbf24dbbbe52", + "bytes": 1714 + }, + { + "path": "frontend/src/components/OverviewView.tsx", + "sha256": "64d7fdd604f262418d3802e5a69ec4750e0c04c2a5cc310a73be2e2f8dec0072", + "bytes": 10146 + }, + { + "path": "frontend/src/components/PortfolioAtlas.test.tsx", + "sha256": "9befda03b64aff43f05b134c26633c5df54d3e8fb6db036abf5eb33509936324", + "bytes": 1252 + }, + { + "path": "frontend/src/components/PortfolioAtlas.tsx", + "sha256": "f647dc2405a4d6f049ce4cf6296774061ce60944ef3bdaa243445d785bce94e8", + "bytes": 5256 + }, + { + "path": "frontend/src/components/RemediationCenter.tsx", + "sha256": "60b1aaeae17a3b60ded5b3e76201663120ce86af35e6b7f1935ae86e4b69c715", + "bytes": 2338 + }, + { + "path": "frontend/src/components/RepositoryVisual.test.tsx", + "sha256": "68c1b2caf1dfb12ad6a2bdc0b8469afd0b18b0efe17a1c0eaf7c95904465d083", + "bytes": 961 + }, + { + "path": "frontend/src/components/RepositoryVisual.tsx", + "sha256": "ec830882de931e0c7d4314cf8f35e80a727fae5ed5fc1b92b3821aaa9e747266", + "bytes": 7330 + }, + { + "path": "frontend/src/components/ScanRunsView.tsx", + "sha256": "c8b857efe2492ef0460f77038a0f159a6c5a41f0ca83c619acf56f587c8900b6", + "bytes": 1357 + }, + { + "path": "frontend/src/main.tsx", + "sha256": "b3f023da133154adff448b24c256c554072536939e52291490a6128a9cfc82f7", + "bytes": 237 + }, + { + "path": "frontend/src/navigation.test.ts", + "sha256": "8afc1406f9642896b9d667e525c7c3d7ff12afc82451f2c41dcc138b2bc15fcd", + "bytes": 1026 + }, + { + "path": "frontend/src/navigation.ts", + "sha256": "693b8ff5c2319363bfcc4f8353cfd45ae4c5fad4e9ceae50cbf707aeda24a673", + "bytes": 1647 + }, + { + "path": "frontend/src/styles.css", + "sha256": "8cd3bbecd95b579e990c628fac58c00bdedecd66927235440eb01b516b3f7fdb", + "bytes": 97641 + }, + { + "path": "frontend/src/test-fixtures.ts", + "sha256": "1fdcd6a69bdcbe2df5ac8666295ea68f6c08e20c0222c477b1eecb5b6ffcce22", + "bytes": 5145 + }, + { + "path": "frontend/tsconfig.json", + "sha256": "9616ea859510ecae0cb8464fbe3bbd1448d66bb5a4ebf40d163e8f14fa8fff2b", + "bytes": 545 + }, + { + "path": "frontend/vite.config.ts", + "sha256": "f1adfcd5da9ab034690b135c5ba82e14df800296c15347a57549d3a5829e0464", + "bytes": 560 + }, + { + "path": "package-lock.json", + "sha256": "f3bfac56e6fd5d2b2752c0f03e3b4c6e71d3ce700c644be60ad3941435053b5b", + "bytes": 213 + }, + { + "path": "package.json", + "sha256": "76c07b9209f6c3eeebc34d6562d7f9dc3ade4165734065e267f94db4a2ea29b6", + "bytes": 256 + }, + { + "path": "requirements.txt", + "sha256": "c7d062f1bd7b5bd124a3153eab82308d6118a183241327c0b5e2da668e567b33", + "bytes": 19 + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..6991dab --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# RepoPulse + +**Explain repository health, maintenance risk and release readiness from local, deterministic evidence.** + +RepoPulse is a self-hosted Gitea repository intelligence dashboard. It scans exact revisions, keeps findings and history, and helps prioritize the next maintenance action. Source code is analyzed locally without sending it to an external model. + +```mermaid +flowchart LR + A[Gitea catalog / local path] --> B[Exact revision acquisition] + B --> C[Bounded deterministic scan] + C --> D[Findings and explainable scores] + D --> E[Portfolio / remediation / history] +``` + +## Capabilities + +- Read-only Gitea repository discovery, default-branch reconciliation and optional signed push webhooks. +- Durable SQLite job queue, revision deduplication, lease recovery and detached scan worktrees. +- Repository portfolio, triage, quick switch, architecture, hotspots, findings and history views. +- Language/LOC inventory, test/CI signals, dependency and lockfile analysis across common ecosystems. +- TODO debt, bounded churn, local import graphs and explainable hotspot ranking. +- Redacted credential-assignment warnings and backend-owned remediation guidance. +- Transparent scan budgets and truncation metadata instead of overstating scan coverage. +- Accepted-risk policies, release gates, finding deltas and standalone HTML/JSON reports. + +## Interface preview + +These screenshots show the deterministic browser-test fixture: fictional Alpha/Beta/Gamma repositories, not a private or live repository inventory. + +![Portfolio triage and recommended next action](docs/screenshots/demo-portfolio.png) + +![Repository explorer with health and revision filters](docs/screenshots/demo-explorer.png) + +## Deploy + +```bash +git clone https://gitea.itworx.tech/Jens/RepoPulse-Public.git +cd RepoPulse-Public +cp .env.example .env +# Select the intended repository directory and configure your Gitea connector. +docker compose up --build +``` + +Open `http://localhost:1254`. Compose runs a Python API and React frontend. `REPOPULSE_SOURCE_ROOT` defaults to the parent directory and is mounted read-only at `/scan`; narrow it to the repository collection you intend to inspect. The Advanced path flow accepts paths inside that boundary. + +`REPOPULSE_DATA_ROOT=./data` in the local template retains SQLite history, queue and repository mirrors. If unset, Compose uses the named `repopulse_data` volume. Keep persisted data outside a replaceable checkout and back it up before upgrades. The durable queue intentionally runs one active scan worker. + +## Connect Gitea + +Configure these values on the server, never in the frontend: + +```dotenv +REPOPULSE_GITEA_URL=https://gitea.example.test +REPOPULSE_GITEA_TOKEN= +REPOPULSE_OPERATOR_TOKEN= +``` + +Use only the repository-read permissions needed for the intended catalog and revisions. An optional `REPOPULSE_GITEA_WEBHOOK_SECRET` authenticates push notifications. RepoPulse discovers repositories, resolves revisions, queues stale work, scans exact SHAs and retains results by revision. Incomplete or failed discovery preserves existing evidence rather than silently declaring missing repositories unavailable. + +Protect deployment with an authenticated proxy or configure `REPOPULSE_OPERATOR_TOKEN`; the mutation API uses `X-RepoPulse-Token`. Limit network access and the scan mount. Direct development without `REPOPULSE_SCAN_ROOT` can read any path accessible to that process. + +## Interpret results + +Findings are deterministic observations with locations and severity. Health scores reflect documented deductions; release gates report **READY**, **READY WITH WARNINGS** or **BLOCKED** under repository policy. Remediation guidance explains confidence, priority and a suggested first action. RepoPulse never edits external repositories automatically. + +Heuristics are engineering guidance, not a substitute for review, runtime tests or specialized security audits. Truncated scans expose their limits. Credential warnings redact values but still require human investigation. Repository history and retained reports contain source-derived metadata, so treat the persisted store as sensitive. + +## Development + +```bash +python -m venv .venv +# Activate .venv for your shell. +pip install -r requirements.txt +uvicorn app.main:app --app-dir backend --reload --port 8000 +``` + +In another terminal: + +```bash +npm --prefix frontend ci +npm --prefix frontend run dev +``` + +Run `python -m pytest`, `npm --prefix frontend test`, `npm --prefix frontend run typecheck` and `npm --prefix frontend run build`. The browser test fixture uses deterministic API responses; it is not a live production portfolio. See [architecture](docs/ARCHITECTURE.md) for implementation detail. + +## Source distribution and licensing + +This is the curated public source edition of RepoPulse 1.5.0. Reviewed updates flow from the private canonical repository after merges to `main`, without private history, repository inventories, runtime stores or internal CI/deployment workflows. Configure the generic deployment templates for your own environment. + +No project-wide license is included. Public visibility does not grant reuse or redistribution rights; obtain the owner's terms and review third-party dependencies before redistribution. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..4ed2997 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +WORKDIR /app +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + REPOPULSE_DB=/data/repopulse.db \ + REPOPULSE_SCAN_ROOT=/scan + +RUN apt-get update \ + && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* + +COPY backend/pyproject.toml ./pyproject.toml +COPY backend/app ./app +RUN pip install --no-cache-dir . + +VOLUME ["/data", "/scan"] +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..917c235 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""RepoPulse backend package.""" diff --git a/backend/app/acquisition.py b/backend/app/acquisition.py new file mode 100644 index 0000000..4d113db --- /dev/null +++ b/backend/app/acquisition.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import os +import re +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path +from threading import Lock, RLock +from urllib.parse import urlsplit + +from .models import Repository + +SAFE_REVISION = re.compile(r"^[0-9a-fA-F]{7,64}$") +SAFE_BRANCH = re.compile(r"^[A-Za-z0-9._/-]+$") +SAFE_JOB_ID = re.compile(r"^[A-Za-z0-9-]+$") + + +class AcquisitionError(RuntimeError): + def __init__(self, code: str, detail: str) -> None: + super().__init__(detail) + self.code = code + self.detail = detail + + +@dataclass(frozen=True) +class AcquiredSource: + path: Path + actual_revision: str + branch: str + + +class GitAcquirer: + """Maintain bounded bare mirrors and scan detached exact-revision worktrees.""" + + def __init__( + self, + cache_root: str | Path | None = None, + token: str | None = None, + *, + timeout: int = 180, + allowed_clone_origin: str | None = None, + ) -> None: + raw_root = cache_root or os.getenv("REPOPULSE_REPOSITORY_CACHE", "data/repos") + self.cache_root = Path(raw_root).expanduser().resolve() + self.token = token if token is not None else os.getenv("REPOPULSE_GITEA_TOKEN", "") + self.timeout = max(10, timeout) + configured_origin = allowed_clone_origin or os.getenv("REPOPULSE_GITEA_URL", "") + self.allowed_clone_origin = self._origin(configured_origin) + self.mirrors_root = self.cache_root / "mirrors" + self.worktrees_root = self.cache_root / "worktrees" + self.mirrors_root.mkdir(parents=True, exist_ok=True) + self.worktrees_root.mkdir(parents=True, exist_ok=True) + self._lock_guard = Lock() + self._repository_locks: dict[str, RLock] = {} + + @staticmethod + def _origin(value: str) -> str | None: + if not value: + return None + try: + parsed = urlsplit(value) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + return None + port = parsed.port or (443 if parsed.scheme == "https" else 80) + except ValueError: + return None + return f"{parsed.scheme.casefold()}://{parsed.hostname.casefold()}:{port}" + + def _repository_lock(self, repository_id: str) -> RLock: + with self._lock_guard: + return self._repository_locks.setdefault(repository_id, RLock()) + + def _git_env(self) -> dict[str, str]: + env = os.environ.copy() + if self.token: + env.update( + { + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "http.extraHeader", + "GIT_CONFIG_VALUE_0": f"Authorization: token {self.token}", + } + ) + return env + + def _run(self, args: list[str]) -> str: + try: + result = subprocess.run( + args, + check=True, + capture_output=True, + text=True, + errors="replace", + timeout=self.timeout, + env=self._git_env(), + ) + except subprocess.TimeoutExpired as exc: + raise AcquisitionError("timeout", "Git source acquisition timed out") from exc + except (OSError, subprocess.CalledProcessError) as exc: + # Git stderr may contain remote URLs or server-specific details; do + # not persist or expose it because the job API is user-visible. + raise AcquisitionError("git_failed", "Git source acquisition failed") from exc + return result.stdout.strip() + + def _mirror_path(self, repository: Repository) -> Path: + identity = f"{repository.provider}-{repository.provider_repository_id}" + safe = re.sub(r"[^A-Za-z0-9._-]", "_", identity) + return self.mirrors_root / f"{safe}.git" + + def _worktree_path(self, job_id: str) -> Path: + if not SAFE_JOB_ID.fullmatch(job_id): + raise AcquisitionError("invalid_job", "Scan job identity is invalid") + return self.worktrees_root / job_id + + @staticmethod + def _validate_branch(branch: str) -> None: + if ( + not SAFE_BRANCH.fullmatch(branch) + or branch.startswith("-") + or ".." in branch + or branch.endswith(("/", ".")) + ): + raise AcquisitionError("invalid_branch", "Repository default branch is invalid") + + def _validate_clone_url(self, clone_url: str | None) -> str: + if not clone_url or "\x00" in clone_url: + raise AcquisitionError("missing_clone_url", "Repository clone URL is unavailable") + try: + parsed = urlsplit(clone_url) + if parsed.username or parsed.password or parsed.query or parsed.fragment: + raise AcquisitionError("unsafe_clone_url", "Repository clone URL contains unsafe credentials or metadata") + if parsed.scheme in {"http", "https"}: + origin = self._origin(clone_url) + if self.token and (self.allowed_clone_origin is None or origin != self.allowed_clone_origin): + raise AcquisitionError("unsafe_clone_url", "Repository clone URL is outside the configured Gitea origin") + elif parsed.scheme != "file": + raise AcquisitionError("unsafe_clone_url", "Repository clone URL uses an unsupported scheme") + except ValueError as exc: + raise AcquisitionError("unsafe_clone_url", "Repository clone URL is invalid") from exc + return clone_url + + def acquire( + self, + repository: Repository, + job_id: str, + requested_revision: str | None = None, + ) -> AcquiredSource: + with self._repository_lock(repository.id): + return self._acquire(repository, job_id, requested_revision) + + def _acquire( + self, + repository: Repository, + job_id: str, + requested_revision: str | None = None, + ) -> AcquiredSource: + branch = repository.default_branch or "main" + self._validate_branch(branch) + if requested_revision is not None and not SAFE_REVISION.fullmatch(requested_revision): + raise AcquisitionError("invalid_revision", "Requested revision is not a valid commit identifier") + clone_url = self._validate_clone_url(repository.clone_url) + mirror = self._mirror_path(repository) + if mirror.exists() and not self._mirror_is_usable(mirror): + self._remove_mirror_if_owned(mirror) + if not mirror.exists(): + self._run(["git", "clone", "--mirror", clone_url, str(mirror)]) + else: + self._run(["git", "--git-dir", str(mirror), "remote", "set-url", "origin", clone_url]) + self._run( + [ + "git", + "--git-dir", + str(mirror), + "fetch", + "--prune", + "--no-tags", + "origin", + f"+refs/heads/{branch}:refs/remotes/origin/{branch}", + ] + ) + if requested_revision: + try: + actual_revision = self._run( + ["git", "--git-dir", str(mirror), "rev-parse", f"{requested_revision}^{{commit}}"] + ) + except AcquisitionError as exc: + raise AcquisitionError("revision_unavailable", "Requested commit was not found in the mirror") from exc + if actual_revision.casefold() != requested_revision.casefold() and len(requested_revision) == 40: + raise AcquisitionError("revision_unavailable", "Requested commit was not found in the mirror") + else: + actual_revision = self._run( + [ + "git", + "--git-dir", + str(mirror), + "rev-parse", + f"refs/remotes/origin/{branch}^{{commit}}", + ] + ) + if not SAFE_REVISION.fullmatch(actual_revision): + raise AcquisitionError("invalid_revision", "Git did not return a usable commit identifier") + + worktree = self._worktree_path(job_id) + self._remove_worktree_if_owned(mirror, worktree) + try: + self._run( + [ + "git", + "--git-dir", + str(mirror), + "worktree", + "add", + "--detach", + str(worktree), + actual_revision, + ] + ) + except AcquisitionError: + self._remove_worktree_if_owned(mirror, worktree) + raise + return AcquiredSource(path=worktree, actual_revision=actual_revision, branch=branch) + + def cleanup(self, source: AcquiredSource, repository: Repository) -> None: + with self._repository_lock(repository.id): + mirror = self._mirror_path(repository) + self._remove_worktree_if_owned(mirror, source.path) + + def _mirror_is_usable(self, mirror: Path) -> bool: + try: + return self._run(["git", "--git-dir", str(mirror), "rev-parse", "--is-bare-repository"]) == "true" + except AcquisitionError: + return False + + def _remove_mirror_if_owned(self, mirror: Path) -> None: + try: + relative = mirror.resolve().relative_to(self.mirrors_root.resolve()) + except (OSError, RuntimeError, ValueError): + raise AcquisitionError("unsafe_mirror", "Repository mirror path escaped the repository cache") + if not relative.parts: + raise AcquisitionError("unsafe_mirror", "Repository mirror path is invalid") + if mirror.is_dir() and not mirror.is_symlink(): + shutil.rmtree(mirror) + elif mirror.exists() or mirror.is_symlink(): + mirror.unlink() + + def _remove_worktree_if_owned(self, mirror: Path, worktree: Path) -> None: + try: + relative = worktree.resolve().relative_to(self.worktrees_root.resolve()) + except (OSError, RuntimeError, ValueError): + raise AcquisitionError("unsafe_worktree", "Worktree path escaped the repository cache") + if not relative.parts: + raise AcquisitionError("unsafe_worktree", "Worktree path is invalid") + if worktree.exists(): + try: + self._run(["git", "--git-dir", str(mirror), "worktree", "remove", "--force", str(worktree)]) + except AcquisitionError: + # A stale registration must not prevent a safe bounded cleanup. + pass + if worktree.exists(): + shutil.rmtree(worktree) + try: + self._run(["git", "--git-dir", str(mirror), "worktree", "prune"]) + except AcquisitionError: + pass diff --git a/backend/app/architecture.py b/backend/app/architecture.py new file mode 100644 index 0000000..fbec4bb --- /dev/null +++ b/backend/app/architecture.py @@ -0,0 +1,340 @@ +from __future__ import annotations + +import ast +import json +import math +import os +import re +from collections import Counter +from pathlib import Path + +from .models import ArchitectureEdge, ArchitectureGraph, ArchitectureNode, Hotspot + +JS_IMPORT_RE = re.compile( + r"(?:\bfrom\s+|\bimport\s*|\brequire\(\s*|\bimport\(\s*)['\"](?P[^'\"]+)['\"]" +) +JS_EXTENSIONS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs") +SOURCE_ROOTS = {"src", "app", "backend", "frontend", "lib", "packages"} +CONFIG_IGNORED_DIRS = {".git", ".venv", "node_modules", "dist", "build", "coverage", "target", "vendor"} + + +def _bounded_config_files(root: Path, names: set[str], *, maximum: int = 20) -> list[Path]: + files: list[Path] = [] + for current, directories, candidates in os.walk(root, followlinks=False): + directories[:] = [name for name in directories if name.casefold() not in CONFIG_IGNORED_DIRS] + for name in candidates: + if name in names: + files.append(Path(current) / name) + if len(files) >= maximum: + return sorted(files) + return sorted(files) + + +def _ts_path_aliases(root: Path) -> list[tuple[str, str]]: + """Read a small local subset of tsconfig paths without resolving packages or URLs.""" + + aliases: list[tuple[str, str]] = [] + root_resolved = root.resolve() + for config in _bounded_config_files(root, {"tsconfig.json", "jsconfig.json"}): + try: + if config.stat().st_size > 256_000: + continue + raw = config.read_text(encoding="utf-8") + # Most project tsconfigs are JSON. Comments are deliberately not + # interpreted as configuration to keep this scanner deterministic. + data = json.loads(raw) + options = data.get("compilerOptions", {}) + paths = options.get("paths", {}) if isinstance(options, dict) else {} + if not isinstance(paths, dict): + continue + base_url = options.get("baseUrl", ".") if isinstance(options, dict) else "." + base = (config.parent / str(base_url)).resolve() + if base != root_resolved and root_resolved not in base.parents: + continue + for alias, targets in paths.items(): + if not isinstance(alias, str) or not isinstance(targets, list): + continue + for target in targets[:5]: + if not isinstance(target, str) or target.startswith(("/", "\\")): + continue + resolved = (base / target.replace("*", "placeholder")).resolve() + if resolved != root_resolved and root_resolved not in resolved.parents: + continue + aliases.append((alias, str((base / target).relative_to(root_resolved)).replace("\\", "/"))) + except (OSError, ValueError, json.JSONDecodeError, TypeError): + continue + return aliases[:100] + + +def _workspace_packages(root: Path) -> dict[str, Path]: + packages: dict[str, Path] = {} + for manifest in _bounded_config_files(root, {"package.json"}, maximum=100): + try: + if manifest.stat().st_size > 256_000: + continue + data = json.loads(manifest.read_text(encoding="utf-8")) + name = data.get("name") if isinstance(data, dict) else None + if isinstance(name, str) and name and not name.startswith((".", "/")): + packages.setdefault(name, manifest.parent) + except (OSError, ValueError, json.JSONDecodeError, TypeError): + continue + return packages + + +def _module_names(path: str) -> list[str]: + parts = list(Path(path).with_suffix("").parts) + if parts and parts[-1] == "__init__": + parts = parts[:-1] + names: list[str] = [] + if parts: + names.append(".".join(parts)) + if parts[0] in SOURCE_ROOTS and len(parts) > 1: + names.append(".".join(parts[1:])) + if len(parts) > 2 and parts[1] in SOURCE_ROOTS: + names.append(".".join(parts[2:])) + return [name for name in names if name] + + +def _python_module_map(paths: list[str]) -> dict[str, str]: + result: dict[str, str] = {} + for path in paths: + if not path.endswith(".py"): + continue + for module in _module_names(path): + result.setdefault(module, path) + return result + + +def _resolve_python_target(module: str, module_map: dict[str, str]) -> str | None: + candidate = module + while candidate: + if candidate in module_map: + return module_map[candidate] + if "." not in candidate: + break + candidate = candidate.rsplit(".", 1)[0] + return None + + +def _python_imports(path: str, text: str, module_map: dict[str, str]) -> set[str]: + try: + tree = ast.parse(text) + except SyntaxError: + return set() + + current_names = _module_names(path) + current = current_names[0] if current_names else "" + current_parts = current.split(".") if current else [] + if not path.endswith("/__init__.py") and current_parts: + package_parts = current_parts[:-1] + else: + package_parts = current_parts + + imports: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + target = _resolve_python_target(alias.name, module_map) + if target and target != path: + imports.add(target) + elif isinstance(node, ast.ImportFrom): + module = node.module or "" + if node.level: + trim = max(0, node.level - 1) + prefix = package_parts[:-trim] if trim else package_parts + absolute = ".".join([*prefix, *([module] if module else [])]) + else: + absolute = module + target = _resolve_python_target(absolute, module_map) + if target and target != path: + imports.add(target) + return imports + + +def _resolve_js_target( + root: Path, + source_path: str, + specifier: str, + known: set[str], + aliases: list[tuple[str, str]], + workspace_packages: dict[str, Path], +) -> str | None: + if specifier.startswith("."): + source_dir = (root / source_path).parent + base = (source_dir / specifier).resolve() + else: + base = None + for alias, target in aliases: + if alias.endswith("/*") and specifier.startswith(alias[:-1]): + base = (root / target.replace("*", specifier[len(alias) - 1 :])).resolve() + break + if alias == specifier: + base = (root / target).resolve() + break + if base is None: + package_name = next( + (name for name in workspace_packages if specifier == name or specifier.startswith(f"{name}/")), + None, + ) + if package_name: + package_root = workspace_packages[package_name] + suffix = specifier.removeprefix(package_name).lstrip("/") + base = package_root / suffix if suffix else package_root / "src" / "index" + if base is None: + return None + try: + relative_base = base.relative_to(root.resolve()).as_posix() + except ValueError: + return None + + candidates = [relative_base] + suffix = Path(relative_base).suffix.lower() + if suffix in JS_EXTENSIONS: + extensionless = relative_base[: -len(suffix)] + candidates.extend(extensionless + extension for extension in JS_EXTENSIONS) + candidates.extend(f"{extensionless}/index{extension}" for extension in JS_EXTENSIONS) + elif not suffix: + candidates.extend(relative_base + extension for extension in JS_EXTENSIONS) + candidates.extend(f"{relative_base}/index{extension}" for extension in JS_EXTENSIONS) + return next((candidate for candidate in candidates if candidate in known), None) + + +def _js_imports( + root: Path, + path: str, + text: str, + known: set[str], + aliases: list[tuple[str, str]], + workspace_packages: dict[str, Path], +) -> set[str]: + imports: set[str] = set() + for match in JS_IMPORT_RE.finditer(text): + target = _resolve_js_target(root, path, match.group("target"), known, aliases, workspace_packages) + if target and target != path: + imports.add(target) + return imports + + +def build_architecture( + root: Path, + source_texts: dict[str, str], + language_by_path: dict[str, str], + loc_by_path: dict[str, int], + debt_by_path: dict[str, int], + churn_by_path: dict[str, int], + *, + max_nodes: int = 160, + max_edges: int = 600, +) -> tuple[ArchitectureGraph, list[Hotspot]]: + max_nodes = max(1, max_nodes) + max_edges = max(0, max_edges) + known_paths = set(source_texts) + module_map = _python_module_map(list(known_paths)) + ts_aliases = _ts_path_aliases(root) + workspace_packages = _workspace_packages(root) + edge_pairs: set[tuple[str, str]] = set() + total_inbound: Counter[str] = Counter() + total_outbound: Counter[str] = Counter() + total_edges = 0 + truncated = len(known_paths) > max_nodes + + for path in sorted(source_texts): + text = source_texts[path] + language = language_by_path.get(path, "") + if language == "Python": + targets = _python_imports(path, text, module_map) + elif language in {"TypeScript", "JavaScript"}: + targets = _js_imports(root, path, text, known_paths, ts_aliases, workspace_packages) + else: + targets = set() + for target in sorted(targets): + if target not in known_paths: + continue + pair = (path, target) + total_edges += 1 + total_outbound[path] += 1 + total_inbound[target] += 1 + if len(edge_pairs) < max_edges: + edge_pairs.add(pair) + else: + truncated = True + + hotspots: list[Hotspot] = [] + for path in known_paths: + loc = loc_by_path.get(path, 0) + debt = debt_by_path.get(path, 0) + churn = churn_by_path.get(path, 0) + coupling = total_inbound[path] + total_outbound[path] + score = min( + 100, + round( + min(30, loc / 40) + + min(20, debt * 4) + + min(30, math.log10(churn + 1) * 10 if churn else 0) + + min(20, coupling * 2.5) + ), + ) + reasons: list[str] = [] + if loc >= 500: + reasons.append(f"large module ({loc} LOC)") + if debt: + reasons.append(f"{debt} debt markers") + if churn >= 300: + reasons.append(f"high recent churn ({churn} changed lines)") + if coupling >= 8: + reasons.append(f"high coupling ({coupling} local edges)") + if not reasons and score >= 30: + reasons.append("combined size, churn and coupling") + hotspots.append( + Hotspot( + path=path, + language=language_by_path.get(path, "Other"), + lines_of_code=loc, + debt_markers=debt, + churn=churn, + inbound=total_inbound[path], + outbound=total_outbound[path], + score=score, + reasons=reasons, + ) + ) + + hotspots.sort(key=lambda item: (-item.score, -item.churn, -item.lines_of_code, item.path)) + selected_paths = {item.path for item in hotspots[:max_nodes]} + graph_edges = [ + ArchitectureEdge(source=source, target=target) + for source, target in sorted(edge_pairs) + if source in selected_paths and target in selected_paths + ][:max_edges] + visible_inbound: Counter[str] = Counter() + visible_outbound: Counter[str] = Counter() + for edge in graph_edges: + visible_outbound[edge.source] += 1 + visible_inbound[edge.target] += 1 + hotspot_scores = {item.path: item.score for item in hotspots} + nodes = [ + ArchitectureNode( + id=path, + label=Path(path).name, + path=path, + language=language_by_path.get(path, "Other"), + inbound=total_inbound[path], + outbound=total_outbound[path], + visible_inbound=visible_inbound[path], + visible_outbound=visible_outbound[path], + hotspot_score=hotspot_scores[path], + ) + for path in sorted(selected_paths) + ] + clusters = Counter((Path(path).parts[0] if len(Path(path).parts) > 1 else "root") for path in selected_paths) + return ( + ArchitectureGraph( + nodes=nodes, + edges=graph_edges, + clusters=dict(clusters.most_common()), + truncated=truncated, + total_nodes=len(known_paths), + total_edges=total_edges, + ), + hotspots[:30], + ) diff --git a/backend/app/git_analysis.py b/backend/app/git_analysis.py new file mode 100644 index 0000000..891e361 --- /dev/null +++ b/backend/app/git_analysis.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import subprocess +from collections import Counter +from datetime import datetime +from pathlib import Path + +from .models import GitSignals + + +def _run(root: Path, *args: str, timeout: int = 8) -> str | None: + try: + result = subprocess.run( + ["git", "-C", str(root), *args], + check=True, + capture_output=True, + text=True, + errors="replace", + timeout=timeout, + ) + except (OSError, UnicodeError, subprocess.SubprocessError): + return None + return result.stdout + + +def analyze_git(root: Path) -> tuple[GitSignals, dict[str, int]]: + top_level = _run(root, "rev-parse", "--show-toplevel") + if not top_level: + return GitSignals(), {} + try: + if Path(top_level.strip()).resolve() != root.resolve(): + return GitSignals(), {} + except (OSError, RuntimeError, ValueError): + return GitSignals(), {} + + inside = _run(root, "rev-parse", "--is-inside-work-tree") + if not inside or inside.strip() != "true": + return GitSignals(), {} + + raw_count = _run(root, "rev-list", "--count", "HEAD") + raw_latest = _run(root, "log", "-1", "--format=%aI") + raw_authors = _run(root, "log", "--format=%aN%x00%aE", "-n", "2000") + raw_files = _run(root, "ls-files", "-z") + raw_churn = _run(root, "log", "--numstat", "--format=", "--find-renames=20%", "-n", "200") + raw_renames = _run(root, "log", "--name-status", "--format=", "--find-renames=20%", "-n", "200") + + contributors: set[str] = set() + if raw_authors: + for line in raw_authors.splitlines(): + if line.strip(): + contributors.add(line.strip()) + + churn: Counter[str] = Counter() + renamed_files: set[str] = set() + rename_map: dict[str, str] = {} + if raw_renames: + for line in raw_renames.splitlines(): + parts = line.split("\t") + if len(parts) >= 3 and parts[0].startswith("R"): + old_path, new_path = parts[-2:] + rename_map[old_path] = new_path + renamed_files.add(new_path) + if raw_churn: + for line in raw_churn.splitlines(): + parts = line.split("\t", 2) + if len(parts) != 3: + continue + additions, deletions, path = parts + churn_path = _rename_target(path) if " => " in path else rename_map.get(path, path) + if " => " in path: + renamed_files.add(churn_path) + if additions.isdigit(): + churn[churn_path] += int(additions) + if deletions.isdigit(): + churn[churn_path] += int(deletions) + + latest_commit_at = None + if raw_latest and raw_latest.strip(): + try: + latest_commit_at = datetime.fromisoformat(raw_latest.strip()) + except ValueError: + latest_commit_at = None + + return ( + GitSignals( + available=True, + commit_count=int(raw_count.strip()) if raw_count and raw_count.strip().isdigit() else None, + contributor_count=len(contributors) if contributors else None, + latest_commit_at=latest_commit_at, + tracked_files=len([item for item in raw_files.split("\0") if item]) if raw_files is not None else None, + renamed_files=sorted(renamed_files), + ), + dict(churn), + ) + + +def _rename_target(path: str) -> str: + """Normalize Git's brace-formatted rename path to the new path.""" + if "{" in path and "}" in path: + prefix, remainder = path.split("{", 1) + inner, suffix = remainder.split("}", 1) + if " => " in inner: + return f"{prefix}{inner.split(' => ', 1)[1]}{suffix}" + return path.rsplit(" => ", 1)[-1] diff --git a/backend/app/gitea.py b/backend/app/gitea.py new file mode 100644 index 0000000..7aeba73 --- /dev/null +++ b/backend/app/gitea.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.parse +import urllib.request +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime +from typing import Any + +from .models import Repository, RepositoryPolicy, RepositorySyncResult + +JsonTransport = Callable[[str, dict[str, str]], tuple[int, bytes]] + + +class GiteaError(RuntimeError): + def __init__(self, code: str, detail: str, *, status_code: int | None = None) -> None: + super().__init__(detail) + self.code = code + self.detail = detail + self.status_code = status_code + + +def _parse_time(value: object) -> datetime | None: + if not isinstance(value, str) or not value: + return None + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return None + return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed + + +class GiteaClient: + """Small, read-only Gitea API client with bounded pagination and safe errors.""" + + def __init__( + self, + base_url: str | None = None, + token: str | None = None, + *, + timeout: float = 10.0, + page_size: int = 50, + max_pages: int = 20, + max_repositories: int = 500, + max_branch_workers: int | None = None, + transport: JsonTransport | None = None, + ) -> None: + self.base_url = (base_url or os.getenv("REPOPULSE_GITEA_URL", "")).rstrip("/") + self.token = token if token is not None else os.getenv("REPOPULSE_GITEA_TOKEN", "") + self.timeout = max(1.0, timeout) + self.page_size = max(1, min(page_size, 100)) + self.max_pages = max(1, max_pages) + self.max_repositories = max(1, max_repositories) + if max_branch_workers is None: + try: + configured_workers = int(os.getenv("REPOPULSE_GITEA_BRANCH_WORKERS", "4")) + except ValueError: + configured_workers = 4 + else: + configured_workers = max_branch_workers + self.max_branch_workers = min(8, max(1, configured_workers)) + self.transport = transport + self._listing_complete = True + + @property + def configured(self) -> bool: + return bool(self.base_url and self.token) + + def _request_json(self, path: str, params: dict[str, str | int] | None = None) -> Any: + if not self.base_url: + raise GiteaError("not_configured", "Gitea URL is not configured") + query = urllib.parse.urlencode(params or {}) + url = f"{self.base_url}/api/v1/{path.lstrip('/')}" + if query: + url = f"{url}?{query}" + headers = { + "Accept": "application/json", + "User-Agent": "RepoPulse/1.5", + } + if self.token: + headers["Authorization"] = f"token {self.token}" + try: + if self.transport: + status_code, raw = self.transport(url, headers) + if status_code >= 400: + self._raise_for_status(status_code) + else: + request = urllib.request.Request(url, headers=headers, method="GET") + with urllib.request.urlopen(request, timeout=self.timeout) as response: + status_code = int(response.status) + raw = response.read(2_000_001) + if status_code >= 400: + self._raise_for_status(status_code) + except GiteaError: + raise + except urllib.error.HTTPError as exc: + self._raise_for_status(exc.code) + except (urllib.error.URLError, TimeoutError, OSError) as exc: + raise GiteaError("unreachable", "Gitea could not be reached") from exc + if not isinstance(raw, bytes): + raise GiteaError("invalid_response", "Gitea returned an invalid response body") + if len(raw) > 2_000_000: + raise GiteaError("response_too_large", "Gitea response exceeded the configured bound") + try: + return json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise GiteaError("invalid_response", "Gitea returned invalid JSON") from exc + + @staticmethod + def _raise_for_status(status_code: int) -> None: + if status_code in {401, 403}: + raise GiteaError("authentication", "Gitea authentication was rejected", status_code=status_code) + if status_code == 404: + raise GiteaError("not_found", "The requested Gitea resource was not found", status_code=status_code) + if status_code == 429: + raise GiteaError("rate_limited", "Gitea rate-limited the request", status_code=status_code) + if status_code >= 500: + raise GiteaError("upstream", "Gitea returned a server error", status_code=status_code) + raise GiteaError("request_failed", "Gitea rejected the request", status_code=status_code) + + def _repository_from_payload(self, payload: object) -> Repository: + if not isinstance(payload, dict): + raise GiteaError("invalid_repository", "Gitea returned an invalid repository record") + raw_id = payload.get("id") + if isinstance(raw_id, bool) or not isinstance(raw_id, (int, str)) or not str(raw_id).isdigit(): + raise GiteaError("invalid_repository", "Gitea repository identity is invalid") + owner_payload = payload.get("owner") + owner = owner_payload.get("login") if isinstance(owner_payload, dict) else None + owner = owner or payload.get("owner_name") + name = payload.get("name") + full_name = payload.get("full_name") + if not isinstance(owner, str) or not isinstance(name, str): + if isinstance(full_name, str) and "/" in full_name: + owner, name = full_name.split("/", 1) + else: + raise GiteaError("invalid_repository", "Gitea repository name is invalid") + full_name = full_name if isinstance(full_name, str) else f"{owner}/{name}" + repository_id = str(raw_id) + # Do not trust a provider-supplied clone host when constructing the + # credential-bearing acquisition URL. The configured Gitea origin is + # the only network destination that may receive the server token. + clone_url = ( + f"{self.base_url}/{urllib.parse.quote(owner, safe='')}/" + f"{urllib.parse.quote(name, safe='')}.git" + ) + html_url = payload.get("html_url") + default_branch = payload.get("default_branch") or "main" + return Repository( + id=f"gitea:{repository_id}", + provider="gitea", + provider_repository_id=repository_id, + owner=owner, + name=name, + full_name=full_name, + description=payload.get("description") if isinstance(payload.get("description"), str) else None, + html_url=html_url if isinstance(html_url, str) else None, + clone_url=clone_url if isinstance(clone_url, str) else None, + default_branch=str(default_branch), + private=bool(payload.get("private", False)), + archived=bool(payload.get("archived", False)), + latest_revision_at=_parse_time(payload.get("updated_at")), + policy=RepositoryPolicy(), + ) + + def list_repositories(self) -> list[Repository]: + if not self.configured: + raise GiteaError("not_configured", "Gitea connector is not configured") + repositories: list[Repository] = [] + self._listing_complete = False + for page in range(1, self.max_pages + 1): + payload = self._request_json( + "user/repos", + {"limit": self.page_size, "page": page, "sort": "updated", "direction": "desc"}, + ) + if not isinstance(payload, list): + raise GiteaError("invalid_response", "Gitea repository listing was not a list") + for item in payload: + if len(repositories) >= self.max_repositories: + return repositories + repositories.append(self._repository_from_payload(item)) + if len(payload) < self.page_size: + self._listing_complete = True + break + return repositories + + def branch_revision(self, repository: Repository) -> str: + owner = urllib.parse.quote(repository.owner, safe="") + name = urllib.parse.quote(repository.name, safe="") + branch = urllib.parse.quote(repository.default_branch, safe="") + payload = self._request_json(f"repos/{owner}/{name}/branches/{branch}") + if not isinstance(payload, dict): + raise GiteaError("invalid_response", "Gitea branch response was invalid") + commit = payload.get("commit") + revision = None + if isinstance(commit, dict): + revision = commit.get("id") or commit.get("sha") + if ( + not isinstance(revision, str) + or not 7 <= len(revision) <= 64 + or not all(char in "0123456789abcdefABCDEF" for char in revision) + ): + raise GiteaError("invalid_revision", "Gitea did not return a usable branch revision") + return revision + + def discover_with_errors(self) -> tuple[list[Repository], list[tuple[Repository, str]], list[str]]: + """Resolve branch tips independently so one repository cannot abort a catalog sync.""" + + repositories = self.list_repositories() + + def resolve(repository: Repository) -> tuple[Repository, str | None, str | None]: + try: + return repository, self.branch_revision(repository), None + except GiteaError as exc: + return repository, None, f"{repository.full_name}: {exc.code}: {exc.detail}" + + with ThreadPoolExecutor(max_workers=min(self.max_branch_workers, len(repositories) or 1)) as executor: + outcomes = list(executor.map(resolve, repositories)) + discovered = [ + (repository.model_copy(update={"latest_revision": revision}), revision) + for repository, revision, error in outcomes + if revision is not None and error is None + ] + errors = [error for _, _, error in outcomes if error] + return repositories, discovered, errors + + def discover(self) -> list[tuple[Repository, str]]: + """Compatibility helper for callers needing only successfully resolved repositories.""" + + _, discovered, _ = self.discover_with_errors() + return discovered + + def sync(self, store) -> RepositorySyncResult: + synced_at = datetime.now(UTC) + try: + listed, discovered, branch_errors = self.discover_with_errors() + except GiteaError as exc: + error = f"{exc.code}: {exc.detail}" + for existing in store.list_repositories(limit=1000): + if existing.provider == "gitea" and existing.available: + store.update_repository_sync(existing, synced_at=synced_at, error=error) + result = RepositorySyncResult( + provider="gitea", + errors=[error], + synced_at=synced_at, + ) + store.record_sync_result(result) + return result + created = 0 + updated = 0 + listed_ids = {repository.id for repository in listed} + seen_ids: set[str] = set() + for repository, revision in discovered: + seen_ids.add(repository.id) + before = store.get_repository(repository.id) + merged = repository.model_copy( + update={ + "available": True, + "last_synced_at": synced_at, + "sync_error": None, + "latest_revision": revision, + "policy": before.policy if before else RepositoryPolicy(), + } + ) + store.upsert_repository(merged) + if before is None: + created += 1 + else: + updated += 1 + for repository in listed: + if repository.id in seen_ids: + continue + before = store.get_repository(repository.id) + error = next((item for item in branch_errors if item.startswith(f"{repository.full_name}:")), None) + merged = repository.model_copy( + update={ + "available": True, + "last_synced_at": synced_at, + "sync_error": error or "branch: revision could not be resolved", + "latest_revision": before.latest_revision if before else None, + "policy": before.policy if before else RepositoryPolicy(), + } + ) + store.upsert_repository(merged) + if before is None: + created += 1 + else: + updated += 1 + unavailable = 0 + if self._listing_complete: + for existing in store.list_repositories(limit=1000): + if existing.provider == "gitea" and existing.id not in listed_ids and existing.available: + store.update_repository_sync( + existing.model_copy(update={"available": False}), + synced_at=synced_at, + error="not_discovered", + ) + unavailable += 1 + result = RepositorySyncResult( + provider="gitea", + discovered=len(discovered), + created=created, + updated=updated, + unavailable=unavailable, + errors=branch_errors, + partial=bool(branch_errors) or not self._listing_complete, + branch_failures=len(branch_errors), + listing_complete=self._listing_complete, + synced_at=synced_at, + ) + store.record_sync_result(result) + return result diff --git a/backend/app/jobs.py b/backend/app/jobs.py new file mode 100644 index 0000000..f0c73fb --- /dev/null +++ b/backend/app/jobs.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +import logging +import os +from datetime import UTC, datetime +from threading import Event, Lock, Thread, current_thread +from time import monotonic + +from .acquisition import AcquisitionError, GitAcquirer +from .gitea import GiteaClient +from .models import ( + Repository, + RepositorySyncResult, + ScanJob, + ScanJobStatus, + ScanSource, + ScanTrigger, + SourceOrigin, +) +from .scanner import scan_repository +from .store import MaintenanceModeError, QueueFullError, ScanStore, job_lease_seconds + +LOGGER = logging.getLogger(__name__) + + +def _positive_int(name: str, default: int, *, maximum: int | None = None) -> int: + try: + value = max(1, int(os.getenv(name, str(default)))) + except ValueError: + value = default + return min(value, maximum) if maximum else value + + +def _positive_float(name: str, default: float) -> float: + try: + return max(0.2, float(os.getenv(name, str(default)))) + except ValueError: + return default + + +class ScanJobRunner: + """Single-process durable worker and bounded Gitea scheduler.""" + + def __init__( + self, + store: ScanStore, + *, + gitea: GiteaClient | None = None, + acquirer: GitAcquirer | None = None, + poll_seconds: float = 0.5, + sync_interval_seconds: float | None = None, + max_queue: int | None = None, + ) -> None: + self.store = store + self.gitea = gitea or GiteaClient() + self.acquirer = acquirer or GitAcquirer() + self.poll_seconds = max(0.2, poll_seconds) + self.sync_interval_seconds = sync_interval_seconds or _positive_float( + "REPOPULSE_GITEA_SYNC_INTERVAL_SECONDS", 300.0 + ) + self.max_queue = max_queue or _positive_int("REPOPULSE_MAX_QUEUED_SCANS", 100, maximum=1000) + self._stop = Event() + self._thread: Thread | None = None + self._last_sync = float("-inf") + self._sync_lock = Lock() + + @property + def started(self) -> bool: + return self._thread is not None and self._thread.is_alive() + + def start(self) -> None: + if self.started: + return + self._stop.clear() + self._thread = Thread(target=self._run, name="repopulse-scan-worker", daemon=True) + self._thread.start() + + def stop(self) -> None: + self._stop.set() + thread = self._thread + if thread and thread is not current_thread(): + thread.join(timeout=5) + if thread is None or not thread.is_alive(): + self._thread = None + + def _run(self) -> None: + while not self._stop.is_set(): + try: + job = self._run_iteration() + except MaintenanceModeError: + job = None + except Exception: + self._last_sync = monotonic() + LOGGER.exception("RepoPulse worker loop recovered from an unexpected failure") + job = None + if job is None: + self._stop.wait(self.poll_seconds) + + def _run_iteration(self) -> ScanJob | None: + """Perform one scheduler pass unless durable maintenance has paused work.""" + + if self.store.maintenance_status()["enabled"]: + return None + if not self.store.touch_worker_heartbeat_if_active(datetime.now(UTC).isoformat()): + return None + if self.gitea.configured and monotonic() - self._last_sync >= self.sync_interval_seconds: + self.sync_now() + if self.store.maintenance_status()["enabled"]: + return None + return self.process_one() + + def sync_now(self) -> RepositorySyncResult: + with self._sync_lock: + if self.store.maintenance_status()["enabled"]: + raise MaintenanceModeError("RepoPulse is in maintenance mode") + if not self.gitea.configured: + result = RepositorySyncResult( + provider="gitea", + errors=["not_configured: Gitea connector is not configured"], + synced_at=datetime.now(UTC), + ) + self.store.record_sync_result(result) + self._last_sync = monotonic() + return result + try: + result = self.gitea.sync(self.store) + except Exception: + self._last_sync = monotonic() + raise + self._last_sync = monotonic() + if not result.errors: + self._schedule_changed_repositories() + return result + + def _schedule_changed_repositories(self) -> None: + if self.store.maintenance_status()["enabled"]: + return + for summary in self.store.list_repositories(limit=1000): + if summary.provider != "gitea" or summary.archived or not summary.latest_revision: + continue + if not summary.policy.auto_scan: + continue + if summary.last_scan_revision and summary.last_scan_revision == summary.latest_revision: + continue + try: + self.store.enqueue_job( + summary.id, + summary.latest_revision, + ScanTrigger.SCHEDULED, + max_queue=self.max_queue, + ) + except (MaintenanceModeError, QueueFullError, KeyError): + continue + + def set_maintenance_mode(self, enabled: bool) -> dict[str, object]: + """Serialize maintenance changes with catalog reconciliation and scan scheduling.""" + + with self._sync_lock: + return self.store.set_maintenance_mode(enabled) + + def enqueue( + self, + repository: Repository, + *, + force: bool = False, + trigger: ScanTrigger = ScanTrigger.MANUAL, + ) -> ScanJob: + requested_revision = repository.latest_revision + return self.store.enqueue_job( + repository.id, + requested_revision, + trigger, + force=force, + max_queue=self.max_queue, + ) + + def process_one(self) -> ScanJob | None: + job = self.store.claim_next_job() + if job is None: + return None + try: + self._process(job) + except Exception: + LOGGER.exception("RepoPulse scan job failed outside the isolated job handler") + try: + self.store.update_job( + job.id, + status=ScanJobStatus.FAILED, + phase="failed", + progress=100, + error_code="internal_error", + error_detail="Unexpected repository analysis failure", + message="Repository analysis failed", + ) + except Exception: + LOGGER.exception("RepoPulse could not persist an unexpected job failure") + return self.store.get_job(job.id) + + class _LeaseHeartbeat: + def __init__(self, store: ScanStore, job_id: str) -> None: + self.store = store + self.job_id = job_id + self.stop_event = Event() + self.thread = Thread(target=self._run, name=f"repopulse-lease-{job_id[:8]}", daemon=True) + + def start(self) -> None: + self.thread.start() + + def stop(self) -> None: + self.stop_event.set() + self.thread.join(timeout=2) + + def _run(self) -> None: + interval = max(1.0, job_lease_seconds() / 3) + while not self.stop_event.wait(interval): + try: + self.store.renew_job_lease(self.job_id) + except Exception: + LOGGER.warning("RepoPulse could not renew a scan-job lease", exc_info=True) + + def _process(self, job: ScanJob) -> None: + repository = self.store.get_repository(job.repository_id) + if repository is None: + self.store.update_job( + job.id, + status=ScanJobStatus.FAILED, + phase="failed", + progress=100, + error_code="repository_not_found", + error_detail="Repository catalog entry no longer exists", + ) + return + source_path = None + acquired = None + lease_heartbeat = self._LeaseHeartbeat(self.store, job.id) + lease_heartbeat.start() + try: + if repository.provider != "gitea": + source_path = self.store.get_repository_source_path(repository.id) + if not source_path: + raise AcquisitionError("source_unavailable", "Local repository source is unavailable") + actual_revision = job.requested_revision + branch = repository.default_branch + else: + acquired = self.acquirer.acquire(repository, job.id, job.requested_revision) + source_path = str(acquired.path) + actual_revision = acquired.actual_revision + branch = acquired.branch + self.store.update_job( + job.id, + status=ScanJobStatus.SCANNING, + phase="scanning", + progress=30, + actual_revision=actual_revision, + message="Running deterministic repository analysis", + ) + source = ScanSource( + origin=SourceOrigin.GITEA if repository.provider == "gitea" else SourceOrigin.LOCAL, + provider=repository.provider, + repository_id=repository.id, + repository_full_name=repository.full_name, + branch=branch, + requested_revision=job.requested_revision, + actual_revision=actual_revision, + trigger=job.trigger, + ) + report = scan_repository(source_path, source=source, display_name=repository.name) + self.store.update_job( + job.id, + status=ScanJobStatus.ANALYZING, + phase="analyzing", + progress=80, + message="Persisting report and comparison baseline", + ) + self.store.update_job( + job.id, + status=ScanJobStatus.PERSISTING, + phase="persisting", + progress=90, + message="Saving report and revision history", + ) + saved = self.store.save(report) + self.store.upsert_repository(repository.model_copy(update={"policy": saved.policy})) + self.store.update_job( + job.id, + status=ScanJobStatus.COMPLETED, + phase="completed", + progress=100, + actual_revision=saved.source.actual_revision, + scan_id=saved.id, + message="Scan completed", + ) + except AcquisitionError as exc: + self.store.update_job( + job.id, + status=ScanJobStatus.FAILED, + phase="failed", + progress=100, + error_code=exc.code, + error_detail=exc.detail, + message="Repository source acquisition failed", + ) + except (ValueError, PermissionError, OSError) as exc: + self.store.update_job( + job.id, + status=ScanJobStatus.FAILED, + phase="failed", + progress=100, + error_code="scan_failed", + error_detail=str(exc) if isinstance(exc, ValueError) else "Repository analysis failed", + message="Repository analysis failed", + ) + except Exception: + LOGGER.exception("RepoPulse scan job failed without exposing job details") + self.store.update_job( + job.id, + status=ScanJobStatus.FAILED, + phase="failed", + progress=100, + error_code="internal_error", + error_detail="Unexpected repository analysis failure", + message="Repository analysis failed", + ) + finally: + lease_heartbeat.stop() + if acquired is not None and repository is not None: + try: + self.acquirer.cleanup(acquired, repository) + except AcquisitionError: + LOGGER.warning("Unable to clean one RepoPulse worktree") diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..64e0b97 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,491 @@ +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import json +import os +from contextlib import asynccontextmanager +from pathlib import Path +from threading import BoundedSemaphore, Lock + +from fastapi import FastAPI, HTTPException, Query, Request, Response, status +from fastapi.concurrency import run_in_threadpool +from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles + +from .acquisition import GitAcquirer +from .gitea import GiteaClient +from .jobs import ScanJobRunner +from .models import ( + RepositoryDetail, + RepositoryScanRequest, + RepositorySummary, + RepositorySyncResult, + ScanComparison, + ScanJob, + ScanReport, + ScanRequest, + ScanSummary, + ScanTrigger, + TrendPoint, + WebhookResponse, +) +from .reporting import content_disposition, render_html_report, safe_report_filename +from .scanner import scan_repository +from .store import MaintenanceModeError, QueueFullError, ScanStore + +VERSION = "1.5.0" +store = ScanStore() +SCAN_ROOT = os.getenv("REPOPULSE_SCAN_ROOT") + + +def _positive_int_env(name: str, default: int) -> int: + try: + return max(1, int(os.getenv(name, str(default)))) + except ValueError: + return default + + +# The durable queue has one real worker. Keep direct legacy path scans at the +# same capacity rather than advertising concurrency that cannot be delivered. +REQUESTED_MAX_CONCURRENT_SCANS = _positive_int_env("REPOPULSE_MAX_CONCURRENT_SCANS", 1) +MAX_CONCURRENT_SCANS = 1 +SCAN_SLOTS = BoundedSemaphore(MAX_CONCURRENT_SCANS) +gitea_client = GiteaClient() +repository_acquirer = GitAcquirer() +job_runner: ScanJobRunner | None = None +_api_mutation_lock = Lock() +_MAINTENANCE_ENTER_PATH = "/api/v1/operator/maintenance/enter" +_MAINTENANCE_EXIT_PATH = "/api/v1/operator/maintenance/exit" + + +def _get_runner() -> ScanJobRunner: + global job_runner + if job_runner is None or job_runner.store is not store: + job_runner = ScanJobRunner( + store, + gitea=gitea_client, + acquirer=repository_acquirer, + ) + return job_runner + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + runner = _get_runner() + runner.start() + try: + yield + finally: + runner.stop() + + +app = FastAPI(title="RepoPulse API", version=VERSION, lifespan=lifespan) + + +@app.middleware("http") +async def maintenance_mutation_gate(request: Request, call_next): + """Serialize external API writes with maintenance transitions and reject writes while paused.""" + + if not request.url.path.startswith("/api/v1/") or request.method in {"GET", "HEAD", "OPTIONS"}: + return await call_next(request) + if request.url.path in {_MAINTENANCE_ENTER_PATH, _MAINTENANCE_EXIT_PATH}: + return await call_next(request) + + await run_in_threadpool(_api_mutation_lock.acquire) + try: + if store.maintenance_status()["enabled"]: + return JSONResponse( + status_code=status.HTTP_423_LOCKED, + content={"detail": "RepoPulse is in maintenance mode"}, + headers={"Retry-After": "30"}, + ) + return await call_next(request) + finally: + _api_mutation_lock.release() + + +def _maintenance_snapshot() -> dict[str, object]: + mode = store.maintenance_status() + stats = store.queue_stats() + queued = int(stats["queued"]) + active = int(stats["active"]) + return { + **mode, + "queue_depth": queued, + "active_jobs": active, + "active_drained": active == 0, + "drained": queued == 0 and active == 0, + } + + +def _require_mutation_authorization(request: Request, *, strict: bool = False) -> None: + """Use an operator token when configured; deployment perimeter remains explicit otherwise.""" + + token = os.getenv("REPOPULSE_OPERATOR_TOKEN", "") + if strict: + token = os.getenv("REPOPULSE_MAINTENANCE_TOKEN", "") or token + if not token: + if strict: + raise HTTPException(status_code=503, detail="Operator token is not configured") + return + supplied = request.headers.get("x-repopulse-token", "") + if not hmac.compare_digest(supplied, token): + raise HTTPException(status_code=401, detail="Operator authorization is required") + + +def _resolve_scan_path(raw_path: str) -> Path: + supplied = Path(raw_path).expanduser() + if SCAN_ROOT: + root = Path(SCAN_ROOT).expanduser().resolve() + candidate = (root / supplied).resolve() if not supplied.is_absolute() else supplied.resolve() + if candidate != root and root not in candidate.parents: + raise ValueError(f"Path must be inside configured scan root {root}") + return candidate + return supplied.resolve() + + +@app.get("/health") +def health() -> dict[str, str]: + recovery = store.database_recovery_status() + status = "degraded" if recovery.get("status") == "degraded_recovered" else "ok" + return {"status": status} + + +@app.get("/api/v1/meta") +def meta() -> dict[str, object]: + stats = store.queue_stats() + repository_states = store.repository_state_counts() + last_sync = store.get_state("last_gitea_sync") + parsed_sync: object = None + if last_sync: + try: + parsed_sync = json.loads(last_sync) + except json.JSONDecodeError: + parsed_sync = last_sync + return { + "name": "RepoPulse", + "version": VERSION, + "stage": "v1.5.0", + "scan_root_restricted": bool(SCAN_ROOT), + "scan_root": SCAN_ROOT, + "max_concurrent_scans": MAX_CONCURRENT_SCANS, + "requested_max_concurrent_scans": REQUESTED_MAX_CONCURRENT_SCANS, + "repository_count": store.repository_count(), + "repository_states": repository_states, + "current_repositories": repository_states.get("current", 0), + "stale_repositories": repository_states.get("stale", 0), + "queue_depth": stats["queued"], + "active_jobs": stats["active"], + "completed_jobs": stats["completed"], + "failed_jobs": stats["failed"], + "worker_heartbeat": stats["worker_heartbeat"], + "gitea": { + "configured": gitea_client.configured, + "url": gitea_client.base_url or None, + "last_sync": parsed_sync, + }, + "storage": store.storage_stats(), + "database_recovery": store.database_recovery_status(), + "maintenance": _maintenance_snapshot(), + "mutation_authentication": "operator_token" if os.getenv("REPOPULSE_OPERATOR_TOKEN") else "perimeter_required", + } + + +@app.get("/api/v1/operator/maintenance") +def get_maintenance_status(request: Request) -> dict[str, object]: + _require_mutation_authorization(request, strict=True) + return _maintenance_snapshot() + + +@app.post("/api/v1/operator/maintenance/enter") +def enter_maintenance(request: Request) -> dict[str, object]: + _require_mutation_authorization(request, strict=True) + with _api_mutation_lock: + if store.maintenance_status()["enabled"]: + raise HTTPException(status_code=423, detail="RepoPulse is already in maintenance mode") + _get_runner().set_maintenance_mode(True) + return _maintenance_snapshot() + + +@app.post("/api/v1/operator/maintenance/exit") +def exit_maintenance(request: Request) -> dict[str, object]: + _require_mutation_authorization(request, strict=True) + with _api_mutation_lock: + _get_runner().set_maintenance_mode(False) + return _maintenance_snapshot() + + +@app.post("/api/v1/scans", response_model=ScanReport, status_code=status.HTTP_201_CREATED) +async def create_scan(payload: ScanRequest, request: Request) -> ScanReport: + _require_mutation_authorization(request) + try: + repository_path = _resolve_scan_path(payload.path) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + if not SCAN_SLOTS.acquire(blocking=False): + raise HTTPException( + status_code=429, + detail="Repository scan capacity is currently in use; retry after the active scan completes", + headers={"Retry-After": "2"}, + ) + + try: + report = await asyncio.to_thread(scan_repository, str(repository_path)) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=403, detail="Repository path cannot be read") from exc + except OSError as exc: + raise HTTPException(status_code=422, detail="Repository path cannot be scanned") from exc + finally: + SCAN_SLOTS.release() + return store.save(report) + + +@app.get("/api/v1/repositories", response_model=list[RepositorySummary]) +def list_repositories( + limit: int = Query(default=100, ge=1, le=1000), + offset: int = Query(default=0, ge=0, le=100_000), +) -> list[RepositorySummary]: + return store.list_repositories(limit, offset) + + +@app.post("/api/v1/repositories/sync", response_model=RepositorySyncResult) +async def sync_repositories(request: Request) -> RepositorySyncResult: + _require_mutation_authorization(request) + if not gitea_client.configured: + raise HTTPException(status_code=503, detail="Gitea connector is not configured") + try: + result = await asyncio.to_thread(_get_runner().sync_now) + except MaintenanceModeError as exc: + raise HTTPException(status_code=423, detail=str(exc), headers={"Retry-After": "30"}) from exc + if result.errors and result.branch_failures == 0: + raise HTTPException(status_code=502, detail=result.errors[0]) + return result + + +@app.get("/api/v1/repositories/{repository_id}", response_model=RepositoryDetail) +def get_repository(repository_id: str) -> RepositoryDetail: + repository = store.get_repository_detail(repository_id) + if repository is None: + raise HTTPException(status_code=404, detail="Repository not found") + return repository + + +@app.get("/api/v1/repositories/{repository_id}/scans", response_model=list[ScanSummary]) +def list_repository_scans( + repository_id: str, + limit: int = Query(default=50, ge=1, le=200), +) -> list[ScanSummary]: + if store.get_repository(repository_id) is None: + raise HTTPException(status_code=404, detail="Repository not found") + return store.repository_scans(repository_id, limit) + + +@app.get("/api/v1/repositories/{repository_id}/trend", response_model=list[TrendPoint]) +def repository_trend( + repository_id: str, + limit: int = Query(default=50, ge=1, le=200), +) -> list[TrendPoint]: + if store.get_repository(repository_id) is None: + raise HTTPException(status_code=404, detail="Repository not found") + return store.trend(repository_id, limit) + + +@app.post("/api/v1/repositories/{repository_id}/scans", response_model=ScanJob, status_code=202) +async def create_repository_scan( + request: Request, + repository_id: str, + payload: RepositoryScanRequest | None = None, +) -> ScanJob: + _require_mutation_authorization(request) + repository = store.get_repository(repository_id) + if repository is None: + raise HTTPException(status_code=404, detail="Repository not found") + if repository.archived: + raise HTTPException(status_code=409, detail="Archived repositories are not scanned") + if not repository.available: + raise HTTPException(status_code=409, detail="Repository is no longer available to the Gitea connector") + if repository.provider == "gitea" and not gitea_client.configured: + raise HTTPException(status_code=503, detail="Gitea connector is not configured") + try: + return _get_runner().enqueue(repository, force=bool(payload and payload.force)) + except MaintenanceModeError as exc: + raise HTTPException(status_code=423, detail=str(exc), headers={"Retry-After": "30"}) from exc + except QueueFullError as exc: + raise HTTPException( + status_code=429, + detail=str(exc), + headers={"Retry-After": "5"}, + ) from exc + + +@app.get("/api/v1/scan-jobs/{job_id}", response_model=ScanJob) +def get_scan_job(job_id: str) -> ScanJob: + job = store.get_job(job_id) + if job is None: + raise HTTPException(status_code=404, detail="Scan job not found") + return job + + +@app.get("/api/v1/repositories/{repository_id}/scans/{scan_id}", response_model=ScanReport) +def get_repository_scan(repository_id: str, scan_id: str) -> ScanReport: + report = store.get(scan_id) + if report is None or report.source.repository_id != repository_id: + raise HTTPException(status_code=404, detail="Repository scan not found") + return report + + +@app.post("/api/v1/webhooks/gitea", response_model=WebhookResponse) +async def gitea_webhook(request: Request) -> WebhookResponse: + secret = os.getenv("REPOPULSE_GITEA_WEBHOOK_SECRET", "") + if not secret: + raise HTTPException(status_code=404, detail="Gitea webhook integration is not configured") + body = await request.body() + if len(body) > 2_000_000: + raise HTTPException(status_code=413, detail="Webhook payload is too large") + signature = request.headers.get("x-gitea-signature", "").removeprefix("sha256=") + expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() + if not signature or not hmac.compare_digest(signature.casefold(), expected.casefold()): + raise HTTPException(status_code=401, detail="Invalid webhook signature") + try: + payload = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise HTTPException(status_code=400, detail="Webhook payload is invalid JSON") from exc + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Webhook payload must be an object") + repository_payload = payload.get("repository") + if not isinstance(repository_payload, dict): + raise HTTPException(status_code=400, detail="Webhook repository is missing") + raw_id = repository_payload.get("id") + repository_id = f"gitea:{raw_id}" if str(raw_id).isdigit() else None + repository = store.get_repository(repository_id) if repository_id else None + if repository is None: + return WebhookResponse( + accepted=False, + message="Repository is not present in the synchronized catalog", + ) + event = request.headers.get("x-gitea-event", "push").casefold() + if event != "push": + return WebhookResponse( + accepted=True, + repository_id=repository.id, + message="Non-push webhook acknowledged without scheduling a scan", + ) + ref = payload.get("ref") + if ref != f"refs/heads/{repository.default_branch}": + return WebhookResponse( + accepted=True, + repository_id=repository.id, + message="Non-default branch push acknowledged without scheduling a scan", + ) + after = payload.get("after") + if ( + not isinstance(after, str) + or not 7 <= len(after) <= 64 + or not all(char in "0123456789abcdefABCDEF" for char in after) + or not set(after) - {"0"} + ): + return WebhookResponse( + accepted=True, + repository_id=repository.id, + message="Push acknowledged without a usable commit revision", + ) + if not gitea_client.configured: + raise HTTPException(status_code=503, detail="Gitea connector is not configured") + updated = repository.model_copy(update={"latest_revision": after, "sync_error": None}) + store.upsert_repository(updated) + try: + job = _get_runner().enqueue(updated, trigger=ScanTrigger.WEBHOOK) + except MaintenanceModeError as exc: + raise HTTPException(status_code=423, detail=str(exc), headers={"Retry-After": "30"}) from exc + except QueueFullError as exc: + raise HTTPException(status_code=429, detail=str(exc), headers={"Retry-After": "5"}) from exc + return WebhookResponse( + accepted=True, + repository_id=repository.id, + job_id=job.id, + message="Push accepted and scan scheduled", + ) + + +@app.post("/api/v1/operator/database-recovery/acknowledge", status_code=status.HTTP_204_NO_CONTENT) +def acknowledge_database_recovery(request: Request) -> Response: + _require_mutation_authorization(request, strict=True) + store.acknowledge_database_recovery() + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@app.get("/api/v1/scans", response_model=list[ScanReport]) +def list_scans(limit: int = Query(default=20, ge=1, le=100)) -> list[ScanReport]: + return store.recent(limit) + + +@app.get("/api/v1/scans/{scan_id}", response_model=ScanReport) +def get_scan(scan_id: str) -> ScanReport: + report = store.get(scan_id) + if report is None: + raise HTTPException(status_code=404, detail="Scan not found") + return report + + +@app.get("/api/v1/scans/{scan_id}/comparison", response_model=ScanComparison) +def get_scan_comparison(scan_id: str) -> ScanComparison: + report = store.get(scan_id) + if report is None: + raise HTTPException(status_code=404, detail="Scan not found") + return store.compare(report) + + +@app.get("/api/v1/scans/{scan_id}/delta") +def get_scan_delta(scan_id: str) -> dict[str, int | None]: + report = store.get(scan_id) + if report is None: + raise HTTPException(status_code=404, detail="Scan not found") + previous = store.previous_for_repository( + report.source.repository_id or report.repository_path, + report.id, + ) + return { + "previous_health_score": previous.health_score if previous else None, + "health_score_delta": report.health_score - previous.health_score if previous else None, + "previous_finding_count": len(previous.findings) if previous else None, + "finding_count_delta": len(report.findings) - len(previous.findings) if previous else None, + } + + +@app.get("/api/v1/trend", response_model=list[TrendPoint]) +def trend(path: str, limit: int = Query(default=30, ge=1, le=100)) -> list[TrendPoint]: + try: + resolved = _resolve_scan_path(path) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + return store.trend(str(resolved), limit) + + +@app.get("/api/v1/scans/{scan_id}/export") +def export_scan(scan_id: str, format: str = Query(default="html", pattern="^(html|json)$")) -> Response: + report = store.get(scan_id) + if report is None: + raise HTTPException(status_code=404, detail="Scan not found") + if format == "json": + filename = safe_report_filename(report.repository_name, "json") + return Response( + report.model_dump_json(indent=2), + media_type="application/json", + headers={"Content-Disposition": content_disposition(filename)}, + ) + filename = safe_report_filename(report.repository_name, "html") + return Response( + render_html_report(report), + media_type="text/html", + headers={"Content-Disposition": content_disposition(filename)}, + ) + + +static_dir = Path(os.getenv("REPOPULSE_STATIC_DIR", "/app/static")) +if static_dir.is_dir(): + app.mount("/", StaticFiles(directory=static_dir, html=True), name="frontend") diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..4468eaa --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +import re +from datetime import datetime +from enum import StrEnum +from hashlib import sha256 + +from pydantic import BaseModel, Field + + +class Severity(StrEnum): + INFO = "info" + WARNING = "warning" + BLOCKER = "blocker" + + +class GateState(StrEnum): + PASS = "pass" + WARN = "warn" + BLOCK = "block" + + +class SourceOrigin(StrEnum): + LOCAL = "local" + GITEA = "gitea" + + +class ScanTrigger(StrEnum): + MANUAL = "manual" + SCHEDULED = "scheduled" + WEBHOOK = "webhook" + LOCAL = "local" + + +class ScanJobStatus(StrEnum): + QUEUED = "queued" + ACQUIRING_SOURCE = "acquiring_source" + SCANNING = "scanning" + ANALYZING = "analyzing" + PERSISTING = "persisting" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + SUPERSEDED = "superseded" + + +class RepositoryState(StrEnum): + NEVER_SCANNED = "never_scanned" + CURRENT = "current" + STALE = "stale" + QUEUED = "queued" + SCANNING = "scanning" + FAILED = "failed" + UNAVAILABLE = "unavailable" + ARCHIVED = "archived" + + +class RepositoryPolicy(BaseModel): + auto_scan: bool = True + min_health_score: int = Field(default=0, ge=0, le=100) + max_blockers: int = Field(default=0, ge=0) + require_tests: bool = False + require_ci: bool = False + suppressions: list[str] = Field(default_factory=list) + + +class RemediationSignal(BaseModel): + """A compact, redacted finding summary safe for catalog-level triage.""" + + code: str + category: str + severity: Severity + message: str + count: int = Field(default=1, ge=1) + guidance: FindingGuidance | None = None + + +class ScanSource(BaseModel): + origin: SourceOrigin = SourceOrigin.LOCAL + provider: str | None = None + repository_id: str | None = None + repository_full_name: str | None = None + branch: str | None = None + requested_revision: str | None = None + actual_revision: str | None = None + trigger: ScanTrigger = ScanTrigger.MANUAL + + +class Repository(BaseModel): + id: str + provider: str + provider_repository_id: str + owner: str + name: str + full_name: str + description: str | None = None + html_url: str | None = None + clone_url: str | None = None + default_branch: str = "main" + private: bool = False + archived: bool = False + available: bool = True + latest_revision: str | None = None + latest_revision_at: datetime | None = None + last_synced_at: datetime | None = None + sync_error: str | None = None + state: RepositoryState = RepositoryState.NEVER_SCANNED + stale: bool = False + active_job_id: str | None = None + last_scan_id: str | None = None + last_scan_at: datetime | None = None + last_scan_health: int | None = None + last_scan_readiness: str | None = None + last_scan_revision: str | None = None + policy: RepositoryPolicy = Field(default_factory=RepositoryPolicy) + + +class RepositorySummary(Repository): + """Catalog row intentionally contains no full scan report.""" + + last_scan_blockers: int = 0 + last_scan_warnings: int = 0 + last_scan_primary_signal: RemediationSignal | None = None + last_scan_health_delta: int | None = None + last_scan_added_findings: int = 0 + last_scan_resolved_findings: int = 0 + last_scan_priority: int = 0 + last_scan_priority_rationale: str | None = None + + +class RepositoryDetail(RepositorySummary): + latest_scan: ScanReport | None = None + + +class ScanJob(BaseModel): + id: str + repository_id: str + status: ScanJobStatus + trigger: ScanTrigger + requested_revision: str | None = None + actual_revision: str | None = None + phase: str = "queued" + progress: int = Field(default=0, ge=0, le=100) + message: str | None = None + created_at: datetime + started_at: datetime | None = None + updated_at: datetime + finished_at: datetime | None = None + duration_ms: float | None = None + scan_id: str | None = None + error_code: str | None = None + error_detail: str | None = None + attempt: int = 0 + deduplicated: bool = False + + +class RepositoryScanRequest(BaseModel): + force: bool = False + + +class RepositorySyncResult(BaseModel): + provider: str + discovered: int = 0 + created: int = 0 + updated: int = 0 + unavailable: int = 0 + errors: list[str] = Field(default_factory=list) + partial: bool = False + branch_failures: int = 0 + listing_complete: bool = True + synced_at: datetime + + +class WebhookResponse(BaseModel): + accepted: bool + repository_id: str | None = None + job_id: str | None = None + message: str + + +class FindingGuidance(BaseModel): + """Backend-owned, safe-to-display explanation of a deterministic rule.""" + + remediation_class: str + confidence: str + why: str + recommended_action: str + priority: int = Field(default=0, ge=0, le=1000) + priority_rationale: str + + +class Finding(BaseModel): + code: str + category: str + severity: Severity + message: str + path: str | None = None + line: int | None = None + deduction: int = Field(default=0, ge=0) + fingerprint: str | None = None + suppressed: bool = False + guidance: FindingGuidance | None = None + + +def finding_fingerprint(finding: Finding) -> str: + """Build a stable semantic identity that ignores line movement and counters.""" + normalized_message = re.sub(r"\b\d+(?:\.\d+)?\b", "#", finding.message.casefold()) + payload = "\0".join( + ( + finding.code, + finding.category, + finding.path or "", + normalized_message, + ) + ) + return sha256(payload.encode("utf-8")).hexdigest()[:32] + + +class RepositoryMetrics(BaseModel): + files: int = 0 + source_files: int = 0 + lines_of_code: int = 0 + test_files: int = 0 + todo: int = 0 + fixme: int = 0 + hack: int = 0 + xxx: int = 0 + dependency_manifests: int = 0 + declared_dependencies: int = 0 + large_files: int = 0 + large_source_files: int = 0 + + +class TestSignals(BaseModel): + frameworks: list[str] = Field(default_factory=list) + coverage_artifacts: list[str] = Field(default_factory=list) + ci_workflows: list[str] = Field(default_factory=list) + ci_validation_workflows: list[str] = Field(default_factory=list) + ci_build_workflows: list[str] = Field(default_factory=list) + ci_deploy_only_workflows: list[str] = Field(default_factory=list) + coverage_evidence: list[str] = Field(default_factory=list) + test_source_ratio: float | None = None + + +class GitSignals(BaseModel): + available: bool = False + commit_count: int | None = None + contributor_count: int | None = None + latest_commit_at: datetime | None = None + tracked_files: int | None = None + renamed_files: list[str] = Field(default_factory=list) + + +class DependencySignals(BaseModel): + manifests: list[str] = Field(default_factory=list) + lockfiles: list[str] = Field(default_factory=list) + declared_dependencies: int = 0 + unbounded_dependencies: int = 0 + unpinned_dependencies: int = 0 + duplicate_dependencies: list[str] = Field(default_factory=list) + included_requirements: list[str] = Field(default_factory=list) + + +class ArchitectureNode(BaseModel): + id: str + label: str + path: str + language: str + inbound: int = 0 + outbound: int = 0 + visible_inbound: int = 0 + visible_outbound: int = 0 + hotspot_score: int = Field(default=0, ge=0, le=100) + + +class ArchitectureEdge(BaseModel): + source: str + target: str + kind: str = "import" + + +class ArchitectureGraph(BaseModel): + nodes: list[ArchitectureNode] = Field(default_factory=list) + edges: list[ArchitectureEdge] = Field(default_factory=list) + clusters: dict[str, int] = Field(default_factory=dict) + truncated: bool = False + total_nodes: int = 0 + total_edges: int = 0 + + +class Hotspot(BaseModel): + path: str + language: str + lines_of_code: int + debt_markers: int = 0 + churn: int = 0 + inbound: int = 0 + outbound: int = 0 + score: int = Field(ge=0, le=100) + reasons: list[str] = Field(default_factory=list) + + +class GateCheck(BaseModel): + key: str + label: str + state: GateState + detail: str + + +class ReleaseGate(BaseModel): + status: str + checks: list[GateCheck] + + +class ScanLimits(BaseModel): + max_files: int = Field(default=10_000, ge=1, le=100_000) + max_text_bytes_per_file: int = Field(default=2_000_000, ge=1_024, le=10_000_000) + max_total_text_bytes: int = Field(default=50_000_000, ge=1_000_000, le=500_000_000) + max_findings: int = Field(default=2_000, ge=10, le=20_000) + max_secret_findings: int = Field(default=20, ge=1, le=500) + + +class ScanCoverage(BaseModel): + limits: ScanLimits + discovered_files: int = 0 + scanned_files: int = 0 + skipped_files_by_budget: int = 0 + skipped_files_by_size: int = 0 + skipped_files_by_text_budget: int = 0 + text_bytes_scanned: int = 0 + truncated: bool = False + notes: list[str] = Field(default_factory=list) + + +class ScanReport(BaseModel): + id: str + repository_path: str + repository_name: str + source: ScanSource = Field(default_factory=ScanSource) + policy: RepositoryPolicy = Field(default_factory=RepositoryPolicy) + scanned_at: datetime + duration_ms: float + metrics: RepositoryMetrics + languages: dict[str, int] + category_scores: dict[str, int] + health_score: int + release_readiness: str + findings: list[Finding] + tests: TestSignals = Field(default_factory=TestSignals) + git: GitSignals = Field(default_factory=GitSignals) + dependencies: DependencySignals = Field(default_factory=DependencySignals) + architecture: ArchitectureGraph = Field(default_factory=ArchitectureGraph) + hotspots: list[Hotspot] = Field(default_factory=list) + release_gate: ReleaseGate | None = None + coverage: ScanCoverage | None = None + compacted: bool = False + + +class ScanSummary(BaseModel): + id: str + repository_id: str + scanned_at: datetime + branch: str | None = None + requested_revision: str | None = None + actual_revision: str | None = None + trigger: ScanTrigger = ScanTrigger.MANUAL + health_score: int + release_readiness: str + findings: int + duration_ms: float + + +class ScanRequest(BaseModel): + path: str = Field(min_length=1) + + +class ScanComparison(BaseModel): + current_scan_id: str + previous_scan_id: str | None = None + health_score_delta: int | None = None + category_score_deltas: dict[str, int] = Field(default_factory=dict) + metric_deltas: dict[str, int] = Field(default_factory=dict) + added_findings: list[Finding] = Field(default_factory=list) + resolved_findings: list[Finding] = Field(default_factory=list) + + +class TrendPoint(BaseModel): + scan_id: str + scanned_at: datetime + health_score: int + findings: int + blockers: int + warnings: int + debt_markers: int + + +RepositoryDetail.model_rebuild() diff --git a/backend/app/policy.py b/backend/app/policy.py new file mode 100644 index 0000000..cbe1b77 --- /dev/null +++ b/backend/app/policy.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from pathlib import Path + +from .models import RepositoryPolicy + +POLICY_NAMES = (".repopulse.yml", ".repopulse.yaml") +MAX_POLICY_BYTES = 64_000 + + +def _scalar(value: str) -> bool | int | str: + normalized = value.strip().strip('"\'') + lowered = normalized.casefold() + if lowered in {"true", "yes", "on"}: + return True + if lowered in {"false", "no", "off"}: + return False + try: + return int(normalized) + except ValueError: + return normalized + + +def load_policy(root: Path) -> RepositoryPolicy: + policy_path = next((root / name for name in POLICY_NAMES if (root / name).is_file()), None) + if policy_path is None: + return RepositoryPolicy() + try: + if policy_path.stat().st_size > MAX_POLICY_BYTES: + return RepositoryPolicy() + lines = policy_path.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + return RepositoryPolicy() + + values: dict[str, object] = {} + suppressions: list[str] = [] + in_suppressions = False + for raw_line in lines: + line = raw_line.split("#", 1)[0].rstrip() + if not line.strip(): + continue + stripped = line.strip() + if stripped == "suppressions:": + in_suppressions = True + continue + if in_suppressions and stripped.startswith("-"): + value = stripped[1:].strip().strip('"\'') + if value: + suppressions.append(value) + continue + in_suppressions = False + if ":" not in stripped: + continue + key, value = stripped.split(":", 1) + key = key.strip() + if key in {"auto_scan", "min_health_score", "max_blockers", "require_tests", "require_ci"}: + values[key] = _scalar(value) + values["suppressions"] = suppressions + try: + return RepositoryPolicy.model_validate(values) + except ValueError: + return RepositoryPolicy() + + +def apply_policy(report, policy: RepositoryPolicy): + findings = [ + item.model_copy( + update={ + "suppressed": item.suppressed or item.code in set(policy.suppressions), + } + ) + for item in report.findings + ] + active_findings = [item for item in findings if not item.suppressed] + scores = {category: 100 for category in report.category_scores} + for finding in active_findings: + scores[finding.category] = max(0, scores[finding.category] - finding.deduction) + health_score = round(sum(scores.values()) / len(scores)) if scores else 100 + from .release_gate import build_release_gate + + release_gate = build_release_gate( + health_score=health_score, + findings=findings, + metrics=report.metrics, + tests=report.tests, + dependencies=report.dependencies, + policy=policy, + ) + return report.model_copy( + update={ + "findings": findings, + "category_scores": scores, + "health_score": health_score, + "release_readiness": release_gate.status, + "release_gate": release_gate, + "policy": policy, + } + ) diff --git a/backend/app/release_gate.py b/backend/app/release_gate.py new file mode 100644 index 0000000..c49d5ec --- /dev/null +++ b/backend/app/release_gate.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from .models import ( + DependencySignals, + Finding, + GateCheck, + GateState, + ReleaseGate, + RepositoryMetrics, + RepositoryPolicy, + ScanCoverage, + Severity, + TestSignals, +) + + +def build_release_gate( + *, + health_score: int, + findings: list[Finding], + metrics: RepositoryMetrics, + tests: TestSignals, + dependencies: DependencySignals, + coverage: ScanCoverage | None = None, + policy: RepositoryPolicy | None = None, +) -> ReleaseGate: + policy = policy or RepositoryPolicy() + active_findings = [item for item in findings if not item.suppressed] + blockers = [item for item in active_findings if item.severity == Severity.BLOCKER] + warnings = [item for item in active_findings if item.severity == Severity.WARNING] + dependency_issues = dependencies.unbounded_dependencies + dependencies.unpinned_dependencies + checks = [ + GateCheck( + key="security", + label="Security basics", + state=GateState.BLOCK if len(blockers) > policy.max_blockers else GateState.PASS, + detail=( + f"{len(blockers)} blocker finding(s), policy allows {policy.max_blockers}" + if blockers + else "No blocker-level secret/environment findings" + ), + ), + GateCheck( + key="tests", + label="Tests" + (" (required)" if policy.require_tests else ""), + state=( + GateState.BLOCK + if policy.require_tests and not metrics.test_files + else GateState.PASS + if metrics.test_files + else GateState.WARN + ), + detail=( + f"{metrics.test_files} test file(s) detected" + if metrics.test_files + else "Tests are required by repository policy" + if policy.require_tests + else "No test files detected" + ), + ), + GateCheck( + key="ci", + label="Continuous integration" + (" (required)" if policy.require_ci else ""), + state=( + GateState.BLOCK + if policy.require_ci and not tests.ci_validation_workflows + else GateState.PASS + if tests.ci_validation_workflows + else GateState.WARN + ), + detail=( + f"{len(tests.ci_validation_workflows)} validation workflow(s) detected" + if tests.ci_validation_workflows + else f"{len(tests.ci_deploy_only_workflows)} deploy-only workflow(s) do not provide validation evidence" + if tests.ci_deploy_only_workflows + else "CI is required by repository policy" + if policy.require_ci + else "No validation workflow detected" + ), + ), + GateCheck( + key="coverage", + label="Coverage evidence", + state=( + GateState.PASS + if tests.coverage_evidence + else GateState.WARN + if metrics.test_files + else GateState.PASS + ), + detail=( + f"{len(tests.coverage_evidence)} coverage evidence source(s) detected" + if tests.coverage_evidence + else "Tests exist but no coverage-producing command or retained coverage output was detected" + if metrics.test_files + else "No test suite is present to produce coverage evidence" + ), + ), + GateCheck( + key="analysis_coverage", + label="Analysis coverage", + state=GateState.WARN if coverage and coverage.truncated else GateState.PASS, + detail=( + "; ".join(coverage.notes) if coverage and coverage.truncated else "Analysis completed within configured limits" + ), + ), + GateCheck( + key="dependencies", + label="Dependency hygiene", + state=GateState.WARN if dependency_issues else GateState.PASS, + detail=( + f"{dependencies.unbounded_dependencies} unbounded and {dependencies.unpinned_dependencies} unpinned declaration(s)" + if dependency_issues + else f"{dependencies.declared_dependencies} dependency declaration(s), no unbounded or unpinned versions" + ), + ), + GateCheck( + key="health", + label="Repository health", + state=( + GateState.PASS + if health_score >= max(85, policy.min_health_score) + else GateState.WARN + if health_score >= max(65, policy.min_health_score) + else GateState.BLOCK + ), + detail=( + f"Health score {health_score}/100" + if not policy.min_health_score + else f"Health score {health_score}/100; policy minimum {policy.min_health_score}" + ), + ), + GateCheck( + key="warnings", + label="Engineering warnings", + state=GateState.WARN if warnings else GateState.PASS, + detail=f"{len(warnings)} warning finding(s)" if warnings else "No warning-level findings", + ), + ] + + if any(check.state == GateState.BLOCK for check in checks): + status = "BLOCKED" + elif any(check.state == GateState.WARN for check in checks): + status = "READY WITH WARNINGS" + else: + status = "READY" + return ReleaseGate(status=status, checks=checks) diff --git a/backend/app/reporting.py b/backend/app/reporting.py new file mode 100644 index 0000000..e7254fa --- /dev/null +++ b/backend/app/reporting.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import re +import unicodedata +from html import escape +from urllib.parse import quote + +from .models import ScanReport + +INVALID_FILENAME_CHARS = re.compile(r'[\x00-\x1f\x7f<>:"/\\|?*]') + +REPORT_MARK = """""" + + +def safe_report_filename(repository_name: str, extension: str) -> str: + cleaned = INVALID_FILENAME_CHARS.sub("_", repository_name).strip(" .") + cleaned = cleaned[:100].rstrip(" .") or "repository" + return f"repopulse-{cleaned}.{extension}" + + +def content_disposition(filename: str) -> str: + fallback = unicodedata.normalize("NFKD", filename).encode("ascii", "ignore").decode("ascii") + fallback = INVALID_FILENAME_CHARS.sub("_", fallback).strip(" .") or "repopulse-report" + return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(filename, safe='')}" + + +def render_html_report(report: ScanReport) -> str: + category_rows = "".join( + f"{escape(name)}{score}/100" + for name, score in report.category_scores.items() + ) + finding_rows = "".join( + "" + f"{escape(item.severity.value)}" + f"{escape(item.category)}" + f"{escape(item.message)}" + f"{escape(item.path or '')}{':' + str(item.line) if item.line else ''}" + "" + for item in report.findings + ) + hotspot_rows = "".join( + "" + f"{escape(item.path)}{escape(item.language)}" + f"{item.score}{item.lines_of_code}{item.churn}" + "" + for item in report.hotspots[:25] + ) + return f""" + + + + +RepoPulse report — {escape(report.repository_name)} + + + +
{REPORT_MARK}RepoPulseRepository intelligence
+

Deterministic repository intelligence report

{escape(report.repository_name)}

{escape(report.repository_path)} · {escape(report.scanned_at.isoformat())}

{report.health_score}
{escape(report.release_readiness)}
+
+
{report.metrics.source_files}
source files
+
{report.metrics.lines_of_code}
LOC
+
{report.metrics.test_files}
test files
+
{len(report.findings)}
findings
+
+

Category scores

{category_rows}
+

Hotspots

{hotspot_rows}
PathLanguageScoreLOCChurn
+

Findings

{finding_rows}
SeverityCategoryFindingLocation
+ +""" diff --git a/backend/app/rules.py b/backend/app/rules.py new file mode 100644 index 0000000..022b59a --- /dev/null +++ b/backend/app/rules.py @@ -0,0 +1,83 @@ +from .models import Finding, FindingGuidance, Severity + +_RULES: dict[str, tuple[str, str, str, str]] = { + "security.secret-assignment": ( + "security remediation", + "A credential-shaped assignment was found in tracked content.", + "Move the value to a secret store or runtime environment and rotate it if it was genuine.", + "high", + ), + "security.env-committed": ( + "security remediation", + "A live environment file is tracked, so its contents require deliberate review.", + "Remove the live file from version control and retain a sanitized .env.example template.", + "high", + ), + "testing.none-detected": ( + "test coverage", + "No conventionally named test source was detected for a repository with source files.", + "Add a focused deterministic smoke test, or record a repository policy that explains why tests do not apply.", + "medium", + ), + "testing.no-ci": ( + "delivery evidence", + "No repository workflow was found that contains a validation command.", + "Add a CI validation workflow that runs the repository's deterministic checks.", + "medium", + ), + "dependency.no-lockfile": ( + "dependency reproducibility", + "A JavaScript manifest was found without a lockfile in its package or workspace root.", + "Commit the package-manager lockfile used by the build.", + "high", + ), + "hygiene.missing-gitignore": ( + "repository hygiene", + "The repository has no root ignore policy.", + "Add a minimal .gitignore for generated files, local settings and build outputs.", + "high", + ), +} + + +def _default_rule(finding: Finding) -> tuple[str, str, str, str]: + if finding.code.startswith("dependency."): + return ( + "dependency hygiene", + "The dependency declaration has a deterministic hygiene signal.", + "Review the declared version and lockfile semantics, then make the smallest reproducible correction.", + "medium", + ) + if finding.code.startswith("architecture."): + return ( + "architecture review", + "The bounded static graph has a structural signal.", + "Inspect the affected module boundary before planning a contained refactor.", + "medium", + ) + return ( + "engineering hygiene", + "A deterministic repository rule produced this signal.", + "Review the retained evidence and make the smallest safe correction.", + "medium", + ) + + +def enrich_finding(finding: Finding) -> Finding: + remediation_class, why, action, confidence = _RULES.get(finding.code, _default_rule(finding)) + severity_weight = {Severity.BLOCKER: 700, Severity.WARNING: 400, Severity.INFO: 100}[finding.severity] + priority = min(1000, severity_weight + finding.deduction * 5) + rationale = f"{finding.severity.value.title()} signal; {finding.deduction} health-point impact." + guidance = FindingGuidance( + remediation_class=remediation_class, + confidence=confidence, + why=why, + recommended_action=action, + priority=priority, + priority_rationale=rationale, + ) + return finding.model_copy(update={"guidance": guidance}) + + +def enrich_findings(findings: list[Finding]) -> list[Finding]: + return [enrich_finding(item) for item in findings] diff --git a/backend/app/scanner.py b/backend/app/scanner.py new file mode 100644 index 0000000..444a89c --- /dev/null +++ b/backend/app/scanner.py @@ -0,0 +1,695 @@ +from __future__ import annotations + +import ast +import fnmatch +import os +import re +import subprocess +from collections import Counter +from datetime import UTC, datetime +from pathlib import Path +from time import perf_counter +from uuid import uuid4 + +from .architecture import build_architecture +from .git_analysis import analyze_git +from .models import ( + Finding, + RepositoryMetrics, + RepositoryPolicy, + ScanCoverage, + ScanLimits, + ScanReport, + ScanSource, + Severity, + finding_fingerprint, +) +from .policy import load_policy +from .release_gate import build_release_gate +from .rules import enrich_findings +from .signals import analyze_dependencies, detect_test_signals + +LANGUAGES = { + ".py": "Python", + ".ts": "TypeScript", + ".tsx": "TypeScript", + ".js": "JavaScript", + ".jsx": "JavaScript", + ".mjs": "JavaScript", + ".cjs": "JavaScript", + ".cs": "C#", + ".java": "Java", + ".go": "Go", + ".rs": "Rust", + ".cpp": "C++", + ".cc": "C++", + ".c": "C", + ".h": "C/C++ Header", + ".hpp": "C++ Header", + ".php": "PHP", + ".rb": "Ruby", + ".kt": "Kotlin", + ".swift": "Swift", + ".vue": "Vue", + ".svelte": "Svelte", + ".html": "HTML", + ".css": "CSS", + ".scss": "SCSS", + ".sh": "Shell", + ".ps1": "PowerShell", + ".sql": "SQL", +} + +IGNORED_DIRS = { + ".git", + ".idea", + ".vscode", + ".venv", + "venv", + "node_modules", + "dist", + "build", + "coverage", + ".next", + ".nuxt", + ".pytest_cache", + "__pycache__", + "vendor", + "target", + "bin", + "obj", +} + +MAX_TEXT_BYTES = 2_000_000 +MAX_SECRET_FINDINGS = 20 + +DEBT_PATTERN = re.compile(r"\b(TODO|FIXME|HACK|XXX)\b") +SECRET_PATTERN = re.compile( + r"""(?ix) + (?: + ^\s*(?:export\s+)?(?:(?:const|let|var|final|static|private|public|protected|internal)\s+)?(?:[A-Za-z_]\w*\.)* + |[,{]\s* + ) + [\"']?(?Papi[_-]?key|secret|password|passwd|token|access[_-]?key)[\"']? + \s*[:=]\s*[\"']?(?P[^\s\"']{8,}) + """ +) +PLACEHOLDER_SECRET_MARKERS = ("example", "placeholder", "dummy", "changeme", "not-a-real", "replace-me", "your-", "fake", "test-") +SECRET_REFERENCE_MARKERS = ( + "os.environ", + "os.getenv", + "getenv", + "process.env", + "env[", + "config.", + "settings.", +) +SECRET_SCAN_CONFIG_SUFFIXES = {".json", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".md", ".txt"} +CATEGORY_NAMES = ( + "Architecture", + "Testing", + "Maintainability", + "Documentation", + "Dependencies", + "Repository hygiene", + "Security basics", +) + + +def _ignored(relative: str) -> bool: + return any(part.casefold() in IGNORED_DIRS for part in Path(relative).parts) + + +def _is_env_template(name: str) -> bool: + return name == ".env.example" or (name.startswith(".env.") and name.endswith(".example")) + + +def _is_secret_scan_candidate(path_obj: Path, relative: str, language: str | None) -> bool: + return language is not None or path_obj.suffix.casefold() in SECRET_SCAN_CONFIG_SUFFIXES or path_obj.name.casefold().startswith(".env") + + +def _bounded_env_int(name: str, default: int, *, minimum: int, maximum: int) -> int: + try: + value = int(os.getenv(name, str(default))) + except ValueError: + value = default + return min(maximum, max(minimum, value)) + + +def scan_limits_from_environment() -> ScanLimits: + """Return bounded analysis limits; input cannot turn a scan into an unbounded job.""" + + return ScanLimits( + max_files=_bounded_env_int("REPOPULSE_SCAN_MAX_FILES", 10_000, minimum=1, maximum=100_000), + max_text_bytes_per_file=_bounded_env_int( + "REPOPULSE_SCAN_MAX_TEXT_BYTES", MAX_TEXT_BYTES, minimum=1_024, maximum=10_000_000 + ), + max_total_text_bytes=_bounded_env_int( + "REPOPULSE_SCAN_MAX_TOTAL_TEXT_BYTES", 50_000_000, minimum=1_000_000, maximum=500_000_000 + ), + max_findings=_bounded_env_int("REPOPULSE_SCAN_MAX_FINDINGS", 2_000, minimum=10, maximum=20_000), + max_secret_findings=_bounded_env_int( + "REPOPULSE_SCAN_MAX_SECRET_FINDINGS", MAX_SECRET_FINDINGS, minimum=1, maximum=500 + ), + ) + + +class _GitIgnoreMatcher: + """Bounded root/nested .gitignore support for non-Git directory scans.""" + + def __init__(self, root: Path) -> None: + self.rules: list[tuple[str, str, bool, bool, bool]] = [] + for current, dirs, names in os.walk(root, followlinks=False): + dirs[:] = [ + name + for name in dirs + if name.casefold() not in IGNORED_DIRS and not (Path(current) / name).is_symlink() + ] + if ".gitignore" not in names: + continue + ignore_file = Path(current) / ".gitignore" + try: + lines = ignore_file.read_text(encoding="utf-8", errors="replace").splitlines() + base_path = Path(current).relative_to(root) + base = "" if str(base_path) == "." else base_path.as_posix() + except (OSError, ValueError): + continue + for raw in lines: + pattern = raw.strip() + if not pattern or pattern.startswith("#"): + continue + negated = pattern.startswith("!") + if negated: + pattern = pattern[1:] + pattern = pattern.replace("\\", "/") + anchored = pattern.startswith("/") + pattern = pattern.lstrip("/") + dir_only = pattern.endswith("/") + pattern = pattern.rstrip("/") + if pattern: + self.rules.append((base, pattern, negated, dir_only, anchored)) + + def is_ignored(self, relative: str, *, is_dir: bool) -> bool: + normalized = relative.replace("\\", "/").strip("/") + ignored = False + for base, pattern, negated, dir_only, anchored in self.rules: + if dir_only and not is_dir: + continue + if base: + if normalized == base: + target = "" + elif normalized.startswith(f"{base}/"): + target = normalized[len(base) + 1 :] + else: + continue + else: + target = normalized + if not target: + continue + if "/" not in pattern: + matched = ( + fnmatch.fnmatch(target, pattern) + if anchored + else any(fnmatch.fnmatch(part, pattern) for part in target.split("/") + ) + ) + else: + matched = fnmatch.fnmatch(target, pattern) + if not matched and pattern.startswith("**/"): + matched = fnmatch.fnmatch(target, pattern[3:]) + if matched: + ignored = not negated + return ignored + + +def _git_worktree_root(root: Path) -> Path | None: + try: + result = subprocess.run( + ["git", "-C", str(root), "rev-parse", "--show-toplevel"], + check=True, + capture_output=True, + timeout=5, + ) + except (OSError, subprocess.SubprocessError): + return None + try: + return Path(result.stdout.decode("utf-8", errors="surrogateescape").strip()).resolve() + except (OSError, ValueError): + return None + + +def _safe_file(root: Path, candidate: Path) -> Path | None: + """Return a resolved regular file only when every path component is safe.""" + try: + relative = candidate.relative_to(root) + if _ignored(str(relative)): + return None + current = root + for part in Path(relative).parts: + current /= part + if current.is_symlink(): + return None + resolved = candidate.resolve(strict=True) + root_resolved = root.resolve() + if resolved == root_resolved or root_resolved not in resolved.parents: + return None + if not resolved.is_file(): + return None + except (OSError, RuntimeError, ValueError): + return None + return resolved + + +def _tracked_files(root: Path) -> list[Path] | None: + if _git_worktree_root(root) != root: + return None + try: + result = subprocess.run( + ["git", "-C", str(root), "ls-files", "-z"], + check=True, + capture_output=True, + timeout=5, + ) + except (OSError, subprocess.SubprocessError): + return None + + paths: list[Path] = [] + for raw in result.stdout.split(b"\0"): + if not raw: + continue + relative = raw.decode("utf-8", errors="surrogateescape") + relative_path = Path(relative) + if relative_path.is_absolute() or _ignored(relative): + continue + safe = _safe_file(root, root / relative_path) + if safe is not None: + paths.append(safe) + return paths + + +def _walk_files(root: Path) -> list[Path]: + files: list[Path] = [] + gitignore = _GitIgnoreMatcher(root) + for current, dirs, names in os.walk(root, followlinks=False): + current_path = Path(current) + dirs[:] = [ + name + for name in dirs + if name.casefold() not in IGNORED_DIRS + and not (current_path / name).is_symlink() + and not gitignore.is_ignored( + str((current_path / name).relative_to(root)), + is_dir=True, + ) + ] + for name in names: + candidate = current_path / name + try: + relative = str(candidate.relative_to(root)) + except (OSError, ValueError): + continue + if _ignored(relative): + continue + if gitignore.is_ignored(relative, is_dir=False): + continue + safe = _safe_file(root, candidate) + if safe is not None: + files.append(safe) + return files + + +def _read_text(path: Path, max_bytes: int = MAX_TEXT_BYTES) -> str | None: + try: + if path.stat().st_size > max_bytes: + return None + raw = path.read_bytes() + except OSError: + return None + if b"\0" in raw[:8192]: + return None + return raw.decode("utf-8", errors="replace") + + +def _is_test_file(relative: str) -> bool: + normalized = relative.replace("\\", "/").lower() + name = Path(normalized).name + return ( + "/tests/" in f"/{normalized}" + or "/test/" in f"/{normalized}" + or name.startswith("test_") + or ".test." in name + or ".spec." in name + or name.endswith(("tests.cs", "test.cs", "_test.go")) + ) + + +def _python_function_findings(relative: str, text: str) -> list[Finding]: + try: + tree = ast.parse(text) + except SyntaxError: + return [] + findings: list[Finding] = [] + branch_types = (ast.If, ast.For, ast.AsyncFor, ast.While, ast.Try, ast.Match, ast.BoolOp) + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + end_line = getattr(node, "end_lineno", node.lineno) + length = max(1, end_line - node.lineno + 1) + branches = sum(1 for child in ast.walk(node) if isinstance(child, branch_types)) + if length >= 140: + findings.append( + Finding( + code="maintainability.long-function", + category="Maintainability", + severity=Severity.WARNING, + message=f"Function {node.name} spans {length} lines", + path=relative, + line=node.lineno, + deduction=4, + ) + ) + if branches >= 20: + findings.append( + Finding( + code="maintainability.branch-heavy-function", + category="Maintainability", + severity=Severity.WARNING, + message=f"Function {node.name} contains {branches} branch constructs", + path=relative, + line=node.lineno, + deduction=4, + ) + ) + return findings[:12] + + +def _is_placeholder_secret(match: re.Match[str]) -> bool: + value = match.group("value").casefold().strip("`$(){}[]<>") + exact_placeholder = value in {"", "none", "null", "undefined", "xxx", "xxxxx"} + obvious_placeholder = any(value.startswith(marker) for marker in PLACEHOLDER_SECRET_MARKERS) + return exact_placeholder or obvious_placeholder or any( + value.startswith(marker) for marker in SECRET_REFERENCE_MARKERS + ) + + +def scan_repository( + path: str, + *, + source: ScanSource | None = None, + display_name: str | None = None, + policy: RepositoryPolicy | None = None, + limits: ScanLimits | None = None, +) -> ScanReport: + started = perf_counter() + root = Path(path).expanduser().resolve() + if not root.exists() or not root.is_dir(): + raise ValueError("Repository path does not exist or is not a directory") + + limits = limits or scan_limits_from_environment() + tracked_files = _tracked_files(root) + all_files = _walk_files(root) if tracked_files is None else tracked_files + all_files.sort(key=lambda item: str(item.relative_to(root)).replace("\\", "/").casefold()) + files = all_files[: limits.max_files] + coverage = ScanCoverage( + limits=limits, + discovered_files=len(all_files), + skipped_files_by_budget=max(0, len(all_files) - len(files)), + truncated=len(all_files) > len(files), + notes=( + [f"File analysis limited to {limits.max_files} of {len(all_files)} discovered safe files."] + if len(all_files) > len(files) + else [] + ), + ) + languages: Counter[str] = Counter() + findings: list[Finding] = [] + metrics = RepositoryMetrics(files=len(all_files)) + files_by_name: dict[str, list[Path]] = {} + root_entries = {entry.name.lower() for entry in root.iterdir()} + text_by_path: dict[str, str] = {} + source_texts: dict[str, str] = {} + language_by_path: dict[str, str] = {} + loc_by_path: dict[str, int] = {} + debt_by_path: dict[str, int] = {} + secret_findings = 0 + secret_findings_truncated = False + text_budget_exhausted = False + + for path_obj in files: + try: + relative = str(path_obj.relative_to(root)).replace("\\", "/") + size = path_obj.stat().st_size + except (OSError, ValueError): + continue + + files_by_name.setdefault(path_obj.name, []).append(path_obj) + suffix = path_obj.suffix.lower() + language = LANGUAGES.get(suffix) + + if size >= 1_000_000: + metrics.large_files += 1 + findings.append( + Finding( + code="hygiene.large-file", + category="Repository hygiene", + severity=Severity.INFO, + message=f"Large tracked file ({size / 1_000_000:.1f} MB)", + path=relative, + deduction=1, + ) + ) + + name = path_obj.name.casefold() + if name == ".env" or (name.startswith(".env.") and not _is_env_template(name)): + findings.append( + Finding( + code="security.env-committed", + category="Security basics", + severity=Severity.BLOCKER, + message="Environment file is tracked; review it for credentials", + path=relative, + deduction=30, + ) + ) + + if size > limits.max_text_bytes_per_file: + coverage.skipped_files_by_size += 1 + continue + if coverage.text_bytes_scanned + size > limits.max_total_text_bytes: + coverage.skipped_files_by_text_budget += 1 + text_budget_exhausted = True + continue + text = _read_text(path_obj, limits.max_text_bytes_per_file) + if text is None: + continue + coverage.scanned_files += 1 + coverage.text_bytes_scanned += len(text.encode("utf-8", errors="replace")) + text_by_path[relative] = text + + if language: + loc = sum(1 for line in text.splitlines() if line.strip()) + metrics.source_files += 1 + metrics.lines_of_code += loc + languages[language] += loc + source_texts[relative] = text + language_by_path[relative] = language + loc_by_path[relative] = loc + if loc > 1000: + metrics.large_source_files += 1 + findings.append( + Finding( + code="maintainability.large-source", + category="Maintainability", + severity=Severity.WARNING, + message=f"Source file has {loc} non-empty lines", + path=relative, + deduction=5, + ) + ) + if language == "Python": + findings.extend(_python_function_findings(relative, text)) + + if _is_test_file(relative): + metrics.test_files += 1 + + debt = Counter(DEBT_PATTERN.findall(text)) + metrics.todo += debt["TODO"] + metrics.fixme += debt["FIXME"] + metrics.hack += debt["HACK"] + metrics.xxx += debt["XXX"] + debt_by_path[relative] = sum(debt.values()) + + if _is_secret_scan_candidate(path_obj, relative, language) and secret_findings < limits.max_secret_findings: + for line_number, line in enumerate(text.splitlines(), start=1): + match = SECRET_PATTERN.search(line) + if match is None or _is_placeholder_secret(match): + continue + findings.append( + Finding( + code="security.secret-assignment", + category="Security basics", + severity=Severity.BLOCKER, + message="Potential credential assignment detected; value intentionally redacted", + path=relative, + line=line_number, + deduction=20, + ) + ) + secret_findings += 1 + if secret_findings >= limits.max_secret_findings: + secret_findings_truncated = True + break + + if text_budget_exhausted: + coverage.truncated = True + coverage.notes.append( + f"Text analysis limited to {limits.max_total_text_bytes} bytes across repository files." + ) + + if secret_findings_truncated: + findings.append( + Finding( + code="security.secret-findings-truncated", + category="Security basics", + severity=Severity.INFO, + message=f"Potential credential findings are capped at {limits.max_secret_findings}", + deduction=0, + ) + ) + + if not any(name.startswith("readme") for name in root_entries): + findings.append(Finding(code="docs.missing-readme", category="Documentation", severity=Severity.WARNING, message="No root README detected", deduction=20)) + if not any(name.startswith(("license", "copying")) for name in root_entries): + findings.append(Finding(code="docs.missing-license", category="Documentation", severity=Severity.INFO, message="No root license file detected", deduction=5)) + if ".gitignore" not in root_entries: + findings.append(Finding(code="hygiene.missing-gitignore", category="Repository hygiene", severity=Severity.WARNING, message="No root .gitignore detected", deduction=15)) + if not any(_is_env_template(name) for name in root_entries) and any(name.startswith(".env") for name in root_entries): + findings.append(Finding(code="hygiene.missing-env-example", category="Repository hygiene", severity=Severity.INFO, message="Environment configuration exists without a root .env.example", deduction=3)) + + test_signals = detect_test_signals( + [str(path.relative_to(root)).replace("\\", "/") for path in files], + metrics.source_files, + metrics.test_files, + text_by_path, + ) + if metrics.source_files and metrics.test_files == 0: + findings.append(Finding(code="testing.none-detected", category="Testing", severity=Severity.WARNING, message="No test files detected", deduction=30)) + if not test_signals.ci_workflows: + findings.append(Finding(code="testing.no-ci", category="Testing", severity=Severity.WARNING, message="No recognized CI workflow detected", deduction=15)) + if metrics.test_files and not test_signals.coverage_artifacts: + findings.append(Finding(code="testing.no-coverage-artifact", category="Testing", severity=Severity.INFO, message="Tests are present but no coverage artifact is tracked", deduction=2)) + + common_source_roots = {"src", "app", "backend", "frontend", "lib", "packages"} + if metrics.source_files >= 10 and not (root_entries & common_source_roots): + findings.append(Finding(code="architecture.flat-layout", category="Architecture", severity=Severity.INFO, message="No conventional source root detected in a non-trivial repository", deduction=8)) + + debt_total = metrics.todo + metrics.fixme + metrics.hack + metrics.xxx + if debt_total: + findings.append(Finding(code="maintainability.debt-markers", category="Maintainability", severity=Severity.INFO, message=f"Detected {debt_total} TODO/FIXME/HACK/XXX markers", deduction=min(20, max(1, debt_total // 5)))) + + dependency_signals, dependency_findings = analyze_dependencies(root, files_by_name) + metrics.dependency_manifests = len(dependency_signals.manifests) + metrics.declared_dependencies = dependency_signals.declared_dependencies + findings.extend(dependency_findings) + + git_signals, churn_by_path = analyze_git(root) + architecture, hotspots = build_architecture( + root, + source_texts, + language_by_path, + loc_by_path, + debt_by_path, + churn_by_path, + ) + for hotspot in hotspots[:5]: + if hotspot.score >= 70: + findings.append( + Finding( + code="maintainability.hotspot", + category="Maintainability", + severity=Severity.WARNING, + message=f"Engineering hotspot score {hotspot.score}/100: {', '.join(hotspot.reasons) or 'combined risk signals'}", + path=hotspot.path, + deduction=4, + ) + ) + + if architecture.truncated: + findings.append( + Finding( + code="architecture.graph-truncated", + category="Architecture", + severity=Severity.INFO, + message="Architecture graph was bounded to keep analysis responsive", + deduction=0, + ) + ) + + severity_rank = {Severity.BLOCKER: 0, Severity.WARNING: 1, Severity.INFO: 2} + if len(findings) > limits.max_findings: + discarded = len(findings) - limits.max_findings + findings.sort(key=lambda item: (severity_rank[item.severity], item.category, item.path or "", item.line or 0)) + findings = findings[: limits.max_findings] + findings.append( + Finding( + code="analysis.findings-truncated", + category="Repository hygiene", + severity=Severity.INFO, + message=f"{discarded} lower-priority findings were omitted by the analysis finding budget", + deduction=0, + ) + ) + coverage.truncated = True + coverage.notes.append(f"Finding output limited to {limits.max_findings} deterministic signals.") + + policy = policy or load_policy(root) + suppressed_codes = set(policy.suppressions) + findings = [ + item.model_copy(update={"suppressed": item.suppressed or item.code in suppressed_codes}) + for item in findings + ] + scores = {category: 100 for category in CATEGORY_NAMES} + for finding in findings: + if not finding.suppressed: + scores[finding.category] = max(0, scores[finding.category] - finding.deduction) + + health_score = round(sum(scores.values()) / len(scores)) + release_gate = build_release_gate( + health_score=health_score, + findings=findings, + metrics=metrics, + tests=test_signals, + dependencies=dependency_signals, + coverage=coverage, + policy=policy, + ) + + findings.sort(key=lambda item: (severity_rank[item.severity], item.category, item.path or "", item.line or 0)) + findings = [ + item.model_copy(update={"fingerprint": item.fingerprint or finding_fingerprint(item)}) + for item in findings + ] + findings = enrich_findings(findings) + + return ScanReport( + id=str(uuid4()), + repository_path=str(root), + repository_name=display_name or root.name, + source=source or ScanSource(), + policy=policy, + scanned_at=datetime.now(UTC), + duration_ms=round((perf_counter() - started) * 1000, 2), + metrics=metrics, + languages=dict(languages.most_common()), + category_scores=scores, + health_score=health_score, + release_readiness=release_gate.status, + findings=findings, + tests=test_signals, + git=git_signals, + dependencies=dependency_signals, + architecture=architecture, + hotspots=hotspots, + release_gate=release_gate, + coverage=coverage, + ) diff --git a/backend/app/signals.py b/backend/app/signals.py new file mode 100644 index 0000000..ce54af2 --- /dev/null +++ b/backend/app/signals.py @@ -0,0 +1,494 @@ +from __future__ import annotations + +import json +import re +import tomllib +import xml.etree.ElementTree as ET +from collections import defaultdict +from pathlib import Path + +from .models import DependencySignals, Finding, Severity, TestSignals + +LOCKFILES = { + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", + "bun.lock", + "bun.lockb", + "uv.lock", + "poetry.lock", + "Pipfile.lock", + "Cargo.lock", + "go.sum", +} +MANIFEST_MAX_BYTES = 2_000_000 +COVERAGE_NAMES = { + "coverage.xml", + "lcov.info", + "cobertura.xml", + ".coverage", + "jacoco.xml", +} +COVERAGE_NAMES_CASEFOLD = {item.casefold() for item in COVERAGE_NAMES} + + +def _python_dependency_name(declaration: str) -> str: + return re.split(r"[<>=!~ @;\[]", declaration, maxsplit=1)[0].strip() + + +def _python_constraint_kind(declaration: str) -> str: + """Classify the useful subset of Python version constraints without penalising bounded ranges.""" + + value = declaration.split(";", maxsplit=1)[0].strip().lower() + if " @ " in value or "==" in value: + return "pinned" + if "~=" in value or "^" in value: + return "bounded" + has_lower = ">" in value + has_upper = "<" in value + if has_lower and has_upper: + return "bounded" + return "unbounded" + + +def _python_dependency_finding(declaration: str, relative: str) -> Finding | None: + if _python_constraint_kind(declaration) != "unbounded": + return None + return Finding( + code="dependency.python-unbounded", + category="Dependencies", + severity=Severity.WARNING, + message=f"Python dependency has no bounded version range: {declaration.split(';')[0]}", + path=relative, + deduction=3, + ) + + +def detect_test_signals( + relative_paths: list[str], + source_files: int, + test_files: int, + text_by_path: dict[str, str], +) -> TestSignals: + frameworks: set[str] = set() + coverage: list[str] = [] + ci: list[str] = [] + ci_validation: list[str] = [] + ci_build: list[str] = [] + ci_deploy_only: list[str] = [] + coverage_evidence: list[str] = [] + + for path in relative_paths: + normalized = path.replace("\\", "/") + name = Path(normalized).name + name_lower = name.casefold() + lower = normalized.lower() + if name_lower in COVERAGE_NAMES_CASEFOLD or "/coverage/" in f"/{lower}": + coverage.append(normalized) + if lower.startswith((".github/workflows/", ".gitea/workflows/")) or name_lower in { + ".gitlab-ci.yml", + "jenkinsfile", + "azure-pipelines.yml", + }: + ci.append(normalized) + + if (name_lower.startswith("test_") or "/tests/" in f"/{lower}") and name_lower.endswith(".py"): + frameworks.add("pytest/unittest") + if (".test." in name_lower or ".spec." in name_lower) and name_lower.endswith( + (".ts", ".tsx", ".js", ".jsx") + ): + frameworks.add("JavaScript test runner") + if name_lower.endswith("_test.go"): + frameworks.add("go test") + if name_lower.endswith(("tests.cs", "test.cs")): + frameworks.add(".NET test") + + for path, text in text_by_path.items(): + name = Path(path).name + normalized = path.replace("\\", "/") + lowered_text = text.casefold() + if normalized in ci: + validates = any( + token in lowered_text + for token in ("pytest", "vitest", "jest", "npm test", "pnpm test", "go test", "dotnet test", "ruff", "eslint", "typecheck", "py_compile") + ) + builds = any( + token in lowered_text + for token in ("npm run build", "pnpm build", "docker build", "vite build", "cargo build", "go build", "dotnet build") + ) + deploys = any(token in lowered_text for token in ("deploy", "release", "publish", "kubectl apply")) + if validates: + ci_validation.append(normalized) + if builds: + ci_build.append(normalized) + if deploys and not validates: + ci_deploy_only.append(normalized) + if any(token in lowered_text for token in ("--cov", "coverage", "lcov", "codecov")): + coverage_evidence.append(normalized) + if name.casefold() in COVERAGE_NAMES_CASEFOLD: + coverage_evidence.append(normalized) + if name.casefold() == "package.json": + try: + data = json.loads(text) + except (TypeError, json.JSONDecodeError): + continue + if not isinstance(data, dict): + continue + deps: dict[object, object] = {} + for section_name in ( + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + ): + section = data.get(section_name, {}) + if isinstance(section, dict): + deps.update(section) + if "vitest" in deps: + frameworks.add("Vitest") + if "jest" in deps: + frameworks.add("Jest") + if "@playwright/test" in deps: + frameworks.add("Playwright") + if "cypress" in deps: + frameworks.add("Cypress") + elif name.casefold() == "pyproject.toml": + lowered = text.lower() + if "pytest" in lowered: + frameworks.add("pytest") + + ratio = round((test_files / source_files) * 100, 2) if source_files else None + return TestSignals( + frameworks=sorted(frameworks), + coverage_artifacts=sorted(set(coverage))[:30], + ci_workflows=sorted(set(ci))[:30], + ci_validation_workflows=sorted(set(ci_validation))[:30], + ci_build_workflows=sorted(set(ci_build))[:30], + ci_deploy_only_workflows=sorted(set(ci_deploy_only))[:30], + coverage_evidence=sorted(set(coverage_evidence))[:30], + test_source_ratio=ratio, + ) + + +def analyze_dependencies( + root: Path, + files_by_name: dict[str, list[Path]], +) -> tuple[DependencySignals, list[Finding]]: + findings: list[Finding] = [] + manifests: list[str] = [] + lockfiles: list[str] = [] + dependency_origins: dict[str, set[str]] = defaultdict(set) + declared = 0 + unbounded = 0 + unpinned = 0 + + for lock_name in sorted(LOCKFILES): + for path in files_by_name.get(lock_name, []): + lockfiles.append(str(path.relative_to(root)).replace("\\", "/")) + + for package_json in files_by_name.get("package.json", []): + relative = str(package_json.relative_to(root)).replace("\\", "/") + manifests.append(relative) + try: + if package_json.stat().st_size > MANIFEST_MAX_BYTES: + raise ValueError("package.json is too large to parse") + data = json.loads(package_json.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise TypeError("package.json root must be an object") + except (OSError, UnicodeDecodeError, json.JSONDecodeError, TypeError): + findings.append( + Finding( + code="dependency.invalid-package-json", + category="Dependencies", + severity=Severity.WARNING, + message="package.json could not be parsed safely", + path=relative, + deduction=8, + ) + ) + continue + deps: dict[str, object] = {} + invalid_sections = False + for section_name in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"): + section = data.get(section_name, {}) + if section is None: + continue + if not isinstance(section, dict): + invalid_sections = True + continue + deps.update(section) + if invalid_sections: + findings.append( + Finding( + code="dependency.invalid-package-json", + category="Dependencies", + severity=Severity.WARNING, + message="One or more package dependency sections are not objects", + path=relative, + deduction=8, + ) + ) + declared += len(deps) + for name, version in deps.items(): + dependency_name = str(name) + dependency_origins[dependency_name.casefold()].add(relative) + raw = str(version).strip().lower() + if raw in {"*", "latest", "x"} and not raw.startswith("workspace:"): + unbounded += 1 + findings.append( + Finding( + code="dependency.unbounded", + category="Dependencies", + severity=Severity.WARNING, + message=f"Dependency {dependency_name} uses an unbounded version", + path=relative, + deduction=5, + ) + ) + parent = package_json.parent + package_roots = [parent, *[ancestor for ancestor in parent.parents if ancestor == root or root in ancestor.parents]] + if not any( + (package_root / lock).exists() + for package_root in package_roots + for lock in ("package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb") + ): + findings.append(Finding(code="dependency.no-lockfile", category="Dependencies", severity=Severity.WARNING, message="JavaScript package has no recognized lockfile", path=relative, deduction=8)) + + requirement_paths = [ + path + for name, values in files_by_name.items() + if name.casefold().startswith("requirements") and name.casefold().endswith(".txt") + for path in values + ] + included_requirements: list[str] = [] + processed_requirements: set[Path] = set() + pending_requirements = list(requirement_paths) + while pending_requirements: + requirements = pending_requirements.pop(0) + try: + requirements = requirements.resolve(strict=True) + except OSError: + continue + if requirements in processed_requirements or requirements != root and root not in requirements.parents: + continue + processed_requirements.add(requirements) + relative = str(requirements.relative_to(root)).replace("\\", "/") + manifests.append(relative) + try: + lines = requirements.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + findings.append( + Finding( + code="dependency.unreadable-requirements", + category="Dependencies", + severity=Severity.WARNING, + message="Python requirements file could not be read", + path=relative, + deduction=8, + ) + ) + continue + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if stripped.startswith(("-r ", "--requirement ")): + include = stripped.split(maxsplit=1)[1].strip() + candidate = (requirements.parent / include).resolve() + if candidate == root or root in candidate.parents: + pending_requirements.append(candidate) + included_requirements.append(str(candidate.relative_to(root)).replace("\\", "/")) + continue + if stripped.startswith("-"): + continue + declared += 1 + name = _python_dependency_name(stripped) + if name: + dependency_origins[name.casefold()].add(relative) + finding = _python_dependency_finding(stripped, relative) + if finding: + unbounded += 1 + findings.append(finding) + + for pyproject in files_by_name.get("pyproject.toml", []): + relative = str(pyproject.relative_to(root)).replace("\\", "/") + manifests.append(relative) + try: + if pyproject.stat().st_size > MANIFEST_MAX_BYTES: + raise ValueError("pyproject.toml is too large to parse") + data = tomllib.loads(pyproject.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise TypeError("pyproject.toml root must be a table") + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError, TypeError): + findings.append( + Finding( + code="dependency.invalid-pyproject", + category="Dependencies", + severity=Severity.WARNING, + message="pyproject.toml could not be parsed safely", + path=relative, + deduction=8, + ) + ) + continue + project = data.get("project", {}) + if not isinstance(project, dict): + findings.append( + Finding( + code="dependency.invalid-pyproject", + category="Dependencies", + severity=Severity.WARNING, + message="pyproject.toml project section is not a table", + path=relative, + deduction=8, + ) + ) + project = {} + project_deps = project.get("dependencies", []) + if isinstance(project_deps, list): + declared += len(project_deps) + for dependency in project_deps: + declaration = str(dependency) + name = _python_dependency_name(declaration) + if name: + dependency_origins[name.casefold()].add(relative) + finding = _python_dependency_finding(declaration, relative) + if finding: + unbounded += 1 + findings.append(finding) + elif project_deps is not None: + findings.append( + Finding( + code="dependency.invalid-pyproject", + category="Dependencies", + severity=Severity.WARNING, + message="pyproject.toml project.dependencies is not a list", + path=relative, + deduction=8, + ) + ) + + optional_deps = project.get("optional-dependencies", {}) + if isinstance(optional_deps, dict): + for dependencies in optional_deps.values(): + if not isinstance(dependencies, list): + continue + declared += len(dependencies) + for dependency in dependencies: + declaration = str(dependency) + name = _python_dependency_name(declaration) + if name: + dependency_origins[name.casefold()].add(relative) + finding = _python_dependency_finding(declaration, relative) + if finding: + unbounded += 1 + findings.append(finding) + elif optional_deps is not None: + findings.append( + Finding( + code="dependency.invalid-pyproject", + category="Dependencies", + severity=Severity.WARNING, + message="pyproject.toml project.optional-dependencies is not a table", + path=relative, + deduction=8, + ) + ) + + for go_mod in files_by_name.get("go.mod", []): + relative = str(go_mod.relative_to(root)).replace("\\", "/") + manifests.append(relative) + try: + text = go_mod.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + findings.append( + Finding( + code="dependency.unreadable-go-mod", + category="Dependencies", + severity=Severity.WARNING, + message="go.mod could not be read", + path=relative, + deduction=8, + ) + ) + continue + matches = list(re.finditer(r"(?m)^\s*([\w./-]+)\s+v\d[^\s]*", text)) + if "require" in text and not matches: + findings.append( + Finding( + code="dependency.invalid-go-mod", + category="Dependencies", + severity=Severity.WARNING, + message="go.mod contains no parseable versioned dependencies", + path=relative, + deduction=8, + ) + ) + for match in matches: + declared += 1 + dependency_origins[match.group(1).casefold()].add(relative) + + for cargo in files_by_name.get("Cargo.toml", []): + relative = str(cargo.relative_to(root)).replace("\\", "/") + manifests.append(relative) + try: + data = tomllib.loads(cargo.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError): + findings.append( + Finding( + code="dependency.invalid-cargo-toml", + category="Dependencies", + severity=Severity.WARNING, + message="Cargo.toml could not be parsed safely", + path=relative, + deduction=8, + ) + ) + continue + cargo_deps = data.get("dependencies", {}) + if isinstance(cargo_deps, dict): + declared += len(cargo_deps) + for name in cargo_deps: + dependency_origins[str(name).casefold()].add(relative) + + for pom in files_by_name.get("pom.xml", []): + relative = str(pom.relative_to(root)).replace("\\", "/") + manifests.append(relative) + try: + xml_root = ET.parse(pom).getroot() + except (OSError, UnicodeError, ET.ParseError): + findings.append( + Finding( + code="dependency.invalid-pom", + category="Dependencies", + severity=Severity.WARNING, + message="pom.xml could not be parsed safely", + path=relative, + deduction=8, + ) + ) + continue + dependencies = [element for element in xml_root.iter() if element.tag.split("}")[-1] == "dependency"] + declared += len(dependencies) + for dependency in dependencies: + artifact = next((child.text for child in dependency if child.tag.split("}")[-1] == "artifactId"), None) + if artifact: + dependency_origins[artifact.casefold()].add(relative) + + duplicates = sorted(name for name, origins in dependency_origins.items() if len(origins) > 1) + for name in duplicates[:25]: + origins = sorted(dependency_origins[name]) + findings.append(Finding(code="dependency.duplicate-manifest", category="Dependencies", severity=Severity.INFO, message=f"Dependency {name} is declared across multiple manifests", path=origins[0], deduction=1)) + + return ( + DependencySignals( + manifests=sorted(set(manifests)), + lockfiles=sorted(set(lockfiles)), + declared_dependencies=declared, + unbounded_dependencies=unbounded, + unpinned_dependencies=unpinned, + duplicate_dependencies=duplicates[:50], + included_requirements=sorted(set(included_requirements))[:50], + ), + findings, + ) diff --git a/backend/app/store.py b/backend/app/store.py new file mode 100644 index 0000000..fd5594c --- /dev/null +++ b/backend/app/store.py @@ -0,0 +1,1527 @@ +from __future__ import annotations + +import json +import os +import sqlite3 +from collections import Counter +from collections.abc import Iterator +from contextlib import contextmanager +from datetime import UTC, datetime, timedelta +from hashlib import sha256 +from pathlib import Path +from uuid import uuid4 + +from .models import ( + ArchitectureGraph, + RemediationSignal, + Repository, + RepositoryDetail, + RepositoryPolicy, + RepositoryState, + RepositorySummary, + RepositorySyncResult, + ScanComparison, + ScanJob, + ScanJobStatus, + ScanReport, + ScanSummary, + ScanTrigger, + Severity, + SourceOrigin, + TrendPoint, + finding_fingerprint, +) +from .rules import enrich_findings + +MAX_HISTORY_PER_REPOSITORY = 200 +SCHEMA_VERSION = 4 +JOB_LEASE_SECONDS = 900 +ACTIVE_JOB_STATUSES = ( + ScanJobStatus.QUEUED.value, + ScanJobStatus.ACQUIRING_SOURCE.value, + ScanJobStatus.SCANNING.value, + ScanJobStatus.ANALYZING.value, + ScanJobStatus.PERSISTING.value, +) + + +class QueueFullError(RuntimeError): + pass + + +class MaintenanceModeError(RuntimeError): + """Raised when a new scan is requested while durable maintenance is active.""" + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _iso(value: datetime | None) -> str | None: + return value.isoformat() if value else None + + +def _datetime(value: str | None) -> datetime | None: + if not value: + return None + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return None + return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed + + +def local_repository_id(path: str) -> str: + resolved = str(Path(path).expanduser().resolve()) + return f"local:{sha256(resolved.encode('utf-8')).hexdigest()[:24]}" + + +def _bounded_setting(name: str, default: int, *, minimum: int, maximum: int) -> int: + try: + value = int(os.getenv(name, str(default))) + except ValueError: + value = default + return min(maximum, max(minimum, value)) + + +def history_per_repository() -> int: + return _bounded_setting("REPOPULSE_HISTORY_PER_REPOSITORY", MAX_HISTORY_PER_REPOSITORY, minimum=1, maximum=2_000) + + +def full_reports_per_repository() -> int: + return _bounded_setting("REPOPULSE_FULL_REPORTS_PER_REPOSITORY", 30, minimum=1, maximum=500) + + +def report_retention_days() -> int: + return _bounded_setting("REPOPULSE_REPORT_RETENTION_DAYS", 365, minimum=1, maximum=3_650) + + +def job_lease_seconds() -> int: + return _bounded_setting("REPOPULSE_JOB_LEASE_SECONDS", JOB_LEASE_SECONDS, minimum=30, maximum=3_600) + + +class ScanStore: + """SQLite-backed catalog, durable scan history and recoverable job queue.""" + + def __init__(self, db_path: str | None = None) -> None: + raw_path = db_path or os.getenv("REPOPULSE_DB", "data/repopulse.db") + self.path = Path(raw_path) + self.path.parent.mkdir(parents=True, exist_ok=True) + self.recovery_metadata: dict[str, str] | None = None + try: + self._validate_existing_database() + self._init_schema() + except sqlite3.DatabaseError as exc: + if not self._is_corruption_error(exc): + raise + backup = self._quarantine_corrupt_database() + self._init_schema() + self.recovery_metadata = { + "status": "degraded_recovered", + "detected_at": _now().isoformat(), + "quarantined_database": backup.name if backup else "unavailable", + "operator_action": "Verify the quarantined database, restore it if needed, then acknowledge recovery.", + } + self.set_state("database_recovery", self.recovery_metadata) + + @staticmethod + def _is_corruption_error(error: sqlite3.DatabaseError) -> bool: + message = str(error).casefold() + return "not a database" in message or "malformed" in message or "corrupt" in message + + def _quarantine_corrupt_database(self) -> Path | None: + if not self.path.exists(): + return None + stamp = _now().strftime("%Y%m%dT%H%M%S%fZ") + backup = self.path.with_name(f"{self.path.name}.corrupt-{stamp}-{uuid4().hex[:8]}") + self.path.replace(backup) + for suffix in ("-wal", "-shm"): + sidecar = Path(f"{self.path}{suffix}") + if sidecar.exists(): + sidecar.replace(Path(f"{backup}{suffix}")) + return backup + + def _validate_existing_database(self) -> None: + if not self.path.exists() or self.path.stat().st_size == 0: + return + connection = sqlite3.connect(self.path, timeout=5.0) + try: + row = connection.execute("PRAGMA quick_check").fetchone() + if row is None or str(row[0]).casefold() != "ok": + raise sqlite3.DatabaseError("database quick_check reported corruption") + finally: + connection.close() + + def _backup_before_migration(self, db: sqlite3.Connection, version: int) -> Path: + stamp = _now().strftime("%Y%m%dT%H%M%S%fZ") + backup = self.path.with_name(f"{self.path.name}.pre-v{version}-{stamp}.bak") + destination = sqlite3.connect(backup) + try: + db.backup(destination) + finally: + destination.close() + return backup + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self.path, timeout=5.0) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA busy_timeout = 5000") + return connection + + @contextmanager + def _connection(self) -> Iterator[sqlite3.Connection]: + connection = self._connect() + try: + yield connection + connection.commit() + except BaseException: + connection.rollback() + raise + finally: + connection.close() + + @staticmethod + def _columns(db: sqlite3.Connection, table: str) -> set[str]: + return {str(row[1]) for row in db.execute(f"PRAGMA table_info({table})").fetchall()} + + def _init_schema(self) -> None: + with self._connection() as db: + current_version = int(db.execute("PRAGMA user_version").fetchone()[0]) + migration_backup: Path | None = None + if current_version > SCHEMA_VERSION: + raise RuntimeError( + f"Database schema version {current_version} is newer than supported version {SCHEMA_VERSION}" + ) + if current_version and current_version < SCHEMA_VERSION: + migration_backup = self._backup_before_migration(db, current_version) + db.execute("PRAGMA journal_mode = WAL") + db.execute("PRAGMA synchronous = NORMAL") + db.execute( + """ + CREATE TABLE IF NOT EXISTS scans ( + id TEXT PRIMARY KEY, + repository_path TEXT NOT NULL, + repository_name TEXT NOT NULL, + scanned_at TEXT NOT NULL, + health_score INTEGER NOT NULL, + report_json TEXT NOT NULL + ) + """ + ) + scan_columns = self._columns(db, "scans") + additions = { + "repository_id": "TEXT", + "history_key": "TEXT", + "source_origin": "TEXT", + "provider": "TEXT", + "branch": "TEXT", + "requested_revision": "TEXT", + "actual_revision": "TEXT", + "trigger": "TEXT", + "finding_count": "INTEGER", + "blocker_count": "INTEGER", + "warning_count": "INTEGER", + "release_readiness": "TEXT", + "primary_signal_json": "TEXT", + "added_findings": "INTEGER", + "resolved_findings": "INTEGER", + "triage_priority": "INTEGER", + "triage_rationale": "TEXT", + "report_compacted": "INTEGER NOT NULL DEFAULT 0", + } + for name, definition in additions.items(): + if name not in scan_columns: + db.execute(f"ALTER TABLE scans ADD COLUMN {name} {definition}") + + db.execute( + """ + CREATE TABLE IF NOT EXISTS repositories ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + provider_repository_id TEXT NOT NULL, + owner TEXT NOT NULL, + name TEXT NOT NULL, + full_name TEXT NOT NULL, + description TEXT, + html_url TEXT, + clone_url TEXT, + default_branch TEXT NOT NULL DEFAULT 'main', + private INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + latest_revision TEXT, + latest_revision_at TEXT, + last_synced_at TEXT, + sync_error TEXT, + policy_json TEXT NOT NULL DEFAULT '{}', + source_path TEXT, + UNIQUE(provider, provider_repository_id) + ) + """ + ) + repository_columns = self._columns(db, "repositories") + repository_additions = { + "description": "TEXT", + "available": "INTEGER NOT NULL DEFAULT 1", + } + for name, definition in repository_additions.items(): + if name not in repository_columns: + db.execute(f"ALTER TABLE repositories ADD COLUMN {name} {definition}") + db.execute( + """ + CREATE TABLE IF NOT EXISTS scan_jobs ( + id TEXT PRIMARY KEY, + repository_id TEXT NOT NULL, + status TEXT NOT NULL, + trigger TEXT NOT NULL, + requested_revision TEXT, + actual_revision TEXT, + phase TEXT NOT NULL, + progress INTEGER NOT NULL DEFAULT 0, + message TEXT, + created_at TEXT NOT NULL, + started_at TEXT, + updated_at TEXT NOT NULL, + finished_at TEXT, + duration_ms REAL, + scan_id TEXT, + error_code TEXT, + error_detail TEXT, + attempt INTEGER NOT NULL DEFAULT 0, + lease_until TEXT, + deduplicated INTEGER NOT NULL DEFAULT 0 + ) + """ + ) + job_columns = self._columns(db, "scan_jobs") + if "duration_ms" not in job_columns: + db.execute("ALTER TABLE scan_jobs ADD COLUMN duration_ms REAL") + db.execute( + """ + CREATE TABLE IF NOT EXISTS app_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + if migration_backup: + db.execute( + """ + INSERT INTO app_state (key, value, updated_at) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + """, + ( + "last_schema_migration_backup", + json.dumps( + { + "from_version": current_version, + "to_version": SCHEMA_VERSION, + "backup_file": migration_backup.name, + "created_at": _now().isoformat(), + }, + separators=(",", ":"), + ), + _now().isoformat(), + ), + ) + db.execute( + "CREATE INDEX IF NOT EXISTS idx_scans_repository_time ON scans(repository_path, scanned_at DESC)" + ) + db.execute( + "CREATE INDEX IF NOT EXISTS idx_scans_repository_id_time ON scans(repository_id, scanned_at DESC)" + ) + db.execute( + "CREATE INDEX IF NOT EXISTS idx_scans_history_time ON scans(history_key, scanned_at DESC)" + ) + db.execute( + "CREATE INDEX IF NOT EXISTS idx_scans_repository_revision ON scans(repository_id, actual_revision)" + ) + db.execute( + "CREATE INDEX IF NOT EXISTS idx_jobs_queue ON scan_jobs(status, created_at)" + ) + db.execute( + "CREATE INDEX IF NOT EXISTS idx_jobs_repository ON scan_jobs(repository_id, created_at DESC)" + ) + self._backfill_legacy_catalog(db) + self._backfill_scan_summaries(db) + db.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") + + def _backfill_legacy_catalog(self, db: sqlite3.Connection) -> None: + rows = db.execute( + """ + SELECT id, repository_path, repository_name, report_json, repository_id, history_key + FROM scans + WHERE repository_id IS NULL OR history_key IS NULL + """ + ).fetchall() + for row in rows: + repository_id = row["repository_id"] or local_repository_id(row["repository_path"]) + history_key = row["history_key"] or repository_id + self._upsert_local_repository_row( + db, + repository_id, + row["repository_name"], + row["repository_path"], + ) + db.execute( + """ + UPDATE scans + SET repository_id = ?, history_key = ?, source_origin = COALESCE(source_origin, 'local'), + provider = COALESCE(provider, 'local'), trigger = COALESCE(trigger, 'local') + WHERE id = ? + """, + (repository_id, history_key, row["id"]), + ) + + @staticmethod + def _summary_fields(report: ScanReport, previous: ScanReport | None = None) -> dict[str, object]: + report = report.model_copy(update={"findings": enrich_findings(report.findings)}) + active = [item for item in report.findings if not item.suppressed] + severity_rank = {Severity.BLOCKER: 0, Severity.WARNING: 1, Severity.INFO: 2} + primary = min( + active, + key=lambda item: (severity_rank[item.severity], -item.deduction, item.category, item.code), + default=None, + ) + primary_signal = ( + RemediationSignal( + code=primary.code, + category=primary.category, + severity=primary.severity, + message=primary.message, + count=sum(1 for item in active if item.code == primary.code), + guidance=primary.guidance, + ) + if primary + else None + ) + previous_active = [item for item in (previous.findings if previous else []) if not item.suppressed] + active_keys = {finding_fingerprint(item) for item in active} + previous_keys = {finding_fingerprint(item) for item in previous_active} + guidance = primary_signal.guidance if primary_signal else None + return { + "finding_count": len(active), + "blocker_count": sum(1 for item in active if item.severity == Severity.BLOCKER), + "warning_count": sum(1 for item in active if item.severity == Severity.WARNING), + "release_readiness": report.release_gate.status if report.release_gate else report.release_readiness, + "primary_signal_json": primary_signal.model_dump_json() if primary_signal else None, + "added_findings": len(active_keys - previous_keys) if previous else 0, + "resolved_findings": len(previous_keys - active_keys) if previous else 0, + "triage_priority": guidance.priority if guidance else 0, + "triage_rationale": guidance.priority_rationale if guidance else None, + } + + def _backfill_scan_summaries(self, db: sqlite3.Connection) -> None: + rows = db.execute( + "SELECT rowid AS scan_rowid, * FROM scans " + "WHERE finding_count IS NULL OR blocker_count IS NULL OR release_readiness IS NULL" + ).fetchall() + for row in rows: + report = self._report_from_row(row) + previous_row = db.execute( + """ + SELECT * FROM scans + WHERE history_key = ? AND ( + scanned_at < ? OR (scanned_at = ? AND rowid < ?) + ) + ORDER BY scanned_at DESC, rowid DESC LIMIT 1 + """, + (row["history_key"], row["scanned_at"], row["scanned_at"], row["scan_rowid"]), + ).fetchone() + previous = self._report_from_row(previous_row) if previous_row else None + fields = self._summary_fields(report, previous) + db.execute( + """ + UPDATE scans SET finding_count = ?, blocker_count = ?, warning_count = ?, release_readiness = ?, + primary_signal_json = ?, added_findings = ?, resolved_findings = ?, triage_priority = ?, + triage_rationale = ? WHERE id = ? + """, + (*fields.values(), row["id"]), + ) + + @staticmethod + def _upsert_local_repository_row( + db: sqlite3.Connection, + repository_id: str, + name: str, + source_path: str, + ) -> None: + db.execute( + """ + INSERT INTO repositories ( + id, provider, provider_repository_id, owner, name, full_name, + default_branch, source_path + ) VALUES (?, 'local', ?, 'local', ?, ?, 'main', ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + full_name = excluded.full_name, + source_path = COALESCE(excluded.source_path, repositories.source_path) + """, + (repository_id, repository_id.removeprefix("local:"), name, f"local/{name}", source_path), + ) + + @staticmethod + def _upsert_repository_row( + db: sqlite3.Connection, + repository: Repository, + source_path: str | None = None, + ) -> None: + db.execute( + """ + INSERT INTO repositories ( + id, provider, provider_repository_id, owner, name, full_name, + description, html_url, clone_url, default_branch, private, archived, available, + latest_revision, latest_revision_at, last_synced_at, sync_error, + policy_json, source_path + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + provider = excluded.provider, + provider_repository_id = excluded.provider_repository_id, + owner = excluded.owner, + name = excluded.name, + full_name = excluded.full_name, + description = excluded.description, + html_url = excluded.html_url, + clone_url = excluded.clone_url, + default_branch = excluded.default_branch, + private = excluded.private, + archived = excluded.archived, + available = excluded.available, + latest_revision = COALESCE(excluded.latest_revision, repositories.latest_revision), + latest_revision_at = COALESCE(excluded.latest_revision_at, repositories.latest_revision_at), + last_synced_at = COALESCE(excluded.last_synced_at, repositories.last_synced_at), + sync_error = excluded.sync_error, + policy_json = excluded.policy_json, + source_path = COALESCE(excluded.source_path, repositories.source_path) + """, + ( + repository.id, + repository.provider, + repository.provider_repository_id, + repository.owner, + repository.name, + repository.full_name, + repository.description, + repository.html_url, + repository.clone_url, + repository.default_branch, + int(repository.private), + int(repository.archived), + int(repository.available), + repository.latest_revision, + _iso(repository.latest_revision_at), + _iso(repository.last_synced_at), + repository.sync_error, + repository.policy.model_dump_json(), + source_path, + ), + ) + + def upsert_repository(self, repository: Repository, *, source_path: str | None = None) -> Repository: + with self._connection() as db: + self._upsert_repository_row(db, repository, source_path) + row = db.execute("SELECT * FROM repositories WHERE id = ?", (repository.id,)).fetchone() + return self._repository_from_row(row) if row else repository + + @staticmethod + def _repository_from_row(row: sqlite3.Row) -> Repository: + try: + policy = RepositoryPolicy.model_validate_json(row["policy_json"] or "{}") + except (TypeError, ValueError): + policy = RepositoryPolicy() + return Repository( + id=row["id"], + provider=row["provider"], + provider_repository_id=row["provider_repository_id"], + owner=row["owner"], + name=row["name"], + full_name=row["full_name"], + description=row["description"], + html_url=row["html_url"], + clone_url=row["clone_url"], + default_branch=row["default_branch"] or "main", + private=bool(row["private"]), + archived=bool(row["archived"]), + available=bool(row["available"]), + latest_revision=row["latest_revision"], + latest_revision_at=_datetime(row["latest_revision_at"]), + last_synced_at=_datetime(row["last_synced_at"]), + sync_error=row["sync_error"], + policy=policy, + ) + + def get_repository(self, repository_id: str) -> Repository | None: + with self._connection() as db: + row = db.execute("SELECT * FROM repositories WHERE id = ?", (repository_id,)).fetchone() + return self._repository_from_row(row) if row else None + + def get_repository_source_path(self, repository_id: str) -> str | None: + with self._connection() as db: + row = db.execute("SELECT source_path FROM repositories WHERE id = ?", (repository_id,)).fetchone() + return row["source_path"] if row else None + + def _repository_summary(self, db: sqlite3.Connection, row: sqlite3.Row) -> RepositorySummary: + repository = self._repository_from_row(row) + latest = db.execute( + """ + SELECT * FROM scans WHERE repository_id = ? + ORDER BY scanned_at DESC, rowid DESC LIMIT 1 + """, + (repository.id,), + ).fetchone() + active = db.execute( + """ + SELECT * FROM scan_jobs WHERE repository_id = ? AND status IN (?, ?, ?, ?, ?) + ORDER BY created_at DESC LIMIT 1 + """, + (repository.id, *ACTIVE_JOB_STATUSES), + ).fetchone() + last_job = db.execute( + "SELECT * FROM scan_jobs WHERE repository_id = ? ORDER BY created_at DESC LIMIT 1", + (repository.id,), + ).fetchone() + last_scan = self._report_from_row(latest) if latest else None + active_findings = [item for item in (last_scan.findings if last_scan else []) if not item.suppressed] + severity_rank = {Severity.BLOCKER: 0, Severity.WARNING: 1, Severity.INFO: 2} + primary_finding = min( + active_findings, + key=lambda item: (severity_rank[item.severity], -item.deduction, item.category, item.code), + default=None, + ) + primary_signal = ( + RemediationSignal( + code=primary_finding.code, + category=primary_finding.category, + severity=primary_finding.severity, + message=primary_finding.message, + count=sum(1 for item in active_findings if item.code == primary_finding.code), + ) + if primary_finding + else None + ) + previous_row = ( + db.execute( + """ + SELECT * FROM scans + WHERE repository_id = ? AND id != ? + ORDER BY scanned_at DESC, rowid DESC LIMIT 1 + """, + (repository.id, last_scan.id), + ).fetchone() + if last_scan + else None + ) + previous_scan = self._report_from_row(previous_row) if previous_row else None + previous_findings = [item for item in (previous_scan.findings if previous_scan else []) if not item.suppressed] + active_fingerprints = {self._finding_key(item) for item in active_findings} + previous_fingerprints = {self._finding_key(item) for item in previous_findings} + last_revision = ( + last_scan.source.actual_revision + if last_scan and last_scan.source.actual_revision + else (latest["actual_revision"] if latest else None) + ) + stale = bool(repository.latest_revision and last_revision and repository.latest_revision != last_revision) + if repository.archived: + state = RepositoryState.ARCHIVED + elif not repository.available: + state = RepositoryState.UNAVAILABLE + elif active: + state = ( + RepositoryState.QUEUED + if active["status"] == ScanJobStatus.QUEUED.value + else RepositoryState.SCANNING + ) + elif last_job and last_job["status"] == ScanJobStatus.FAILED.value: + last_job_at = _datetime(last_job["created_at"]) + if last_scan is None or (last_job_at and last_job_at > last_scan.scanned_at): + state = RepositoryState.FAILED + elif stale: + state = RepositoryState.STALE + else: + state = RepositoryState.CURRENT + elif last_scan is None: + state = RepositoryState.NEVER_SCANNED + elif stale: + state = RepositoryState.STALE + else: + state = RepositoryState.CURRENT + data = repository.model_dump() + data.update( + { + "state": state, + "stale": stale, + "active_job_id": active["id"] if active else None, + "last_scan_id": last_scan.id if last_scan else None, + "last_scan_at": last_scan.scanned_at if last_scan else None, + "last_scan_health": last_scan.health_score if last_scan else None, + "last_scan_readiness": last_scan.release_gate.status if last_scan and last_scan.release_gate else (last_scan.release_readiness if last_scan else None), + "last_scan_revision": last_revision, + "last_scan_blockers": sum(1 for item in active_findings if item.severity == Severity.BLOCKER), + "last_scan_warnings": sum(1 for item in active_findings if item.severity == Severity.WARNING), + "last_scan_primary_signal": primary_signal, + "last_scan_health_delta": (last_scan.health_score - previous_scan.health_score) if previous_scan and last_scan else None, + "last_scan_added_findings": len(active_fingerprints - previous_fingerprints) if previous_scan else 0, + "last_scan_resolved_findings": len(previous_fingerprints - active_fingerprints) if previous_scan else 0, + } + ) + return RepositorySummary(**data) + + def _catalog_rows( + self, db: sqlite3.Connection, *, limit: int, offset: int = 0, repository_id: str | None = None + ) -> list[sqlite3.Row]: + where = "WHERE r.id = ?" if repository_id else "" + params: tuple[object, ...] = (repository_id, limit, offset) if repository_id else (limit, offset) + return db.execute( + f""" + WITH ranked_scans AS ( + SELECT s.*, ROW_NUMBER() OVER ( + PARTITION BY s.repository_id ORDER BY s.scanned_at DESC, s.rowid DESC + ) AS scan_rank + FROM scans AS s WHERE s.repository_id IS NOT NULL + ), active_jobs AS ( + SELECT j.id, j.repository_id, j.status, ROW_NUMBER() OVER ( + PARTITION BY j.repository_id ORDER BY j.created_at DESC, j.rowid DESC + ) AS job_rank + FROM scan_jobs AS j + WHERE j.status IN ('queued', 'acquiring_source', 'scanning', 'analyzing', 'persisting') + ), last_jobs AS ( + SELECT j.repository_id, j.status, j.created_at, ROW_NUMBER() OVER ( + PARTITION BY j.repository_id ORDER BY j.created_at DESC, j.rowid DESC + ) AS job_rank + FROM scan_jobs AS j + ) + SELECT r.*, + latest.id AS summary_scan_id, latest.scanned_at AS summary_scanned_at, + latest.health_score AS summary_health, latest.release_readiness AS summary_readiness, + latest.actual_revision AS summary_revision, latest.finding_count AS summary_findings, + latest.blocker_count AS summary_blockers, latest.warning_count AS summary_warnings, + latest.primary_signal_json AS summary_primary_signal, + latest.added_findings AS summary_added, latest.resolved_findings AS summary_resolved, + latest.triage_priority AS summary_priority, latest.triage_rationale AS summary_rationale, + previous.health_score AS previous_health, + active.id AS active_job_id, active.status AS active_job_status, + last_job.status AS last_job_status, last_job.created_at AS last_job_created_at + FROM repositories AS r + LEFT JOIN ranked_scans AS latest ON latest.repository_id = r.id AND latest.scan_rank = 1 + LEFT JOIN ranked_scans AS previous ON previous.repository_id = r.id AND previous.scan_rank = 2 + LEFT JOIN active_jobs AS active ON active.repository_id = r.id AND active.job_rank = 1 + LEFT JOIN last_jobs AS last_job ON last_job.repository_id = r.id AND last_job.job_rank = 1 + {where} + ORDER BY r.archived ASC, r.full_name COLLATE NOCASE ASC + LIMIT ? OFFSET ? + """, + params, + ).fetchall() + + def _compact_repository_summary(self, row: sqlite3.Row) -> RepositorySummary: + repository = self._repository_from_row(row) + primary_signal = None + if row["summary_primary_signal"]: + try: + primary_signal = RemediationSignal.model_validate_json(row["summary_primary_signal"]) + except (TypeError, ValueError): + primary_signal = None + last_revision = row["summary_revision"] + stale = bool(repository.latest_revision and last_revision and repository.latest_revision != last_revision) + if repository.archived: + state = RepositoryState.ARCHIVED + elif not repository.available: + state = RepositoryState.UNAVAILABLE + elif row["active_job_id"]: + state = RepositoryState.QUEUED if row["active_job_status"] == ScanJobStatus.QUEUED.value else RepositoryState.SCANNING + elif row["last_job_status"] == ScanJobStatus.FAILED.value: + last_job_at = _datetime(row["last_job_created_at"]) + last_scan_at = _datetime(row["summary_scanned_at"]) + if last_scan_at is None or (last_job_at and last_job_at > last_scan_at): + state = RepositoryState.FAILED + else: + state = RepositoryState.STALE if stale else RepositoryState.CURRENT + elif row["summary_scan_id"] is None: + state = RepositoryState.NEVER_SCANNED + else: + state = RepositoryState.STALE if stale else RepositoryState.CURRENT + data = repository.model_dump() + data.update( + { + "state": state, + "stale": stale, + "active_job_id": row["active_job_id"], + "last_scan_id": row["summary_scan_id"], + "last_scan_at": _datetime(row["summary_scanned_at"]), + "last_scan_health": row["summary_health"], + "last_scan_readiness": row["summary_readiness"], + "last_scan_revision": last_revision, + "last_scan_blockers": int(row["summary_blockers"] or 0), + "last_scan_warnings": int(row["summary_warnings"] or 0), + "last_scan_primary_signal": primary_signal, + "last_scan_health_delta": ( + int(row["summary_health"]) - int(row["previous_health"]) + if row["summary_health"] is not None and row["previous_health"] is not None + else None + ), + "last_scan_added_findings": int(row["summary_added"] or 0), + "last_scan_resolved_findings": int(row["summary_resolved"] or 0), + "last_scan_priority": int(row["summary_priority"] or 0), + "last_scan_priority_rationale": row["summary_rationale"], + } + ) + return RepositorySummary(**data) + + def list_repositories(self, limit: int = 100, offset: int = 0) -> list[RepositorySummary]: + with self._connection() as db: + return [self._compact_repository_summary(row) for row in self._catalog_rows(db, limit=limit, offset=offset)] + + def repository_count(self) -> int: + with self._connection() as db: + return int(db.execute("SELECT COUNT(*) FROM repositories").fetchone()[0]) + + def repository_state_counts(self) -> dict[str, int]: + counts: Counter[str] = Counter() + for repository in self.list_repositories(limit=1000): + counts[repository.state.value] += 1 + return dict(counts) + + def get_repository_detail(self, repository_id: str) -> RepositoryDetail | None: + with self._connection() as db: + rows = self._catalog_rows(db, repository_id=repository_id, limit=1) + if not rows: + return None + summary = self._compact_repository_summary(rows[0]) + latest = db.execute( + "SELECT * FROM scans WHERE repository_id = ? ORDER BY scanned_at DESC, rowid DESC LIMIT 1", + (repository_id,), + ).fetchone() + return RepositoryDetail(**summary.model_dump(), latest_scan=self._report_from_row(latest) if latest else None) + + def repository_scans(self, repository_id: str, limit: int = 50) -> list[ScanSummary]: + with self._connection() as db: + rows = db.execute( + "SELECT * FROM scans WHERE repository_id = ? ORDER BY scanned_at DESC, rowid DESC LIMIT ?", + (repository_id, limit), + ).fetchall() + result: list[ScanSummary] = [] + for row in rows: + report = self._report_from_row(row) + result.append( + ScanSummary( + id=report.id, + repository_id=repository_id, + scanned_at=report.scanned_at, + branch=report.source.branch, + requested_revision=report.source.requested_revision, + actual_revision=report.source.actual_revision, + trigger=report.source.trigger, + health_score=report.health_score, + release_readiness=report.release_readiness, + findings=len(report.findings), + duration_ms=report.duration_ms, + ) + ) + return result + + def _report_from_row(self, row: sqlite3.Row | None) -> ScanReport: + if row is None: + raise ValueError("Scan row is required") + report = ScanReport.model_validate_json(row["report_json"]) + source_updates: dict[str, object] = {} + if row["repository_id"] and not report.source.repository_id: + source_updates["repository_id"] = row["repository_id"] + if row["source_origin"] and report.source.origin == SourceOrigin.LOCAL: + try: + source_updates["origin"] = SourceOrigin(str(row["source_origin"])) + except ValueError: + pass + if row["provider"] and not report.source.provider: + source_updates["provider"] = row["provider"] + for field in ("branch", "requested_revision", "actual_revision", "trigger"): + value = row[field] + if value is not None and getattr(report.source, field) in (None, ScanTrigger.MANUAL): + if field == "trigger": + try: + source_updates[field] = ScanTrigger(str(value)) + except ValueError: + continue + else: + source_updates[field] = value + if source_updates: + report = report.model_copy(update={"source": report.source.model_copy(update=source_updates)}) + return report.model_copy( + update={ + "findings": enrich_findings(report.findings), + "compacted": bool(row["report_compacted"]) if "report_compacted" in row else report.compacted, + } + ) + + def _normalise_report(self, report: ScanReport) -> ScanReport: + if report.source.repository_id: + return report + repository_id = local_repository_id(report.repository_path) + source = report.source.model_copy( + update={ + "origin": SourceOrigin.LOCAL, + "provider": "local", + "repository_id": repository_id, + "repository_full_name": f"local/{report.repository_name}", + "trigger": ScanTrigger.LOCAL, + } + ) + return report.model_copy(update={"source": source}) + + def save(self, report: ScanReport) -> ScanReport: + report = self._normalise_report(report) + report = report.model_copy(update={"findings": enrich_findings(report.findings)}) + source = report.source + history_key = source.repository_id or report.repository_path + with self._connection() as db: + if source.origin == SourceOrigin.LOCAL and source.repository_id: + self._upsert_local_repository_row(db, source.repository_id, report.repository_name, report.repository_path) + previous_row = db.execute( + """ + SELECT * FROM scans WHERE history_key = ? ORDER BY scanned_at DESC, rowid DESC LIMIT 1 + """, + (history_key,), + ).fetchone() + previous = self._report_from_row(previous_row) if previous_row else None + summary = self._summary_fields(report, previous) + db.execute( + """ + INSERT INTO scans ( + id, repository_path, repository_name, scanned_at, health_score, report_json, + repository_id, history_key, source_origin, provider, branch, + requested_revision, actual_revision, trigger, finding_count, blocker_count, + warning_count, release_readiness, primary_signal_json, added_findings, + resolved_findings, triage_priority, triage_rationale, report_compacted + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + report.id, + report.repository_path, + report.repository_name, + report.scanned_at.isoformat(), + report.health_score, + report.model_dump_json(), + source.repository_id, + history_key, + source.origin.value, + source.provider, + source.branch, + source.requested_revision, + source.actual_revision, + source.trigger.value, + summary["finding_count"], + summary["blocker_count"], + summary["warning_count"], + summary["release_readiness"], + summary["primary_signal_json"], + summary["added_findings"], + summary["resolved_findings"], + summary["triage_priority"], + summary["triage_rationale"], + 0, + ), + ) + self._apply_retention(db, history_key) + if source.repository_id: + db.execute( + """ + UPDATE repositories + SET latest_revision = COALESCE(?, latest_revision), + latest_revision_at = COALESCE(latest_revision_at, ?), + sync_error = NULL + WHERE id = ? AND provider = 'local' + """, + (source.actual_revision, _iso(report.scanned_at), source.repository_id), + ) + return report + + def _apply_retention(self, db: sqlite3.Connection, history_key: str) -> None: + db.execute( + """ + DELETE FROM scans + WHERE history_key = ? + AND rowid NOT IN ( + SELECT rowid FROM scans WHERE history_key = ? + ORDER BY scanned_at DESC, rowid DESC LIMIT ? + ) + """, + (history_key, history_key, history_per_repository()), + ) + cutoff = (_now() - timedelta(days=report_retention_days())).isoformat() + db.execute("DELETE FROM scans WHERE history_key = ? AND scanned_at < ?", (history_key, cutoff)) + rows = db.execute( + """ + SELECT * FROM scans WHERE history_key = ? AND report_compacted = 0 + ORDER BY scanned_at DESC, rowid DESC LIMIT -1 OFFSET ? + """, + (history_key, full_reports_per_repository()), + ).fetchall() + for row in rows: + report = self._report_from_row(row) + compact = report.model_copy( + update={"findings": [], "architecture": ArchitectureGraph(), "hotspots": [], "compacted": True} + ) + db.execute( + "UPDATE scans SET report_json = ?, report_compacted = 1 WHERE id = ?", + (compact.model_dump_json(), row["id"]), + ) + + def get(self, scan_id: str) -> ScanReport | None: + with self._connection() as db: + row = db.execute("SELECT * FROM scans WHERE id = ?", (scan_id,)).fetchone() + return self._report_from_row(row) if row else None + + def recent(self, limit: int = 20) -> list[ScanReport]: + with self._connection() as db: + rows = db.execute( + "SELECT * FROM scans ORDER BY scanned_at DESC, rowid DESC LIMIT ?", + (limit,), + ).fetchall() + return [self._report_from_row(row) for row in rows] + + def _history_candidates(self, repository_key: str) -> list[str]: + if repository_key.startswith(("local:", "gitea:")): + return [repository_key] + return [repository_key, local_repository_id(repository_key)] + + def recent_for_repository(self, repository_key: str, limit: int = 50) -> list[ScanReport]: + with self._connection() as db: + for candidate in self._history_candidates(repository_key): + rows = db.execute( + "SELECT * FROM scans WHERE history_key = ? ORDER BY scanned_at DESC, rowid DESC LIMIT ?", + (candidate, limit), + ).fetchall() + if rows: + return [self._report_from_row(row) for row in rows] + return [] + + def previous_for_repository(self, repository_key: str, exclude_id: str) -> ScanReport | None: + with self._connection() as db: + for candidate in self._history_candidates(repository_key): + row = db.execute( + """ + SELECT previous.* + FROM scans AS previous + JOIN scans AS current ON current.id = ? + WHERE previous.history_key = ? + AND previous.id != current.id + AND ( + previous.scanned_at < current.scanned_at + OR (previous.scanned_at = current.scanned_at AND previous.rowid < current.rowid) + ) + ORDER BY previous.scanned_at DESC, previous.rowid DESC + LIMIT 1 + """, + (exclude_id, candidate), + ).fetchone() + if row: + return self._report_from_row(row) + return None + + @staticmethod + def _finding_key(report_finding) -> str: + return report_finding.fingerprint or finding_fingerprint(report_finding) + + def compare(self, report: ScanReport) -> ScanComparison: + previous = self.previous_for_repository( + report.source.repository_id or report.repository_path, + report.id, + ) + if previous is None: + return ScanComparison(current_scan_id=report.id) + + current_metrics = report.metrics.model_dump() + previous_metrics = previous.metrics.model_dump() + metric_deltas = { + key: int(current_metrics[key]) - int(previous_metrics.get(key, 0)) + for key in current_metrics + } + category_deltas = { + category: report.category_scores.get(category, 0) - previous.category_scores.get(category, 0) + for category in set(report.category_scores) | set(previous.category_scores) + } + current_findings = {self._finding_key(item): item for item in report.findings} + previous_findings = {self._finding_key(item): item for item in previous.findings} + added = [current_findings[key] for key in current_findings.keys() - previous_findings.keys()] + resolved = [previous_findings[key] for key in previous_findings.keys() - current_findings.keys()] + + return ScanComparison( + current_scan_id=report.id, + previous_scan_id=previous.id, + health_score_delta=report.health_score - previous.health_score, + category_score_deltas=dict(sorted(category_deltas.items())), + metric_deltas=metric_deltas, + added_findings=sorted(added, key=lambda item: (item.category, item.path or "", item.line or 0)), + resolved_findings=sorted(resolved, key=lambda item: (item.category, item.path or "", item.line or 0)), + ) + + def trend(self, repository_key: str, limit: int = 50) -> list[TrendPoint]: + reports = list(reversed(self.recent_for_repository(repository_key, limit))) + points: list[TrendPoint] = [] + for report in reports: + blockers = sum(1 for item in report.findings if item.severity == Severity.BLOCKER) + warnings = sum(1 for item in report.findings if item.severity == Severity.WARNING) + debt = report.metrics.todo + report.metrics.fixme + report.metrics.hack + report.metrics.xxx + points.append( + TrendPoint( + scan_id=report.id, + scanned_at=report.scanned_at, + health_score=report.health_score, + findings=len(report.findings), + blockers=blockers, + warnings=warnings, + debt_markers=debt, + ) + ) + return points + + @staticmethod + def _job_from_row(row: sqlite3.Row) -> ScanJob: + return ScanJob( + id=row["id"], + repository_id=row["repository_id"], + status=row["status"], + trigger=row["trigger"], + requested_revision=row["requested_revision"], + actual_revision=row["actual_revision"], + phase=row["phase"], + progress=int(row["progress"]), + message=row["message"], + created_at=_datetime(row["created_at"]) or _now(), + started_at=_datetime(row["started_at"]), + updated_at=_datetime(row["updated_at"]) or _now(), + finished_at=_datetime(row["finished_at"]), + duration_ms=float(row["duration_ms"]) if row["duration_ms"] is not None else None, + scan_id=row["scan_id"], + error_code=row["error_code"], + error_detail=row["error_detail"], + attempt=int(row["attempt"]), + deduplicated=bool(row["deduplicated"]), + ) + + def _recover_expired_jobs(self, db: sqlite3.Connection, now: str) -> None: + placeholders = ",".join("?" for _ in ACTIVE_JOB_STATUSES[1:]) + db.execute( + f""" + UPDATE scan_jobs + SET status = 'queued', phase = 'queued', progress = 0, + message = 'Recovered after worker lease expiry', updated_at = ?, lease_until = NULL + WHERE status IN ({placeholders}) AND lease_until IS NOT NULL AND lease_until < ? + """, + (now, *ACTIVE_JOB_STATUSES[1:], now), + ) + + def enqueue_job( + self, + repository_id: str, + requested_revision: str | None, + trigger: ScanTrigger, + *, + force: bool = False, + max_queue: int = 100, + ) -> ScanJob: + now_dt = _now() + now = now_dt.isoformat() + with self._connection() as db: + db.execute("BEGIN IMMEDIATE") + if self._maintenance_enabled(db): + raise MaintenanceModeError("RepoPulse is in maintenance mode") + self._recover_expired_jobs(db, now) + repository = db.execute("SELECT id FROM repositories WHERE id = ?", (repository_id,)).fetchone() + if repository is None: + raise KeyError(repository_id) + active_rows = db.execute( + """ + SELECT * FROM scan_jobs + WHERE repository_id = ? AND status IN (?, ?, ?, ?, ?) + ORDER BY created_at DESC + """, + (repository_id, *ACTIVE_JOB_STATUSES), + ).fetchall() + if not force: + for row in active_rows: + if row["requested_revision"] == requested_revision: + return self._job_from_row(row).model_copy(update={"deduplicated": True}) + if requested_revision and not force: + completed = db.execute( + """ + SELECT * FROM scans + WHERE repository_id = ? AND actual_revision = ? + ORDER BY scanned_at DESC, rowid DESC LIMIT 1 + """, + (repository_id, requested_revision), + ).fetchone() + if completed: + existing = db.execute( + """ + SELECT * FROM scan_jobs WHERE repository_id = ? AND scan_id = ? + ORDER BY created_at DESC LIMIT 1 + """, + (repository_id, completed["id"]), + ).fetchone() + if existing: + return self._job_from_row(existing).model_copy(update={"deduplicated": True}) + job_id = str(uuid4()) + db.execute( + """ + INSERT INTO scan_jobs ( + id, repository_id, status, trigger, requested_revision, actual_revision, + phase, progress, message, created_at, updated_at, finished_at, scan_id, + deduplicated + ) VALUES (?, ?, 'completed', ?, ?, ?, 'completed', 100, + 'Existing revision already analyzed', ?, ?, ?, ?, 1) + """, + ( + job_id, + repository_id, + trigger.value, + requested_revision, + completed["actual_revision"], + now, + now, + completed["scanned_at"], + completed["id"], + ), + ) + row = db.execute("SELECT * FROM scan_jobs WHERE id = ?", (job_id,)).fetchone() + return self._job_from_row(row) + queued = db.execute( + "SELECT COUNT(*) FROM scan_jobs WHERE status = 'queued'" + ).fetchone()[0] + if int(queued) >= max(1, max_queue): + raise QueueFullError("Scan queue is full; retry after queued work completes") + if requested_revision: + for row in active_rows: + if row["status"] == ScanJobStatus.QUEUED.value and row["requested_revision"] != requested_revision: + db.execute( + """ + UPDATE scan_jobs + SET status = 'superseded', phase = 'superseded', progress = 100, + message = 'Superseded by a newer repository revision', + updated_at = ?, finished_at = ? + WHERE id = ? + """, + (now, now, row["id"]), + ) + job_id = str(uuid4()) + db.execute( + """ + INSERT INTO scan_jobs ( + id, repository_id, status, trigger, requested_revision, phase, + progress, message, created_at, updated_at + ) VALUES (?, ?, 'queued', ?, ?, 'queued', 0, 'Waiting for an available worker', ?, ?) + """, + (job_id, repository_id, trigger.value, requested_revision, now, now), + ) + row = db.execute("SELECT * FROM scan_jobs WHERE id = ?", (job_id,)).fetchone() + return self._job_from_row(row) + + def get_job(self, job_id: str) -> ScanJob | None: + with self._connection() as db: + row = db.execute("SELECT * FROM scan_jobs WHERE id = ?", (job_id,)).fetchone() + return self._job_from_row(row) if row else None + + def claim_next_job(self) -> ScanJob | None: + now_dt = _now() + now = now_dt.isoformat() + lease_until = (now_dt + timedelta(seconds=job_lease_seconds())).isoformat() + with self._connection() as db: + db.execute("BEGIN IMMEDIATE") + if self._maintenance_enabled(db): + return None + self._recover_expired_jobs(db, now) + row = db.execute( + """ + SELECT queued.* FROM scan_jobs AS queued + WHERE queued.status = 'queued' + ORDER BY COALESCE(( + SELECT MAX(served.started_at) FROM scan_jobs AS served + WHERE served.repository_id = queued.repository_id AND served.started_at IS NOT NULL + ), ''), queued.created_at, queued.rowid + LIMIT 1 + """ + ).fetchone() + if row is None: + return None + db.execute( + """ + UPDATE scan_jobs + SET status = 'acquiring_source', phase = 'acquiring_source', progress = 5, + message = 'Acquiring exact repository revision', started_at = COALESCE(started_at, ?), + updated_at = ?, attempt = attempt + 1, lease_until = ? + WHERE id = ? AND status = 'queued' + """, + (now, now, lease_until, row["id"]), + ) + claimed = db.execute("SELECT * FROM scan_jobs WHERE id = ?", (row["id"],)).fetchone() + return self._job_from_row(claimed) + + def renew_job_lease(self, job_id: str) -> ScanJob | None: + """Renew only an actively owned lease; terminal jobs are never resurrected.""" + + lease_until = (_now() + timedelta(seconds=job_lease_seconds())).isoformat() + placeholders = ",".join("?" for _ in ACTIVE_JOB_STATUSES[1:]) + with self._connection() as db: + db.execute( + f""" + UPDATE scan_jobs SET lease_until = ?, updated_at = ? + WHERE id = ? AND status IN ({placeholders}) + """, + (lease_until, _now().isoformat(), job_id, *ACTIVE_JOB_STATUSES[1:]), + ) + row = db.execute("SELECT * FROM scan_jobs WHERE id = ?", (job_id,)).fetchone() + return self._job_from_row(row) if row else None + + def update_job(self, job_id: str, **updates: object) -> ScanJob | None: + allowed = { + "status", + "actual_revision", + "phase", + "progress", + "message", + "started_at", + "finished_at", + "duration_ms", + "scan_id", + "error_code", + "error_detail", + "lease_until", + } + invalid = set(updates) - allowed + if invalid: + raise ValueError(f"Unsupported job fields: {sorted(invalid)}") + if not updates: + return self.get_job(job_id) + serialised: dict[str, object] = {} + for key, value in updates.items(): + if isinstance(value, datetime): + serialised[key] = _iso(value) + elif isinstance(value, (ScanJobStatus, ScanTrigger)): + serialised[key] = value.value + else: + serialised[key] = value + serialised["updated_at"] = _now().isoformat() + if serialised.get("status") in ACTIVE_JOB_STATUSES[1:] and "lease_until" not in serialised: + serialised["lease_until"] = (_now() + timedelta(seconds=job_lease_seconds())).isoformat() + if "status" in serialised and serialised["status"] in { + ScanJobStatus.COMPLETED.value, + ScanJobStatus.FAILED.value, + ScanJobStatus.CANCELLED.value, + ScanJobStatus.SUPERSEDED.value, + }: + with self._connection() as db: + existing = db.execute( + "SELECT started_at FROM scan_jobs WHERE id = ?", (job_id,) + ).fetchone() + if "duration_ms" not in serialised and existing and existing["started_at"]: + started = _datetime(existing["started_at"]) + if started: + serialised["duration_ms"] = max(0.0, (_now() - started).total_seconds() * 1000) + serialised.setdefault("finished_at", _now().isoformat()) + serialised.setdefault("lease_until", None) + columns = ", ".join(f"{key} = ?" for key in serialised) + with self._connection() as db: + db.execute( + f"UPDATE scan_jobs SET {columns} WHERE id = ?", + (*serialised.values(), job_id), + ) + row = db.execute("SELECT * FROM scan_jobs WHERE id = ?", (job_id,)).fetchone() + return self._job_from_row(row) if row else None + + @staticmethod + def _maintenance_enabled(db: sqlite3.Connection) -> bool: + row = db.execute("SELECT value FROM app_state WHERE key = 'maintenance_mode'").fetchone() + if row is None: + return False + try: + value = json.loads(row["value"]) + except (TypeError, json.JSONDecodeError): + # Fail closed if persisted control state cannot be interpreted. + return True + if isinstance(value, dict) and isinstance(value.get("enabled"), bool): + return value["enabled"] + return value is not False + + def maintenance_status(self) -> dict[str, object]: + with self._connection() as db: + row = db.execute( + "SELECT value FROM app_state WHERE key = 'maintenance_mode'" + ).fetchone() + if row is None: + return {"enabled": False, "changed_at": None} + try: + value = json.loads(row["value"]) + except (TypeError, json.JSONDecodeError): + return {"enabled": True, "changed_at": None, "state_error": "invalid_persisted_state"} + if isinstance(value, dict) and isinstance(value.get("enabled"), bool): + return { + "enabled": value["enabled"], + "changed_at": value.get("changed_at"), + } + if isinstance(value, bool): + return {"enabled": value, "changed_at": None} + return {"enabled": True, "changed_at": None, "state_error": "invalid_persisted_state"} + + def set_maintenance_mode(self, enabled: bool) -> dict[str, object]: + changed_at = _now().isoformat() + value = json.dumps({"enabled": enabled, "changed_at": changed_at}, separators=(",", ":")) + with self._connection() as db: + db.execute("BEGIN IMMEDIATE") + db.execute( + """ + INSERT INTO app_state (key, value, updated_at) VALUES ('maintenance_mode', ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + """, + (value, changed_at), + ) + return {"enabled": enabled, "changed_at": changed_at} + + def touch_worker_heartbeat_if_active(self, heartbeat: str) -> bool: + """Persist scheduler liveness only when maintenance is not active.""" + + with self._connection() as db: + db.execute("BEGIN IMMEDIATE") + if self._maintenance_enabled(db): + return False + db.execute( + """ + INSERT INTO app_state (key, value, updated_at) VALUES ('worker_heartbeat', ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + """, + (heartbeat, _now().isoformat()), + ) + return True + + def queue_stats(self) -> dict[str, int | str | None]: + with self._connection() as db: + rows = db.execute("SELECT status, COUNT(*) AS count FROM scan_jobs GROUP BY status").fetchall() + counts = {str(row["status"]): int(row["count"]) for row in rows} + heartbeat = db.execute("SELECT value FROM app_state WHERE key = 'worker_heartbeat'").fetchone() + sync = db.execute("SELECT value FROM app_state WHERE key = 'last_gitea_sync'").fetchone() + return { + "queued": counts.get(ScanJobStatus.QUEUED.value, 0), + "active": sum(counts.get(status, 0) for status in ACTIVE_JOB_STATUSES[1:]), + "completed": counts.get(ScanJobStatus.COMPLETED.value, 0), + "failed": counts.get(ScanJobStatus.FAILED.value, 0), + "worker_heartbeat": heartbeat["value"] if heartbeat else None, + "last_gitea_sync": sync["value"] if sync else None, + } + + @staticmethod + def _tree_stats(root: Path) -> tuple[int, int]: + """Return bounded managed-cache file count and bytes without following links.""" + + if not root.exists() or root.is_symlink(): + return 0, 0 + files = 0 + total_bytes = 0 + pending = [root] + while pending and files < 50_000: + current = pending.pop() + try: + entries = list(current.iterdir()) + except OSError: + continue + for entry in entries: + if entry.is_symlink(): + continue + try: + if entry.is_dir(): + pending.append(entry) + elif entry.is_file(): + files += 1 + total_bytes += entry.stat().st_size + except OSError: + continue + if files >= 50_000: + break + return files, total_bytes + + @staticmethod + def _managed_entry_count(root: Path) -> int: + try: + return sum(1 for entry in root.iterdir() if not entry.is_symlink()) + except OSError: + return 0 + + def storage_stats(self) -> dict[str, int | str | None]: + with self._connection() as db: + row = db.execute( + """ + SELECT COUNT(*) AS scan_count, + SUM(CASE WHEN report_compacted = 1 THEN 1 ELSE 0 END) AS compacted_scan_count, + MIN(scanned_at) AS oldest_scan_at, MAX(scanned_at) AS newest_scan_at + FROM scans + """ + ).fetchone() + migration_backup = db.execute( + "SELECT value FROM app_state WHERE key = 'last_schema_migration_backup'" + ).fetchone() + database_bytes = self.path.stat().st_size if self.path.exists() else 0 + journal = Path(f"{self.path}-wal") + journal_bytes = journal.stat().st_size if journal.exists() else 0 + cache_root = Path(os.getenv("REPOPULSE_REPOSITORY_CACHE", "data/repos")).expanduser() + mirrors_root = cache_root / "mirrors" + worktrees_root = cache_root / "worktrees" + _mirror_files, mirror_cache_bytes = self._tree_stats(mirrors_root) + mirror_count = self._managed_entry_count(mirrors_root) + worktree_count = self._managed_entry_count(worktrees_root) + return { + "database_bytes": database_bytes, + "journal_bytes": journal_bytes, + "mirror_count": mirror_count, + "mirror_cache_bytes": mirror_cache_bytes, + "worktree_count": worktree_count, + "scan_count": int(row["scan_count"] or 0), + "compacted_scan_count": int(row["compacted_scan_count"] or 0), + "oldest_scan_at": row["oldest_scan_at"], + "newest_scan_at": row["newest_scan_at"], + "history_per_repository": history_per_repository(), + "full_reports_per_repository": full_reports_per_repository(), + "report_retention_days": report_retention_days(), + "last_schema_migration_backup": migration_backup["value"] if migration_backup else None, + } + + def database_recovery_status(self) -> dict[str, object]: + stored = self.get_state("database_recovery") + if not stored: + return {"status": "healthy"} + try: + value = json.loads(stored) + except json.JSONDecodeError: + value = {"status": "unknown", "detail": "Recovery metadata could not be read"} + return value if isinstance(value, dict) else {"status": "unknown"} + + def acknowledge_database_recovery(self) -> None: + self.recovery_metadata = None + self.set_state("database_recovery", {"status": "acknowledged", "acknowledged_at": _now().isoformat()}) + + def set_state(self, key: str, value: object) -> None: + encoded = value if isinstance(value, str) else json.dumps(value, separators=(",", ":")) + with self._connection() as db: + db.execute( + """ + INSERT INTO app_state (key, value, updated_at) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + """, + (key, encoded, _now().isoformat()), + ) + + def get_state(self, key: str) -> str | None: + with self._connection() as db: + row = db.execute("SELECT value FROM app_state WHERE key = ?", (key,)).fetchone() + return row["value"] if row else None + + def update_repository_sync( + self, + repository: Repository, + *, + synced_at: datetime, + error: str | None = None, + ) -> None: + updated = repository.model_copy( + update={"last_synced_at": synced_at, "sync_error": error} + ) + self.upsert_repository(updated) + + def record_sync_result(self, result: RepositorySyncResult) -> None: + self.set_state("last_gitea_sync", result.model_dump_json()) diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..a7a6852 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "repopulse-backend" +version = "1.5.0" +description = "RepoPulse repository intelligence API and deterministic analysis engine" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.116,<1", + "uvicorn[standard]>=0.35,<1", + "pydantic>=2.11,<3", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.4,<9", + "ruff>=0.12,<1", + "httpx>=0.28,<1", +] + +[tool.pytest.ini_options] +pythonpath = ["."] + +[tool.ruff] +line-length = 100 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..05912e5 --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,122 @@ +from pathlib import Path +from threading import BoundedSemaphore + +import pytest +from fastapi.testclient import TestClient + +from app import main as main_module +from app.store import ScanStore + + +def _repository(root: Path) -> Path: + repository = root / "repo" + repository.mkdir() + (repository / "README.md").write_text("# Demo\n", encoding="utf-8") + (repository / "LICENSE").write_text("MIT\n", encoding="utf-8") + (repository / ".gitignore").write_text(".env\n", encoding="utf-8") + (repository / "app.py").write_text("print('hello')\n", encoding="utf-8") + return repository + + +def test_scan_api_respects_root_and_exports_reports(tmp_path: Path) -> None: + repository = _repository(tmp_path) + main_module.SCAN_ROOT = str(tmp_path) + main_module.store = ScanStore(str(tmp_path / "history.db")) + main_module.SCAN_SLOTS = BoundedSemaphore(1) + client = TestClient(main_module.app) + + first_response = client.post("/api/v1/scans", json={"path": "repo"}) + assert first_response.status_code == 201 + first = first_response.json() + assert first["repository_path"] == str(repository.resolve()) + + html = client.get(f"/api/v1/scans/{first['id']}/export?format=html") + assert html.status_code == 200 + assert "RepoPulse report" in html.text + assert "repo" in html.text + + blocked = client.post("/api/v1/scans", json={"path": str(tmp_path.parent)}) + assert blocked.status_code == 422 + + +def test_scan_api_comparison_delta_and_trend(tmp_path: Path) -> None: + repository = _repository(tmp_path) + main_module.SCAN_ROOT = str(tmp_path) + main_module.store = ScanStore(str(tmp_path / "history.db")) + main_module.SCAN_SLOTS = BoundedSemaphore(1) + client = TestClient(main_module.app) + + first = client.post("/api/v1/scans", json={"path": "repo"}).json() + (repository / "tests").mkdir() + (repository / "tests" / "test_app.py").write_text("def test_ok():\n assert True\n", encoding="utf-8") + second = client.post("/api/v1/scans", json={"path": "repo"}).json() + + comparison = client.get(f"/api/v1/scans/{second['id']}/comparison") + assert comparison.status_code == 200 + assert comparison.json()["previous_scan_id"] == first["id"] + + delta = client.get(f"/api/v1/scans/{second['id']}/delta") + assert delta.status_code == 200 + assert delta.json()["previous_health_score"] == first["health_score"] + assert delta.json()["previous_finding_count"] == len(first["findings"]) + + trend = client.get(f"/api/v1/trend?path={repository}") + assert trend.status_code == 200 + assert [item["scan_id"] for item in trend.json()] == [first["id"], second["id"]] + + +def test_scan_api_rejects_when_scan_capacity_is_busy(tmp_path: Path) -> None: + _repository(tmp_path) + main_module.SCAN_ROOT = str(tmp_path) + main_module.store = ScanStore(str(tmp_path / "history.db")) + main_module.SCAN_SLOTS = BoundedSemaphore(1) + client = TestClient(main_module.app) + + assert main_module.SCAN_SLOTS.acquire(blocking=False) + try: + response = client.post("/api/v1/scans", json={"path": "repo"}) + finally: + main_module.SCAN_SLOTS.release() + + assert response.status_code == 429 + assert response.headers["retry-after"] == "2" + + +def test_scan_api_rejects_symlinked_repository_outside_root(tmp_path: Path) -> None: + outside = tmp_path.parent / f"{tmp_path.name}-outside-repository" + outside.mkdir() + (outside / "app.py").write_text("print('outside')\n", encoding="utf-8") + linked = tmp_path / "linked-repository" + try: + linked.symlink_to(outside, target_is_directory=True) + except (NotImplementedError, OSError): + pytest.skip("directory symlinks are unavailable in this environment") + + main_module.SCAN_ROOT = str(tmp_path) + main_module.store = ScanStore(str(tmp_path / "history.db")) + main_module.SCAN_SLOTS = BoundedSemaphore(1) + client = TestClient(main_module.app) + + response = client.post("/api/v1/scans", json={"path": "linked-repository"}) + + assert response.status_code == 422 + + +def test_mutating_api_requires_operator_token_only_when_configured(tmp_path: Path, monkeypatch) -> None: + _repository(tmp_path) + main_module.SCAN_ROOT = str(tmp_path) + main_module.store = ScanStore(str(tmp_path / "history.db")) + main_module.SCAN_SLOTS = BoundedSemaphore(1) + monkeypatch.setenv("REPOPULSE_OPERATOR_TOKEN", "test-operator-token") + client = TestClient(main_module.app) + + assert client.get("/api/v1/meta").status_code == 200 + denied = client.post("/api/v1/scans", json={"path": "repo"}) + assert denied.status_code == 401 + + allowed = client.post( + "/api/v1/scans", + json={"path": "repo"}, + headers={"X-RepoPulse-Token": "test-operator-token"}, + ) + assert allowed.status_code == 201 diff --git a/backend/tests/test_api_v11.py b/backend/tests/test_api_v11.py new file mode 100644 index 0000000..77c2341 --- /dev/null +++ b/backend/tests/test_api_v11.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import hashlib +import hmac +import json + +from fastapi.testclient import TestClient + +from app import main as main_module +from app.gitea import GiteaClient +from app.models import Repository +from app.store import ScanStore + + +def test_repository_catalog_and_durable_scan_job_contract(tmp_path) -> None: + main_module.store = ScanStore(str(tmp_path / "history.db")) + main_module.job_runner = None + main_module.gitea_client = GiteaClient("http://gitea.local", "test-token", transport=lambda *_: (500, b"")) + repository = Repository( + id="gitea:64", + provider="gitea", + provider_repository_id="64", + owner="Jens", + name="RepoPulse", + full_name="Jens/RepoPulse", + default_branch="main", + latest_revision="0123456789abcdef0123456789abcdef01234567", + ) + main_module.store.upsert_repository(repository) + client = TestClient(main_module.app) + + catalog = client.get("/api/v1/repositories") + assert catalog.status_code == 200 + assert catalog.json()[0]["id"] == "gitea:64" + assert "latest_scan" not in catalog.json()[0] + + queued = client.post("/api/v1/repositories/gitea%3A64/scans", json={"force": False}) + assert queued.status_code == 202 + job = queued.json() + assert job["repository_id"] == "gitea:64" + assert job["status"] == "queued" + + duplicate = client.post("/api/v1/repositories/gitea%3A64/scans", json={"force": False}) + assert duplicate.status_code == 202 + assert duplicate.json()["id"] == job["id"] + assert duplicate.json()["deduplicated"] is True + + detail = client.get("/api/v1/repositories/gitea%3A64") + assert detail.status_code == 200 + assert detail.json()["latest_scan"] is None + assert client.get(f"/api/v1/scan-jobs/{job['id']}").json()["status"] == "queued" + + +def test_signed_gitea_webhook_only_schedules_default_branch_push( + tmp_path, monkeypatch +) -> None: + main_module.store = ScanStore(str(tmp_path / "history.db")) + main_module.job_runner = None + main_module.gitea_client = GiteaClient("http://gitea.local", "test-token", transport=lambda *_: (500, b"")) + repository = Repository( + id="gitea:64", + provider="gitea", + provider_repository_id="64", + owner="Jens", + name="RepoPulse", + full_name="Jens/RepoPulse", + default_branch="main", + latest_revision="0123456789abcdef0123456789abcdef01234567", + ) + main_module.store.upsert_repository(repository) + monkeypatch.setenv("REPOPULSE_GITEA_WEBHOOK_SECRET", "webhook-secret") + payload = { + "repository": {"id": 64}, + "ref": "refs/heads/main", + "after": "abcdef0123456789abcdef0123456789abcdef01", + } + body = json.dumps(payload).encode() + signature = hmac.new(b"webhook-secret", body, hashlib.sha256).hexdigest() + client = TestClient(main_module.app) + + response = client.post( + "/api/v1/webhooks/gitea", + content=body, + headers={"X-Gitea-Signature": signature, "X-Gitea-Event": "push"}, + ) + assert response.status_code == 200 + assert response.json()["job_id"] + assert main_module.store.get_repository("gitea:64").latest_revision == payload["after"] + + wrong_signature = client.post( + "/api/v1/webhooks/gitea", + content=body, + headers={"X-Gitea-Signature": "0" * 64, "X-Gitea-Event": "push"}, + ) + assert wrong_signature.status_code == 401 + + feature_payload = payload | {"ref": "refs/heads/feature"} + feature_body = json.dumps(feature_payload).encode() + feature_signature = hmac.new(b"webhook-secret", feature_body, hashlib.sha256).hexdigest() + feature = client.post( + "/api/v1/webhooks/gitea", + content=feature_body, + headers={"X-Gitea-Signature": feature_signature, "X-Gitea-Event": "push"}, + ) + assert feature.status_code == 200 + assert feature.json()["job_id"] is None + + +def test_meta_exposes_operational_counts_without_connector_token(tmp_path) -> None: + main_module.store = ScanStore(str(tmp_path / "history.db")) + main_module.job_runner = None + main_module.gitea_client = GiteaClient("http://gitea.local", "never-return-this-token") + main_module.store.upsert_repository( + Repository( + id="gitea:64", + provider="gitea", + provider_repository_id="64", + owner="Jens", + name="RepoPulse", + full_name="Jens/RepoPulse", + latest_revision="0123456789abcdef0123456789abcdef01234567", + ) + ) + + response = TestClient(main_module.app).get("/api/v1/meta") + + assert response.status_code == 200 + assert response.json()["repository_count"] == 1 + assert response.json()["repository_states"]["never_scanned"] == 1 + assert "never-return-this-token" not in response.text diff --git a/backend/tests/test_architecture.py b/backend/tests/test_architecture.py new file mode 100644 index 0000000..9caa123 --- /dev/null +++ b/backend/tests/test_architecture.py @@ -0,0 +1,98 @@ +from pathlib import Path + +from app.architecture import build_architecture + + +def test_architecture_resolves_python_and_relative_typescript_imports(tmp_path: Path) -> None: + (tmp_path / "src").mkdir() + (tmp_path / "web").mkdir() + (tmp_path / "src" / "core.py").write_text("VALUE = 1\n", encoding="utf-8") + (tmp_path / "src" / "app.py").write_text("import src.core\n", encoding="utf-8") + (tmp_path / "web" / "util.ts").write_text("export const value = 1\n", encoding="utf-8") + (tmp_path / "web" / "app.ts").write_text("import { value } from './util'\n", encoding="utf-8") + (tmp_path / "web" / "side-effect.ts").write_text("import './util.js'\n", encoding="utf-8") + + source_texts = { + "src/core.py": "VALUE = 1\n", + "src/app.py": "import src.core\n", + "web/util.ts": "export const value = 1\n", + "web/app.ts": "import { value } from './util'\n", + "web/side-effect.ts": "import './util.js'\n", + } + languages = { + "src/core.py": "Python", + "src/app.py": "Python", + "web/util.ts": "TypeScript", + "web/app.ts": "TypeScript", + "web/side-effect.ts": "TypeScript", + } + loc = {path: 1 for path in source_texts} + + graph, hotspots = build_architecture( + tmp_path, + source_texts, + languages, + loc, + {path: 0 for path in source_texts}, + {"src/core.py": 100, "src/app.py": 200}, + ) + + pairs = {(edge.source, edge.target) for edge in graph.edges} + assert ("src/app.py", "src/core.py") in pairs + assert ("web/app.ts", "web/util.ts") in pairs + assert ("web/side-effect.ts", "web/util.ts") in pairs + assert {node.id for node in graph.nodes} == set(source_texts) + assert hotspots[0].path in {"src/app.py", "src/core.py"} + + +def test_architecture_edge_bound_is_explicit(tmp_path: Path) -> None: + source_texts = { + "src/a.py": "import src.b\n", + "src/b.py": "import src.c\n", + "src/c.py": "VALUE = 1\n", + } + languages = {path: "Python" for path in source_texts} + + graph, _ = build_architecture( + tmp_path, + source_texts, + languages, + {path: 1 for path in source_texts}, + {path: 0 for path in source_texts}, + {}, + max_edges=1, + ) + + assert len(graph.edges) == 1 + assert graph.truncated is True + + +def test_architecture_resolves_jsconfig_aliases_and_local_workspace_packages(tmp_path: Path) -> None: + (tmp_path / "src" / "components").mkdir(parents=True) + (tmp_path / "packages" / "shared" / "src").mkdir(parents=True) + (tmp_path / "jsconfig.json").write_text( + '{"compilerOptions":{"baseUrl":".","paths":{"@/*":["src/*"]}}}', + encoding="utf-8", + ) + (tmp_path / "package.json").write_text('{"name":"workspace-root"}', encoding="utf-8") + (tmp_path / "packages" / "shared" / "package.json").write_text( + '{"name":"@workspace/shared"}', encoding="utf-8" + ) + + source_texts = { + "src/app.ts": "import { Button } from '@/components/Button'\nimport { shared } from '@workspace/shared'\n", + "src/components/Button.ts": "export const Button = () => null\n", + "packages/shared/src/index.ts": "export const shared = true\n", + } + graph, _ = build_architecture( + tmp_path, + source_texts, + {path: "TypeScript" for path in source_texts}, + {path: 1 for path in source_texts}, + {path: 0 for path in source_texts}, + {}, + ) + + pairs = {(edge.source, edge.target) for edge in graph.edges} + assert ("src/app.ts", "src/components/Button.ts") in pairs + assert ("src/app.ts", "packages/shared/src/index.ts") in pairs diff --git a/backend/tests/test_gitea.py b/backend/tests/test_gitea.py new file mode 100644 index 0000000..b7f7585 --- /dev/null +++ b/backend/tests/test_gitea.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from app.gitea import GiteaClient, GiteaError +from app.store import ScanStore + + +def _transport(url: str, headers: dict[str, str]) -> tuple[int, bytes]: + assert headers["Authorization"] == "token test-token" + if "/user/repos" in url: + return 200, json.dumps( + [ + { + "id": 64, + "name": "RepoPulse", + "full_name": "Jens/RepoPulse", + "owner": {"login": "Jens"}, + "description": "Repository intelligence", + "default_branch": "main", + "clone_url": "https://evil.example/Jens/RepoPulse.git", + "private": True, + } + ] + ).encode() + if "/branches/main" in url: + return 200, b'{"commit":{"id":"0123456789abcdef0123456789abcdef01234567"}}' + raise AssertionError(url) + + +def test_gitea_discovery_is_bounded_and_preserves_stable_identity() -> None: + client = GiteaClient( + "http://gitea.local", + "test-token", + transport=_transport, + ) + + discovered = client.discover() + + assert len(discovered) == 1 + repository, revision = discovered[0] + assert repository.id == "gitea:64" + assert repository.provider_repository_id == "64" + assert repository.full_name == "Jens/RepoPulse" + assert repository.clone_url == "http://gitea.local/Jens/RepoPulse.git" + assert repository.latest_revision == revision + + +def test_gitea_auth_errors_are_sanitized() -> None: + def rejected(_url: str, _headers: dict[str, str]) -> tuple[int, bytes]: + return 401, b"secret-bearing upstream response" + + client = GiteaClient("http://gitea.local", "test-token", transport=rejected) + + try: + client.list_repositories() + except GiteaError as exc: + assert exc.code == "authentication" + assert "test-token" not in str(exc) + assert "secret-bearing" not in str(exc) + else: + raise AssertionError("authentication error was not raised") + + +@pytest.mark.parametrize("status_code, error_code", [(401, "authentication"), (403, "authentication"), (404, "not_found"), (429, "rate_limited"), (500, "upstream")]) +def test_gitea_http_statuses_are_classified_without_upstream_body(status_code: int, error_code: str) -> None: + client = GiteaClient( + "http://gitea.local", + "test-token", + transport=lambda *_: (status_code, b"token=test-token upstream body"), + ) + + with pytest.raises(GiteaError) as raised: + client.list_repositories() + + assert raised.value.code == error_code + assert "test-token" not in str(raised.value) + assert "upstream body" not in str(raised.value) + + +def test_gitea_discovery_paginates_until_a_short_page() -> None: + calls: list[str] = [] + + def paginated(url: str, headers: dict[str, str]) -> tuple[int, bytes]: + calls.append(url) + assert headers["Authorization"] == "token test-token" + if "/user/repos" in url: + if "page=1" in url: + payload = [{"id": 64, "owner": {"login": "Jens"}, "name": "One", "default_branch": "main"}] + elif "page=2" in url: + payload = [{"id": 65, "owner": {"login": "Jens"}, "name": "Two", "default_branch": "trunk"}] + else: + payload = [] + return 200, json.dumps(payload).encode() + if "/branches/main" in url: + return 200, b'{"commit":{"id":"0123456789abcdef0123456789abcdef01234567"}}' + if "/branches/trunk" in url: + return 200, b'{"commit":{"sha":"abcdef0123456789abcdef0123456789abcdef01"}}' + raise AssertionError(url) + + client = GiteaClient("http://gitea.local", "test-token", page_size=1, max_pages=3, transport=paginated) + + discovered = client.discover() + + assert [repository.id for repository, _ in discovered] == ["gitea:64", "gitea:65"] + assert len([call for call in calls if "/user/repos" in call]) == 3 + + +@pytest.mark.parametrize( + "transport, error_code", + [ + (lambda *_: (200, b"not-json"), "invalid_response"), + (lambda *_: (_ for _ in ()).throw(TimeoutError()), "unreachable"), + ], +) +def test_gitea_malformed_and_timeout_responses_are_safe(transport, error_code: str) -> None: + client = GiteaClient("http://gitea.local", "test-token", transport=transport) + + with pytest.raises(GiteaError) as raised: + client.list_repositories() + + assert raised.value.code == error_code + + +def test_gitea_sync_creates_catalog_without_full_reports(tmp_path: Path) -> None: + store = ScanStore(str(tmp_path / "history.db")) + client = GiteaClient("http://gitea.local", "test-token", transport=_transport) + + result = client.sync(store) + + assert result.discovered == 1 + assert result.created == 1 + repository = store.get_repository("gitea:64") + assert repository is not None + assert repository.latest_revision == "0123456789abcdef0123456789abcdef01234567" + assert repository.description == "Repository intelligence" + assert store.repository_scans(repository.id) == [] + + +def test_gitea_outage_preserves_catalog_and_marks_sync_error(tmp_path: Path) -> None: + store = ScanStore(str(tmp_path / "history.db")) + healthy = GiteaClient("http://gitea.local", "test-token", transport=_transport) + healthy.sync(store) + + unavailable = GiteaClient( + "http://gitea.local", + "test-token", + transport=lambda *_: (503, b"upstream details must not escape"), + ) + result = unavailable.sync(store) + + assert result.errors == ["upstream: Gitea returned a server error"] + repository = store.get_repository("gitea:64") + assert repository is not None + assert repository.available is True + assert repository.latest_revision == "0123456789abcdef0123456789abcdef01234567" + assert repository.sync_error == "upstream: Gitea returned a server error" + + +def test_complete_sync_marks_missing_repository_unavailable_without_deleting_history(tmp_path: Path) -> None: + store = ScanStore(str(tmp_path / "history.db")) + GiteaClient("http://gitea.local", "test-token", transport=_transport).sync(store) + missing = GiteaClient("http://gitea.local", "test-token", transport=lambda *_: (200, b"[]")) + + result = missing.sync(store) + + assert result.unavailable == 1 + repository = store.get_repository("gitea:64") + assert repository is not None + assert repository.available is False + assert store.repository_scans(repository.id) == [] + + +def test_branch_resolution_failure_is_partial_and_preserves_other_catalog_entries(tmp_path: Path) -> None: + def partial_transport(url: str, _headers: dict[str, str]) -> tuple[int, bytes]: + if "/user/repos" in url: + return 200, json.dumps( + [ + {"id": 1, "owner": {"login": "Jens"}, "name": "Healthy", "default_branch": "main"}, + {"id": 2, "owner": {"login": "Jens"}, "name": "Broken", "default_branch": "main"}, + ] + ).encode() + if "/repos/Jens/Healthy/branches/main" in url: + return 200, b'{"commit":{"id":"0123456789abcdef0123456789abcdef01234567"}}' + if "/repos/Jens/Broken/branches/main" in url: + return 500, b"upstream details must remain private" + raise AssertionError(url) + + store = ScanStore(str(tmp_path / "history.db")) + result = GiteaClient("http://gitea.local", "test-token", transport=partial_transport).sync(store) + + assert result.discovered == 1 + assert result.partial is True + assert result.branch_failures == 1 + assert result.listing_complete is True + assert "Jens/Broken: upstream: Gitea returned a server error" in result.errors + assert store.get_repository("gitea:1").latest_revision + broken = store.get_repository("gitea:2") + assert broken is not None and broken.available is True + assert broken.sync_error == "Jens/Broken: upstream: Gitea returned a server error" diff --git a/backend/tests/test_jobs.py b/backend/tests/test_jobs.py new file mode 100644 index 0000000..d0d0eed --- /dev/null +++ b/backend/tests/test_jobs.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import subprocess +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest + +from app.acquisition import AcquisitionError, GitAcquirer +from app.gitea import GiteaClient +from app.jobs import ScanJobRunner +from app.models import Repository, ScanJobStatus, ScanSource, ScanTrigger, SourceOrigin +from app.scanner import scan_repository +from app.store import ScanStore + + +def _git_repository(root: Path) -> tuple[Path, str]: + root.mkdir() + (root / "README.md").write_text("# Demo\n", encoding="utf-8") + (root / "app.py").write_text("print('hello')\n", encoding="utf-8") + subprocess.run(["git", "init", "--quiet", str(root)], check=True) + subprocess.run(["git", "-C", str(root), "config", "user.email", "test@example.com"], check=True) + subprocess.run(["git", "-C", str(root), "config", "user.name", "RepoPulse Test"], check=True) + subprocess.run(["git", "-C", str(root), "add", "."], check=True) + subprocess.run(["git", "-C", str(root), "commit", "--quiet", "-m", "initial"], check=True) + revision = subprocess.run( + ["git", "-C", str(root), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + return root, revision + + +def _catalog_repository(source: Path, revision: str) -> Repository: + return Repository( + id="gitea:64", + provider="gitea", + provider_repository_id="64", + owner="Jens", + name="Demo", + full_name="Jens/Demo", + clone_url=source.as_uri(), + default_branch="master", + latest_revision=revision, + ) + + +def test_acquirer_scans_detached_exact_revision_and_cleans_up(tmp_path: Path) -> None: + source, revision = _git_repository(tmp_path / "source") + repository = _catalog_repository(source, revision) + acquirer = GitAcquirer(tmp_path / "cache", timeout=30) + + acquired = acquirer.acquire(repository, "job-1", revision) + + checked_out = subprocess.run( + ["git", "-C", str(acquired.path), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + assert checked_out == revision + assert (acquired.path / "app.py").read_text(encoding="utf-8") == "print('hello')\n" + acquirer.cleanup(acquired, repository) + assert not acquired.path.exists() + + +def test_acquirer_recovers_a_corrupt_mirror_and_rejects_missing_sha(tmp_path: Path) -> None: + source, revision = _git_repository(tmp_path / "source") + repository = _catalog_repository(source, revision) + acquirer = GitAcquirer(tmp_path / "cache", timeout=30) + mirror = acquirer._mirror_path(repository) + mirror.parent.mkdir(parents=True, exist_ok=True) + mirror.write_text("corrupt mirror", encoding="utf-8") + + with pytest.raises(AcquisitionError, match="Requested commit was not found"): + acquirer.acquire(repository, "job-missing", "abcdef0123456789abcdef0123456789abcdef01") + + acquired = acquirer.acquire(repository, "job-recovered", revision) + assert acquired.actual_revision == revision + acquirer.cleanup(acquired, repository) + + +def test_durable_runner_completes_and_deduplicates_revision(tmp_path: Path) -> None: + source, revision = _git_repository(tmp_path / "source") + store = ScanStore(str(tmp_path / "history.db")) + repository = _catalog_repository(source, revision) + store.upsert_repository(repository) + runner = ScanJobRunner( + store, + gitea=GiteaClient("http://gitea.local", "test-token", transport=lambda *_: (500, b"")), + acquirer=GitAcquirer(tmp_path / "cache", timeout=30), + ) + + first = runner.enqueue(repository) + completed = runner.process_one() + reused = runner.enqueue(repository) + + assert completed is not None + assert completed.status == ScanJobStatus.COMPLETED + assert completed.scan_id is not None + assert reused.id == first.id + assert reused.deduplicated is True + report = store.get(completed.scan_id) + assert report is not None + assert report.source.actual_revision == revision + assert report.source.trigger == ScanTrigger.MANUAL + assert completed.duration_ms is not None + + +def test_expired_job_lease_is_recovered_for_another_claim(tmp_path: Path) -> None: + store = ScanStore(str(tmp_path / "history.db")) + repository = _catalog_repository(tmp_path, "0123456789abcdef0123456789abcdef01234567") + store.upsert_repository(repository) + queued = store.enqueue_job(repository.id, repository.latest_revision, ScanTrigger.SCHEDULED) + store.update_job( + queued.id, + status=ScanJobStatus.SCANNING, + phase="scanning", + lease_until=datetime.now(UTC) - timedelta(seconds=5), + ) + + claimed = store.claim_next_job() + + assert claimed is not None + assert claimed.id == queued.id + assert claimed.status == ScanJobStatus.ACQUIRING_SOURCE + assert claimed.attempt == 1 + assert claimed.message == "Acquiring exact repository revision" + + +def test_active_job_lease_renews_but_terminal_jobs_are_not_resurrected(tmp_path: Path) -> None: + store = ScanStore(str(tmp_path / "history.db")) + repository = _catalog_repository(tmp_path, "0123456789abcdef0123456789abcdef01234567") + store.upsert_repository(repository) + queued = store.enqueue_job(repository.id, repository.latest_revision, ScanTrigger.SCHEDULED) + claimed = store.claim_next_job() + + assert claimed is not None + with store._connection() as db: + initial_lease = db.execute("SELECT lease_until FROM scan_jobs WHERE id = ?", (queued.id,)).fetchone()[0] + assert initial_lease is not None + renewed = store.renew_job_lease(queued.id) + assert renewed is not None + with store._connection() as db: + renewed_lease = db.execute("SELECT lease_until FROM scan_jobs WHERE id = ?", (queued.id,)).fetchone()[0] + assert renewed_lease is not None and renewed_lease > initial_lease + + completed = store.update_job(queued.id, status=ScanJobStatus.COMPLETED, phase="completed", progress=100) + assert completed is not None + after_completion = store.renew_job_lease(queued.id) + assert after_completion is not None + with store._connection() as db: + terminal_lease = db.execute("SELECT lease_until FROM scan_jobs WHERE id = ?", (queued.id,)).fetchone()[0] + assert terminal_lease is None + + +def test_atomic_claim_allows_only_one_worker_to_own_a_queued_job(tmp_path: Path) -> None: + store = ScanStore(str(tmp_path / "history.db")) + repository = _catalog_repository(tmp_path, "0123456789abcdef0123456789abcdef01234567") + store.upsert_repository(repository) + store.enqueue_job(repository.id, repository.latest_revision, ScanTrigger.SCHEDULED) + + with ThreadPoolExecutor(max_workers=2) as workers: + claims = list(workers.map(lambda _unused: store.claim_next_job(), range(2))) + + successful = [claim for claim in claims if claim is not None] + assert len(successful) == 1 + assert successful[0].status == ScanJobStatus.ACQUIRING_SOURCE + + +def test_scheduler_queues_unscanned_and_changed_repositories_only(tmp_path: Path) -> None: + source, revision = _git_repository(tmp_path / "source") + store = ScanStore(str(tmp_path / "history.db")) + repository = _catalog_repository(source, revision) + store.upsert_repository(repository) + runner = ScanJobRunner(store, gitea=GiteaClient()) + + runner._schedule_changed_repositories() + first = store.claim_next_job() + assert first is not None + assert first.requested_revision == revision + store.update_job(first.id, status=ScanJobStatus.SUPERSEDED) + + report = scan_repository( + str(source), + source=ScanSource( + origin=SourceOrigin.GITEA, + provider="gitea", + repository_id=repository.id, + repository_full_name=repository.full_name, + branch=repository.default_branch, + requested_revision=revision, + actual_revision=revision, + trigger=ScanTrigger.MANUAL, + ), + ) + store.save(report) + runner._schedule_changed_repositories() + assert store.queue_stats()["queued"] == 0 + + changed = "abcdef0123456789abcdef0123456789abcdef01" + store.upsert_repository(repository.model_copy(update={"latest_revision": changed})) + runner._schedule_changed_repositories() + assert store.queue_stats()["queued"] == 1 + + disabled = repository.model_copy( + update={ + "id": "gitea:65", + "provider_repository_id": "65", + "latest_revision": "1234567890abcdef1234567890abcdef12345678", + "policy": repository.policy.model_copy(update={"auto_scan": False}), + } + ) + store.upsert_repository(disabled) + runner._schedule_changed_repositories() + assert store.queue_stats()["queued"] == 1 + + +def test_acquisition_rejects_token_bearing_clone_outside_gitea_origin(tmp_path: Path) -> None: + repository = _catalog_repository(tmp_path, "0123456789abcdef0123456789abcdef01234567").model_copy( + update={"clone_url": "https://evil.example/Jens/Demo.git"} + ) + acquirer = GitAcquirer( + tmp_path / "cache", + token="test-token", + allowed_clone_origin="https://gitea.local", + ) + + with pytest.raises(AcquisitionError, match="outside the configured Gitea origin"): + acquirer.acquire(repository, "job-1", repository.latest_revision) diff --git a/backend/tests/test_maintenance.py b/backend/tests/test_maintenance.py new file mode 100644 index 0000000..7e08dab --- /dev/null +++ b/backend/tests/test_maintenance.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from app import main as main_module +from app.gitea import GiteaClient +from app.jobs import ScanJobRunner +from app.models import Repository, ScanJobStatus, ScanTrigger +from app.store import MaintenanceModeError, ScanStore + + +def _gitea_repository() -> Repository: + return Repository( + id="gitea:64", + provider="gitea", + provider_repository_id="64", + owner="Jens", + name="RepoPulse", + full_name="Jens/RepoPulse", + default_branch="main", + latest_revision="0123456789abcdef0123456789abcdef01234567", + ) + + +def test_maintenance_mode_is_durable_authenticated_and_exposes_drain_state(tmp_path: Path, monkeypatch) -> None: + database = tmp_path / "history.db" + main_module.store = ScanStore(str(database)) + main_module.job_runner = None + main_module.store.upsert_repository(_gitea_repository()) + queued = main_module.store.enqueue_job( + "gitea:64", + _gitea_repository().latest_revision, + ScanTrigger.MANUAL, + ) + monkeypatch.delenv("REPOPULSE_OPERATOR_TOKEN", raising=False) + monkeypatch.delenv("REPOPULSE_MAINTENANCE_TOKEN", raising=False) + client = TestClient(main_module.app) + assert client.post("/api/v1/operator/maintenance/enter").status_code == 503 + monkeypatch.setenv("REPOPULSE_MAINTENANCE_TOKEN", "operator-secret") + assert client.get("/api/v1/meta").json()["mutation_authentication"] == "perimeter_required" + + assert client.post("/api/v1/operator/maintenance/enter").status_code == 401 + assert client.get("/api/v1/operator/maintenance").status_code == 401 + headers = {"X-RepoPulse-Token": "operator-secret"} + entered = client.post("/api/v1/operator/maintenance/enter", headers=headers) + + assert entered.status_code == 200 + assert entered.json()["enabled"] is True + assert entered.json()["queue_depth"] == 1 + assert entered.json()["active_jobs"] == 0 + assert entered.json()["active_drained"] is True + assert entered.json()["drained"] is False + changed_at = entered.json()["changed_at"] + assert client.post("/api/v1/operator/maintenance/enter", headers=headers).status_code == 423 + assert main_module.store.maintenance_status()["changed_at"] == changed_at + assert client.get("/api/v1/operator/maintenance", headers=headers).json()["enabled"] is True + assert client.get("/api/v1/meta").json()["maintenance"]["enabled"] is True + + restarted = ScanStore(str(database)) + assert restarted.maintenance_status()["enabled"] is True + assert restarted.get_job(queued.id).status == ScanJobStatus.QUEUED + + assert client.post("/api/v1/operator/maintenance/exit").status_code == 401 + exited = client.post("/api/v1/operator/maintenance/exit", headers=headers) + assert exited.status_code == 200 + assert exited.json()["enabled"] is False + assert ScanStore(str(database)).maintenance_status()["enabled"] is False + + +def test_paused_mode_rejects_api_mutations_but_keeps_read_endpoints_available(tmp_path: Path, monkeypatch) -> None: + main_module.store = ScanStore(str(tmp_path / "history.db")) + main_module.job_runner = None + repository = _gitea_repository() + main_module.store.upsert_repository(repository) + queued = main_module.store.enqueue_job( + repository.id, + repository.latest_revision, + ScanTrigger.MANUAL, + ) + monkeypatch.delenv("REPOPULSE_OPERATOR_TOKEN", raising=False) + monkeypatch.setenv("REPOPULSE_MAINTENANCE_TOKEN", "operator-secret") + client = TestClient(main_module.app) + headers = {"X-RepoPulse-Token": "operator-secret"} + assert client.post("/api/v1/operator/maintenance/enter", headers=headers).status_code == 200 + + mutation_requests = [ + ("/api/v1/scans", {}), + ("/api/v1/repositories/sync", {}), + ("/api/v1/repositories/gitea%3A64/scans", {"json": {"force": True}}), + ("/api/v1/webhooks/gitea", {"content": b"not validated while paused"}), + ("/api/v1/operator/database-recovery/acknowledge", {}), + ] + for path, kwargs in mutation_requests: + response = client.post(path, **kwargs) + assert response.status_code == 423, (path, response.status_code, response.text) + + assert client.get("/api/v1/repositories").status_code == 200 + assert client.get("/api/v1/scan-jobs/" + queued.id).status_code == 200 + assert main_module.store.queue_stats()["queued"] == 1 + assert main_module.store.get_job(queued.id).status == ScanJobStatus.QUEUED + + +def test_paused_worker_makes_no_scheduler_writes_or_job_claims(tmp_path: Path) -> None: + store = ScanStore(str(tmp_path / "history.db")) + repository = _gitea_repository() + store.upsert_repository(repository) + queued = store.enqueue_job(repository.id, repository.latest_revision, ScanTrigger.SCHEDULED) + store.set_state("worker_heartbeat", "before-maintenance") + store.set_state("last_gitea_sync", "prior-sync") + store.set_maintenance_mode(True) + + class CountingGitea: + configured = True + + def __init__(self) -> None: + self.sync_calls = 0 + + def sync(self, _store): + self.sync_calls += 1 + raise AssertionError("scheduler must not sync in maintenance mode") + + gitea = CountingGitea() + runner = ScanJobRunner(store, gitea=gitea) # type: ignore[arg-type] + + assert runner._run_iteration() is None + assert gitea.sync_calls == 0 + assert store.get_state("worker_heartbeat") == "before-maintenance" + assert store.get_state("last_gitea_sync") == "prior-sync" + assert store.claim_next_job() is None + assert store.queue_stats()["queued"] == 1 + assert store.get_job(queued.id).status == ScanJobStatus.QUEUED + + +def test_already_claimed_job_can_finish_during_maintenance(tmp_path: Path) -> None: + source = tmp_path / "source" + source.mkdir() + (source / "README.md").write_text("# Existing work\n", encoding="utf-8") + (source / "app.py").write_text("print('ok')\n", encoding="utf-8") + store = ScanStore(str(tmp_path / "history.db")) + repository = Repository( + id="local:existing-job", + provider="local", + provider_repository_id="existing-job", + owner="local", + name="Existing job", + full_name="local/Existing job", + default_branch="main", + ) + store.upsert_repository(repository, source_path=str(source)) + store.enqueue_job(repository.id, None, ScanTrigger.MANUAL) + claimed = store.claim_next_job() + assert claimed is not None + runner = ScanJobRunner(store, gitea=GiteaClient()) + + store.set_maintenance_mode(True) + assert store.maintenance_status()["enabled"] is True + assert store.queue_stats()["active"] == 1 + runner._process(claimed) + + assert store.get_job(claimed.id).status == ScanJobStatus.COMPLETED + assert store.queue_stats()["active"] == 0 + assert store.maintenance_status()["enabled"] is True + + +def test_invalid_persisted_maintenance_state_fails_closed(tmp_path: Path) -> None: + store = ScanStore(str(tmp_path / "history.db")) + store.set_state("maintenance_mode", "not-json") + + assert store.maintenance_status()["enabled"] is True + with pytest.raises(MaintenanceModeError): + store.enqueue_job("missing", "revision", ScanTrigger.MANUAL) + diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py new file mode 100644 index 0000000..6ce41f1 --- /dev/null +++ b/backend/tests/test_migrations.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +from app.models import ( + DependencySignals, + RepositoryMetrics, + ScanReport, +) +from app.models import ( + TestSignals as SignalsModel, +) +from app.scanner import scan_repository +from app.store import SCHEMA_VERSION, ScanStore + + +def test_v1_scan_schema_is_migrated_and_legacy_history_is_cataloged(tmp_path: Path) -> None: + database = tmp_path / "history.db" + report = ScanReport( + id="legacy-scan", + repository_path=str((tmp_path / "repo").resolve()), + repository_name="repo", + scanned_at="2026-09-12T00:00:00+00:00", + duration_ms=1, + metrics=RepositoryMetrics(), + languages={}, + category_scores={}, + health_score=100, + release_readiness="READY", + findings=[], + tests=SignalsModel(), + dependencies=DependencySignals(), + ) + connection = sqlite3.connect(database) + connection.execute( + """ + CREATE TABLE scans ( + id TEXT PRIMARY KEY, + repository_path TEXT NOT NULL, + repository_name TEXT NOT NULL, + scanned_at TEXT NOT NULL, + health_score INTEGER NOT NULL, + report_json TEXT NOT NULL + ) + """ + ) + connection.execute( + "INSERT INTO scans VALUES (?, ?, ?, ?, ?, ?)", + (report.id, report.repository_path, report.repository_name, report.scanned_at.isoformat(), 100, report.model_dump_json()), + ) + connection.commit() + connection.close() + + store = ScanStore(str(database)) + + assert store.get_state("missing") is None + assert store.repository_count() == 1 + migrated = store.get("legacy-scan") + assert migrated is not None + assert migrated.source.provider == "local" + assert migrated.source.repository_id is not None + assert store.list_repositories()[0].last_scan_id == "legacy-scan" + connection = sqlite3.connect(database) + assert connection.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION + connection.close() + + +def test_schema_upgrade_creates_a_timestamped_pre_migration_backup(tmp_path: Path) -> None: + database = tmp_path / "history.db" + repository = tmp_path / "repo" + repository.mkdir() + (repository / "README.md").write_text("# Demo\n", encoding="utf-8") + (repository / ".gitignore").write_text("*.db\n", encoding="utf-8") + + original = ScanStore(str(database)) + saved = original.save(scan_repository(str(repository))) + connection = sqlite3.connect(database) + connection.execute("PRAGMA user_version = 3") + connection.commit() + connection.close() + + migrated = ScanStore(str(database)) + + assert list(tmp_path.glob("history.db.pre-v3-*.bak")) + assert migrated.get(saved.id) is not None diff --git a/backend/tests/test_reporting.py b/backend/tests/test_reporting.py new file mode 100644 index 0000000..ee1923a --- /dev/null +++ b/backend/tests/test_reporting.py @@ -0,0 +1,12 @@ +from app.reporting import content_disposition, safe_report_filename + + +def test_report_filenames_are_safe_and_support_unicode() -> None: + filename = safe_report_filename('repo/evil\r\n"<é>', "html") + disposition = content_disposition(filename) + + assert filename.startswith("repopulse-repo_evil___") + assert "\r" not in disposition + assert "\n" not in disposition + assert 'filename="' in disposition + assert "filename*=UTF-8''" in disposition diff --git a/backend/tests/test_scanner.py b/backend/tests/test_scanner.py new file mode 100644 index 0000000..d720143 --- /dev/null +++ b/backend/tests/test_scanner.py @@ -0,0 +1,223 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +from app.models import Severity +from app.scanner import scan_repository + + +def _write_good_repo(root: Path) -> None: + (root / "README.md").write_text("# Demo\n", encoding="utf-8") + (root / "LICENSE").write_text("MIT\n", encoding="utf-8") + (root / ".gitignore").write_text(".env\n", encoding="utf-8") + (root / "src").mkdir() + (root / "tests").mkdir() + (root / ".gitea" / "workflows").mkdir(parents=True) + (root / ".gitea" / "workflows" / "check.yml").write_text("name: check\n", encoding="utf-8") + (root / "src" / "core.py").write_text("def answer():\n return 42\n", encoding="utf-8") + (root / "src" / "app.py").write_text("import src.core\n\ndef run():\n return src.core.answer()\n", encoding="utf-8") + (root / "tests" / "test_app.py").write_text("def test_app():\n assert True\n", encoding="utf-8") + + +def _realistic_fixture_secret() -> str: + parts = ["production", "secret", "value", "123456"] + return "-".join(parts) + + +def test_scan_collects_v1_signals_and_architecture(tmp_path: Path) -> None: + _write_good_repo(tmp_path) + + report = scan_repository(str(tmp_path)) + + assert report.repository_name == tmp_path.name + assert report.metrics.source_files == 3 + assert report.metrics.test_files == 1 + assert report.languages["Python"] > 0 + assert report.tests.ci_workflows == [".gitea/workflows/check.yml"] + assert report.release_gate is not None + assert report.release_gate.status in {"READY", "READY WITH WARNINGS"} + assert any(edge.source == "src/app.py" and edge.target == "src/core.py" for edge in report.architecture.edges) + assert any(item.path == "src/app.py" for item in report.hotspots) + assert 0 <= report.health_score <= 100 + + +def test_scan_collects_debt_and_dependency_signals(tmp_path: Path) -> None: + _write_good_repo(tmp_path) + (tmp_path / "package.json").write_text( + '{"dependencies":{"react":"latest"},"devDependencies":{"vitest":"^3.0.0"}}', + encoding="utf-8", + ) + (tmp_path / "src" / "app.py").write_text( + "# TODO improve this\n# FIXME remove workaround\nprint('hello')\n", + encoding="utf-8", + ) + + report = scan_repository(str(tmp_path)) + + assert report.metrics.todo == 1 + assert report.metrics.fixme == 1 + assert report.dependencies.declared_dependencies == 2 + assert report.dependencies.unbounded_dependencies == 1 + assert any(item.code == "dependency.unbounded" for item in report.findings) + + +def test_potential_secret_is_redacted_and_blocks_release(tmp_path: Path) -> None: + (tmp_path / "README.md").write_text("# Demo\n", encoding="utf-8") + (tmp_path / ".gitignore").write_text("*.db\n", encoding="utf-8") + secret_name = "API_" + "KEY" + (tmp_path / "app.py").write_text( + f"{secret_name} = 'super-secret-value-123456'\n", + encoding="utf-8", + ) + + report = scan_repository(str(tmp_path)) + secret_findings = [item for item in report.findings if item.code == "security.secret-assignment"] + + assert report.release_readiness == "BLOCKED" + assert report.release_gate is not None + assert report.release_gate.status == "BLOCKED" + assert secret_findings + assert secret_findings[0].severity == Severity.BLOCKER + assert "super-secret-example-value" not in secret_findings[0].message + + +def test_secret_detection_scans_real_literals_in_tests_but_ignores_obvious_templates(tmp_path: Path) -> None: + _write_good_repo(tmp_path) + realistic_secret = _realistic_fixture_secret() + token_key = "to" + "ken" + (tmp_path / "src" / "settings.py").write_text( + "secret = os.getenv('APP_SECRET')\n" + f"api_key = '{realistic_secret}'\n", + encoding="utf-8", + ) + (tmp_path / "tests" / "test_transport.py").write_text( + token_key + ' = "' + realistic_secret + '"\nresponse = b"token=test-token upstream body"\n', + encoding="utf-8", + ) + (tmp_path / "README.md").write_text("# Demo\n\n`token=example-documentation-value`\n", encoding="utf-8") + (tmp_path / ".env.development.example").write_text("APP_SECRET=example-development-secret\n", encoding="utf-8") + + report = scan_repository(str(tmp_path)) + secret_findings = [item for item in report.findings if item.code == "security.secret-assignment"] + + assert len(secret_findings) == 2 + assert {item.path for item in secret_findings} == {"src/settings.py", "tests/test_transport.py"} + assert all(item.guidance and item.guidance.remediation_class == "security remediation" for item in secret_findings) + assert secret_findings[0].line == 2 + assert not any(item.code == "security.env-committed" for item in report.findings) + + +def test_scan_budget_reports_coverage_without_turning_truncation_into_a_blocker(tmp_path: Path) -> None: + _write_good_repo(tmp_path) + for index in range(4): + (tmp_path / f"module_{index}.py").write_text("print('ok')\n", encoding="utf-8") + + from app.models import ScanLimits + + report = scan_repository(str(tmp_path), limits=ScanLimits(max_files=2, max_text_bytes_per_file=2_000_000, max_findings=20, max_secret_findings=5)) + + assert report.coverage is not None and report.coverage.truncated is True + assert report.coverage.skipped_files_by_budget > 0 + assert any(check.key == "analysis_coverage" and check.state.value == "warn" for check in report.release_gate.checks) + assert any(item.code == "analysis.findings-truncated" for item in report.findings) is False + + +def test_configuration_literal_secret_remains_a_blocker(tmp_path: Path) -> None: + _write_good_repo(tmp_path) + realistic_secret = _realistic_fixture_secret() + token_key = "to" + "ken" + (tmp_path / "settings.json").write_text( + json.dumps({token_key: realistic_secret}) + "\n", + encoding="utf-8", + ) + + report = scan_repository(str(tmp_path)) + secret_findings = [item for item in report.findings if item.code == "security.secret-assignment"] + + assert report.release_readiness == "BLOCKED" + assert [(item.path, item.line) for item in secret_findings] == [("settings.json", 1)] + + +def test_member_literal_secret_remains_a_blocker(tmp_path: Path) -> None: + _write_good_repo(tmp_path) + (tmp_path / "src" / "client.py").write_text( + "class Client:\n def __init__(self):\n self.api_key = 'production-secret-value-123456'\n", + encoding="utf-8", + ) + + report = scan_repository(str(tmp_path)) + secret_findings = [item for item in report.findings if item.code == "security.secret-assignment"] + + assert report.release_readiness == "BLOCKED" + assert [(item.path, item.line) for item in secret_findings] == [("src/client.py", 3)] + + +def test_documentation_only_repository_does_not_require_test_files(tmp_path: Path) -> None: + (tmp_path / "README.md").write_text("# Catalog entry\n", encoding="utf-8") + (tmp_path / ".gitignore").write_text(".DS_Store\n", encoding="utf-8") + + report = scan_repository(str(tmp_path)) + + assert report.metrics.source_files == 0 + assert not any(item.code == "testing.none-detected" for item in report.findings) + + +def test_scan_skips_symlinked_files_outside_repository(tmp_path: Path) -> None: + _write_good_repo(tmp_path) + external = tmp_path.parent / f"{tmp_path.name}-outside.py" + external.write_text("API_KEY = 'outside-secret-example-value'\n", encoding="utf-8") + linked = tmp_path / "linked.py" + try: + linked.symlink_to(external) + except (NotImplementedError, OSError): + pytest.skip("symlinks are unavailable in this environment") + + report = scan_repository(str(tmp_path)) + + assert report.metrics.source_files == 3 + assert all(finding.path != "linked.py" for finding in report.findings) + + +def test_scan_skips_symlinked_directories_outside_repository(tmp_path: Path) -> None: + _write_good_repo(tmp_path) + external = tmp_path.parent / f"{tmp_path.name}-outside-dir" + external.mkdir() + (external / "outside.py").write_text("API_KEY = 'outside-secret-example-value'\n", encoding="utf-8") + linked = tmp_path / "linked" + try: + linked.symlink_to(external, target_is_directory=True) + except (NotImplementedError, OSError): + pytest.skip("directory symlinks are unavailable in this environment") + + report = scan_repository(str(tmp_path)) + + assert report.metrics.source_files == 3 + assert all(finding.path != "linked/outside.py" for finding in report.findings) + + +def test_malformed_dependency_manifests_are_reported_without_crashing(tmp_path: Path) -> None: + (tmp_path / "README.md").write_text("# Demo\n", encoding="utf-8") + (tmp_path / ".gitignore").write_text("\n", encoding="utf-8") + (tmp_path / "package.json").write_text("[]", encoding="utf-8") + (tmp_path / "pyproject.toml").write_text("[project]\ndependencies = \"invalid\"\n", encoding="utf-8") + (tmp_path / "pom.xml").write_text("", encoding="utf-8") + + report = scan_repository(str(tmp_path)) + codes = {finding.code for finding in report.findings} + + assert "dependency.invalid-package-json" in codes + assert "dependency.invalid-pyproject" in codes + assert "dependency.invalid-pom" in codes + + +def test_empty_git_repository_does_not_fallback_to_untracked_files(tmp_path: Path) -> None: + (tmp_path / "stray.py").write_text("print('not tracked')\n", encoding="utf-8") + subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True) + + report = scan_repository(str(tmp_path)) + + assert report.git.available is True + assert report.git.tracked_files == 0 + assert report.metrics.files == 0 + assert report.metrics.source_files == 0 diff --git a/backend/tests/test_signals.py b/backend/tests/test_signals.py new file mode 100644 index 0000000..2b53512 --- /dev/null +++ b/backend/tests/test_signals.py @@ -0,0 +1,113 @@ +from pathlib import Path + +from app.signals import analyze_dependencies, detect_test_signals + + +def test_dependency_analysis_handles_multiple_ecosystems(tmp_path: Path) -> None: + package = tmp_path / "package.json" + package.write_text('{"dependencies":{"react":"latest"}}', encoding="utf-8") + requirements = tmp_path / "requirements.txt" + requirements.write_text("fastapi>=0.100\n", encoding="utf-8") + lock = tmp_path / "package-lock.json" + lock.write_text("{}", encoding="utf-8") + + signals, findings = analyze_dependencies( + tmp_path, + { + "package.json": [package], + "requirements.txt": [requirements], + "package-lock.json": [lock], + }, + ) + + assert signals.declared_dependencies == 2 + assert signals.unbounded_dependencies == 2 + assert signals.unpinned_dependencies == 0 + assert "package-lock.json" in signals.lockfiles + assert any(item.code == "dependency.unbounded" for item in findings) + + +def test_python_dependency_ranges_only_flag_truly_unbounded_declarations(tmp_path: Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[project] +dependencies = [ + "exact==1.2.3", + "compatible~=2.0", + "bounded>=3.0,<4.0", + "open>=5.0", +] +optional-dependencies.dev = ["pytest~=8.0"] +""".strip(), + encoding="utf-8", + ) + + signals, findings = analyze_dependencies(tmp_path, {"pyproject.toml": [pyproject]}) + + assert signals.declared_dependencies == 5 + assert signals.unbounded_dependencies == 1 + assert [finding.code for finding in findings] == ["dependency.python-unbounded"] + + +def test_test_signals_detect_ci_coverage_and_frameworks() -> None: + text_by_path = { + "package.json": '{"devDependencies":{"vitest":"^3.0.0","@playwright/test":"^1.0.0"}}', + } + signals = detect_test_signals( + ["src/app.ts", "src/app.test.ts", ".gitea/workflows/ci.yml", "coverage/lcov.info"], + source_files=2, + test_files=1, + text_by_path=text_by_path, + ) + + assert "Vitest" in signals.frameworks + assert "Playwright" in signals.frameworks + assert signals.ci_workflows == [".gitea/workflows/ci.yml"] + assert signals.coverage_artifacts == ["coverage/lcov.info"] + assert signals.test_source_ratio == 50.0 + + +def test_test_signals_distinguish_validation_from_deploy_only_workflows() -> None: + signals = detect_test_signals( + [".gitea/workflows/validate.yml", ".gitea/workflows/deploy.yml"], + source_files=1, + test_files=1, + text_by_path={ + ".gitea/workflows/validate.yml": "steps:\n - run: pytest --cov\n - run: npm run build\n", + ".gitea/workflows/deploy.yml": "steps:\n - run: deploy production\n", + }, + ) + + assert signals.ci_validation_workflows == [".gitea/workflows/validate.yml"] + assert signals.ci_build_workflows == [".gitea/workflows/validate.yml"] + assert signals.ci_deploy_only_workflows == [".gitea/workflows/deploy.yml"] + assert signals.coverage_evidence == [".gitea/workflows/validate.yml"] + + +def test_dependency_analysis_follows_local_requirements_includes_and_workspace_locks(tmp_path: Path) -> None: + packages = tmp_path / "packages" / "web" + packages.mkdir(parents=True) + package = packages / "package.json" + package.write_text('{"dependencies":{"shared":"workspace:*"}}', encoding="utf-8") + lock = tmp_path / "pnpm-lock.yaml" + lock.write_text("lockfileVersion: '9.0'\n", encoding="utf-8") + requirements = tmp_path / "requirements.txt" + included = tmp_path / "requirements-prod.txt" + requirements.write_text("-r requirements-prod.txt\n", encoding="utf-8") + included.write_text("fastapi==0.116.0\n", encoding="utf-8") + + signals, findings = analyze_dependencies( + tmp_path, + { + "package.json": [package], + "pnpm-lock.yaml": [lock], + "requirements.txt": [requirements], + "requirements-prod.txt": [included], + }, + ) + + assert signals.unbounded_dependencies == 0 + assert signals.included_requirements == ["requirements-prod.txt"] + assert signals.declared_dependencies == 2 + assert not any(item.code == "dependency.no-lockfile" for item in findings) diff --git a/backend/tests/test_signals_v11.py b/backend/tests/test_signals_v11.py new file mode 100644 index 0000000..fe16b0d --- /dev/null +++ b/backend/tests/test_signals_v11.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +from app.git_analysis import analyze_git +from app.scanner import _walk_files, scan_repository + + +def test_non_git_fallback_applies_root_gitignore_rules(tmp_path: Path) -> None: + (tmp_path / ".gitignore").write_text("ignored.py\nignored-dir/\n*.log\n!keep.log\n", encoding="utf-8") + (tmp_path / "kept.py").write_text("print('kept')\n", encoding="utf-8") + (tmp_path / "ignored.py").write_text("print('ignored')\n", encoding="utf-8") + (tmp_path / "keep.log").write_text("kept\n", encoding="utf-8") + (tmp_path / "drop.log").write_text("dropped\n", encoding="utf-8") + (tmp_path / "ignored-dir").mkdir() + (tmp_path / "ignored-dir" / "nested.py").write_text("print('nested')\n", encoding="utf-8") + + files = {path.relative_to(tmp_path).as_posix() for path in _walk_files(tmp_path)} + + assert "kept.py" in files + assert "keep.log" in files + assert "ignored.py" not in files + assert "drop.log" not in files + assert "ignored-dir/nested.py" not in files + + +def test_git_churn_attributes_renames_to_the_new_path(tmp_path: Path) -> None: + subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True) + subprocess.run(["git", "-C", str(tmp_path), "config", "user.email", "test@example.com"], check=True) + subprocess.run(["git", "-C", str(tmp_path), "config", "user.name", "RepoPulse Test"], check=True) + (tmp_path / "old.py").write_text("print('hello')\n", encoding="utf-8") + subprocess.run(["git", "-C", str(tmp_path), "add", "."], check=True) + subprocess.run(["git", "-C", str(tmp_path), "commit", "--quiet", "-m", "initial"], check=True) + subprocess.run(["git", "-C", str(tmp_path), "mv", "old.py", "new.py"], check=True) + subprocess.run(["git", "-C", str(tmp_path), "commit", "--quiet", "-m", "rename"], check=True) + (tmp_path / "new.py").write_text("print('hello world')\n", encoding="utf-8") + subprocess.run(["git", "-C", str(tmp_path), "commit", "--quiet", "-am", "edit after rename"], check=True) + + signals, churn = analyze_git(tmp_path) + + assert signals.renamed_files == ["new.py"] + assert churn.get("new.py", 0) > 0 + assert "old.py" not in churn + + +def test_policy_suppresses_accepted_risk_without_erasing_the_observation(tmp_path: Path) -> None: + (tmp_path / "README.md").write_text("# Demo\n", encoding="utf-8") + (tmp_path / ".gitignore").write_text("\n", encoding="utf-8") + (tmp_path / ".repopulse.yml").write_text("suppressions:\n - security.secret-assignment\n", encoding="utf-8") + (tmp_path / "app.py").write_text("api_key = 'abcdefghijk1234'\n", encoding="utf-8") + + report = scan_repository(str(tmp_path)) + finding = next(item for item in report.findings if item.code == "security.secret-assignment") + + assert finding.suppressed is True + assert report.category_scores["Security basics"] == 100 + assert report.release_gate is not None + assert report.release_gate.checks[0].state.value == "pass" diff --git a/backend/tests/test_store.py b/backend/tests/test_store.py new file mode 100644 index 0000000..14cd0c6 --- /dev/null +++ b/backend/tests/test_store.py @@ -0,0 +1,161 @@ +import json +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import app.store as store_module +from app.scanner import scan_repository +from app.store import ScanStore + + +def test_scan_history_comparison_and_trend(tmp_path: Path) -> None: + repository = tmp_path / "repo" + repository.mkdir() + (repository / "README.md").write_text("# Demo\n", encoding="utf-8") + (repository / ".gitignore").write_text("*.db\n", encoding="utf-8") + + store = ScanStore(str(tmp_path / "repopulse.db")) + first = store.save(scan_repository(str(repository))) + + (repository / "app.py").write_text("print('hello')\n", encoding="utf-8") + (repository / "tests").mkdir() + (repository / "tests" / "test_app.py").write_text("def test_ok():\n assert True\n", encoding="utf-8") + second = store.save(scan_repository(str(repository))) + + assert store.get(second.id) == second + assert store.recent(1) == [second] + assert store.previous_for_repository(second.repository_path, second.id) == first + + comparison = store.compare(second) + assert comparison.previous_scan_id == first.id + assert comparison.health_score_delta == second.health_score - first.health_score + assert comparison.metric_deltas["source_files"] == 2 + + trend = store.trend(second.repository_path) + assert [item.scan_id for item in trend] == [first.id, second.id] + assert trend[-1].health_score == second.health_score + + +def test_comparison_uses_the_scan_before_the_selected_scan(tmp_path: Path) -> None: + repository = tmp_path / "repo" + repository.mkdir() + (repository / "README.md").write_text("# Demo\n", encoding="utf-8") + (repository / ".gitignore").write_text("*.db\n", encoding="utf-8") + + store = ScanStore(str(tmp_path / "repopulse.db")) + first = store.save(scan_repository(str(repository))) + second = store.save(scan_repository(str(repository))) + third = store.save(scan_repository(str(repository))) + + assert store.previous_for_repository(second.repository_path, second.id) == first + assert store.previous_for_repository(third.repository_path, third.id) == second + + +def test_repository_summary_exposes_compact_remediation_context(tmp_path: Path) -> None: + repository = tmp_path / "repo" + repository.mkdir() + (repository / "README.md").write_text("# Demo\n", encoding="utf-8") + (repository / ".gitignore").write_text(".env\n", encoding="utf-8") + (repository / ".env").write_text("TOKEN=actual-secret-value-123456\n", encoding="utf-8") + + store = ScanStore(str(tmp_path / "repopulse.db")) + store.save(scan_repository(str(repository))) + + (repository / ".env").unlink() + (repository / "app.py").write_text("api_key = 'actual-secret-value-123456'\n", encoding="utf-8") + store.save(scan_repository(str(repository))) + + summary = store.list_repositories()[0] + + assert summary.last_scan_blockers == 1 + assert summary.last_scan_primary_signal is not None + assert summary.last_scan_primary_signal.code == "security.secret-assignment" + assert summary.last_scan_primary_signal.count == 1 + assert summary.last_scan_added_findings >= 1 + assert summary.last_scan_resolved_findings == 1 + + +def test_corrupt_database_is_quarantined_and_recreated(tmp_path: Path) -> None: + database = tmp_path / "repopulse.db" + database.write_bytes(b"not a sqlite database") + + store = ScanStore(str(database)) + + assert store.recent() == [] + assert list(tmp_path.glob("repopulse.db.corrupt-*")) + assert store.database_recovery_status()["status"] == "degraded_recovered" + + +def test_schema_migration_records_the_durable_backup_artifact(tmp_path: Path) -> None: + database = tmp_path / "repopulse.db" + initial_store = ScanStore(str(database)) + with initial_store._connection() as db: + db.execute("PRAGMA user_version = 3") + + migrated_store = ScanStore(str(database)) + + recorded = migrated_store.get_state("last_schema_migration_backup") + assert recorded is not None + metadata = json.loads(recorded) + assert metadata["from_version"] == 3 + assert metadata["to_version"] == store_module.SCHEMA_VERSION + assert (tmp_path / metadata["backup_file"]).is_file() + assert migrated_store.storage_stats()["last_schema_migration_backup"] == recorded + + +def test_history_is_bounded_per_repository(tmp_path: Path, monkeypatch) -> None: + repository = tmp_path / "repo" + repository.mkdir() + (repository / "README.md").write_text("# Demo\n", encoding="utf-8") + + monkeypatch.setattr(store_module, "MAX_HISTORY_PER_REPOSITORY", 2) + store = ScanStore(str(tmp_path / "repopulse.db")) + first = store.save(scan_repository(str(repository))) + second = store.save(scan_repository(str(repository))) + third = store.save(scan_repository(str(repository))) + + assert [item.id for item in store.recent_for_repository(third.repository_path)] == [third.id, second.id] + assert store.get(first.id) is None + + +def test_retention_compacts_old_detailed_payloads_and_expires_old_snapshots(tmp_path: Path, monkeypatch) -> None: + repository = tmp_path / "repo" + repository.mkdir() + (repository / "README.md").write_text("# Demo\n", encoding="utf-8") + (repository / ".gitignore").write_text("*.db\n", encoding="utf-8") + monkeypatch.setenv("REPOPULSE_FULL_REPORTS_PER_REPOSITORY", "1") + monkeypatch.setenv("REPOPULSE_REPORT_RETENTION_DAYS", "30") + store = ScanStore(str(tmp_path / "repopulse.db")) + + first = store.save(scan_repository(str(repository))) + second = store.save(scan_repository(str(repository))) + compacted = store.get(first.id) + + assert compacted is not None and compacted.compacted is True + assert compacted.findings == [] + assert store.get(second.id) is not None + + expired = second.model_copy( + update={"id": "expired-scan", "scanned_at": datetime.now(UTC) - timedelta(days=31)} + ) + store.save(expired) + assert store.get("expired-scan") is None + + +def test_catalog_summary_uses_denormalized_fields_without_report_json_parsing(tmp_path: Path, monkeypatch) -> None: + repository = tmp_path / "repo" + repository.mkdir() + (repository / "README.md").write_text("# Demo\n", encoding="utf-8") + (repository / ".gitignore").write_text("*.db\n", encoding="utf-8") + store = ScanStore(str(tmp_path / "repopulse.db")) + store.save(scan_repository(str(repository))) + + def fail_if_called(_row): + raise AssertionError("catalog list must not parse report_json") + + monkeypatch.setattr(store, "_report_from_row", fail_if_called) + summary = store.list_repositories()[0] + assert summary.last_scan_id + storage = store.storage_stats() + assert storage["scan_count"] == 1 + assert storage["mirror_count"] >= 0 + assert storage["worktree_count"] >= 0 diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..29a7f01 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,80 @@ +name: repopulse + +services: + backend: + build: + context: . + dockerfile: backend/Dockerfile + container_name: repopulse-backend + restart: unless-stopped + environment: + REPOPULSE_SCAN_ROOT: /scan + REPOPULSE_DB: /data/repopulse.db + REPOPULSE_MAX_CONCURRENT_SCANS: "${REPOPULSE_MAX_CONCURRENT_SCANS:-1}" + REPOPULSE_JOB_LEASE_SECONDS: "${REPOPULSE_JOB_LEASE_SECONDS:-900}" + REPOPULSE_SCAN_MAX_FILES: "${REPOPULSE_SCAN_MAX_FILES:-10000}" + REPOPULSE_SCAN_MAX_TEXT_BYTES: "${REPOPULSE_SCAN_MAX_TEXT_BYTES:-2000000}" + REPOPULSE_SCAN_MAX_TOTAL_TEXT_BYTES: "${REPOPULSE_SCAN_MAX_TOTAL_TEXT_BYTES:-50000000}" + REPOPULSE_SCAN_MAX_FINDINGS: "${REPOPULSE_SCAN_MAX_FINDINGS:-2000}" + REPOPULSE_SCAN_MAX_SECRET_FINDINGS: "${REPOPULSE_SCAN_MAX_SECRET_FINDINGS:-20}" + REPOPULSE_GITEA_URL: "${REPOPULSE_GITEA_URL:-}" + REPOPULSE_GITEA_TOKEN: "${REPOPULSE_GITEA_TOKEN:-}" + REPOPULSE_GITEA_BRANCH_WORKERS: "${REPOPULSE_GITEA_BRANCH_WORKERS:-4}" + REPOPULSE_GITEA_WEBHOOK_SECRET: "${REPOPULSE_GITEA_WEBHOOK_SECRET:-}" + REPOPULSE_GITEA_SYNC_INTERVAL_SECONDS: "${REPOPULSE_GITEA_SYNC_INTERVAL_SECONDS:-300}" + REPOPULSE_REPOSITORY_CACHE: /data/repos + REPOPULSE_MAX_QUEUED_SCANS: "${REPOPULSE_MAX_QUEUED_SCANS:-100}" + REPOPULSE_HISTORY_PER_REPOSITORY: "${REPOPULSE_HISTORY_PER_REPOSITORY:-200}" + REPOPULSE_FULL_REPORTS_PER_REPOSITORY: "${REPOPULSE_FULL_REPORTS_PER_REPOSITORY:-30}" + REPOPULSE_REPORT_RETENTION_DAYS: "${REPOPULSE_REPORT_RETENTION_DAYS:-365}" + REPOPULSE_OPERATOR_TOKEN: "${REPOPULSE_OPERATOR_TOKEN:-}" + volumes: + - "${REPOPULSE_DATA_ROOT:-repopulse_data}:/data" + - "${REPOPULSE_SOURCE_ROOT:-..}:/scan:ro" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).read()"] + interval: 20s + timeout: 5s + start_period: 10s + retries: 5 + labels: + net.unraid.docker.managed: "dockerman" + security_opt: + - no-new-privileges:true + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + + frontend: + build: + context: . + dockerfile: frontend/Dockerfile + container_name: repopulse + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + ports: + - "${REPOPULSE_PORT:-1254}:80" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/health >/dev/null || exit 1"] + interval: 20s + timeout: 5s + start_period: 10s + retries: 5 + labels: + net.unraid.docker.managed: "dockerman" + net.unraid.docker.webui: "http://127.0.0.1:${REPOPULSE_PORT:-1254}" + security_opt: + - no-new-privileges:true + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + +volumes: + repopulse_data: + name: repopulse_data diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..52e1160 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +name: repopulse + +services: + repopulse: + build: + context: . + dockerfile: Dockerfile.unraid + container_name: repopulse + restart: unless-stopped + environment: + REPOPULSE_SCAN_ROOT: /scan + REPOPULSE_DB: /data/repopulse.db + REPOPULSE_STATIC_DIR: /app/static + REPOPULSE_MAX_CONCURRENT_SCANS: "${REPOPULSE_MAX_CONCURRENT_SCANS:-1}" + REPOPULSE_JOB_LEASE_SECONDS: "${REPOPULSE_JOB_LEASE_SECONDS:-900}" + REPOPULSE_SCAN_MAX_FILES: "${REPOPULSE_SCAN_MAX_FILES:-10000}" + REPOPULSE_SCAN_MAX_TEXT_BYTES: "${REPOPULSE_SCAN_MAX_TEXT_BYTES:-2000000}" + REPOPULSE_SCAN_MAX_TOTAL_TEXT_BYTES: "${REPOPULSE_SCAN_MAX_TOTAL_TEXT_BYTES:-50000000}" + REPOPULSE_SCAN_MAX_FINDINGS: "${REPOPULSE_SCAN_MAX_FINDINGS:-2000}" + REPOPULSE_SCAN_MAX_SECRET_FINDINGS: "${REPOPULSE_SCAN_MAX_SECRET_FINDINGS:-20}" + REPOPULSE_GITEA_URL: "${REPOPULSE_GITEA_URL:-}" + REPOPULSE_GITEA_TOKEN: "${REPOPULSE_GITEA_TOKEN:-}" + REPOPULSE_GITEA_BRANCH_WORKERS: "${REPOPULSE_GITEA_BRANCH_WORKERS:-4}" + REPOPULSE_GITEA_WEBHOOK_SECRET: "${REPOPULSE_GITEA_WEBHOOK_SECRET:-}" + REPOPULSE_GITEA_SYNC_INTERVAL_SECONDS: "${REPOPULSE_GITEA_SYNC_INTERVAL_SECONDS:-300}" + REPOPULSE_REPOSITORY_CACHE: /data/repos + REPOPULSE_MAX_QUEUED_SCANS: "${REPOPULSE_MAX_QUEUED_SCANS:-100}" + REPOPULSE_HISTORY_PER_REPOSITORY: "${REPOPULSE_HISTORY_PER_REPOSITORY:-200}" + REPOPULSE_FULL_REPORTS_PER_REPOSITORY: "${REPOPULSE_FULL_REPORTS_PER_REPOSITORY:-30}" + REPOPULSE_REPORT_RETENTION_DAYS: "${REPOPULSE_REPORT_RETENTION_DAYS:-365}" + REPOPULSE_OPERATOR_TOKEN: "${REPOPULSE_OPERATOR_TOKEN:-}" + REPOPULSE_MAINTENANCE_TOKEN: "${REPOPULSE_MAINTENANCE_TOKEN:-}" + volumes: + - "${REPOPULSE_DATA_ROOT:-repopulse_data}:/data" + - "${REPOPULSE_SOURCE_ROOT:-..}:/scan:ro" + ports: + - "${REPOPULSE_PORT:-1254}:8000" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).read()"] + interval: 20s + timeout: 5s + start_period: 10s + retries: 5 + labels: + net.unraid.docker.managed: "dockerman" + net.unraid.docker.webui: "http://127.0.0.1:${REPOPULSE_PORT:-1254}" + security_opt: + - no-new-privileges:true + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + +volumes: + repopulse_data: + name: repopulse_data diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..d550c09 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,100 @@ +# RepoPulse Architecture + +## Runtime shape + +RepoPulse is a Gitea-native deterministic analysis service. Source code is read by the backend only; the browser receives repository summaries, derived metrics, findings and bounded graph metadata. Gitea is a read-only provider, while SQLite remains the source of RepoPulse catalog and history state during provider outages. + +```text +Gitea (read-only API + Git) + │ catalog + default-branch SHA + ▼ +FastAPI ── SQLite catalog / durable job queue / scan history (/data) + │ │ + │ ├── bounded mirror cache (/data/repos/mirrors) + │ └── detached worktrees (/data/repos/worktrees) + │ │ + │ ▼ + ├── single-process worker → deterministic scanner → report persistence + │ + └── /api → Nginx / React repository cockpit + │ + └── Advanced local provider → read-only /scan +``` + +The worker starts and stops through FastAPI lifespan. SQLite claim transactions and leases make queued work restart-safe; the current deployment intentionally uses one worker and one active scan by default, without Redis or Celery. + +## Repository identity and lifecycle + +Gitea repositories are first-class records keyed by `id = gitea:` and constrained by `(provider, provider_repository_id)`. Owner/name, description, URLs, visibility, archive state, default branch and observed HEAD metadata are catalog attributes; filesystem paths are not primary identity. Local scans use a separate stable `local:` identity for v1 compatibility. + +Catalog synchronization is bounded and paginated. After a successful listing it resolves each default-branch HEAD independently with a bounded `REPOPULSE_GITEA_BRANCH_WORKERS` pool (default 4). A branch failure is persisted as a sanitized repository-specific `sync_error`, while every other repository is upserted and remains usable. A failed or truncated listing preserves all prior catalog/history rows; only a complete listing may mark an absent repository unavailable. Archived/unavailable records are retained and are not cascade-deleted. + +The computed repository state is one of `never_scanned`, `current`, `stale`, `queued`, `scanning`, `failed`, `unavailable` or `archived`. The scheduler reconciles `latest_revision` against `last_scan_revision`, respects `policy.auto_scan`, queues new/stale revisions, and retries after the next sync interval. It does not queue a repository whose observed HEAD already has a successful scan. + +## Exact source acquisition + +For a Gitea job, the requested SHA is captured at enqueue time. The acquirer uses the configured Gitea origin only, passes the token through a subprocess environment header rather than a URL, fetches the selected default branch into a provider-ID mirror, resolves the requested immutable commit, and creates a detached worktree under the managed cache. The scanner sees that one worktree only; cleanup removes it and prunes stale Git registrations. Corrupt mirrors are discarded only after a local bare-repository validation failure, so a remote outage does not erase a usable cache. + +## Scan boundary + +When `REPOPULSE_SCAN_ROOT` is configured, every local scan path is resolved and checked against that root before analysis. Compose sets the root to `/scan` and mounts the user-selected source directory read-only. The scanner then resolves every candidate file, rejects symlinked files and directories (including symlink ancestors), and requires the resolved file to remain below the scan root. This prevents the API from being used as a generic filesystem browser outside the configured analysis boundary. Gitea worktrees are acquired into `/data`, not `/scan`, and are controlled by repository-ID/job-ID validation. + +## Persistence and deployment boundary + +The exact-source deployment controller replaces the registered repository source directory on each deployment. SQLite history, the catalog, queue and mirror cache must therefore not live below that directory. Compose uses the Docker-managed named volume `repopulse_data` for `/data` when `REPOPULSE_DATA_ROOT` is unset (the production configuration); this volume is independent of source refreshes and container recreation. The checked-in `.env.example` opts local Compose into `REPOPULSE_DATA_ROOT=./data` for convenient checkout-local development. `/scan` remains the read-only source boundary and `/data` is the only writable application mount. + +The AppOps-managed runtime candidate is defined separately in `.itworx/deployment.yml` and built with `deploy/managed/Dockerfile`; the legacy root `docker-compose.yml` keeps its existing topology and now forwards the optional `REPOPULSE_MAINTENANCE_TOKEN` so the old writer can be authenticated and quiesced before cutover. `Dockerfile.unraid` remains unchanged pending cutover. The managed target uses the immutable controller-selected source revision to build image `repopulse-managed:production`, runs as UID/GID `10001:10001` with a read-only root filesystem and one existing NPM network, binds `/mnt/pool/appdata/repopulse/data` to writable `/data`, and binds only the existing controller-owned `repopulse` and `privatemuse` source directories read-only at `/scan/repopulse` and `/scan/privatemuse`. The fixed AppOps profile allows only these exact external source mounts and rejects other paths. Git mirrors and worktrees stay under `/data/repos`, while process scratch files use the bounded `/tmp` tmpfs. Runtime secrets are restricted to the fixed Gitea URL/token and a dedicated RepoPulse maintenance token in `deploy.managed.env`; the legacy `deploy.env` remains available for rollback. No Gitea webhook secret is configured. + +## Inventory pipeline and analysis coverage + +The scanner prefers `git ls-files` for a top-level worktree so ignored/untracked build artifacts do not distort results. An empty Git worktree remains empty rather than silently falling back. Non-Git directories use a filesystem traversal that excludes common generated/vendor directories and applies bounded root/nested `.gitignore` rules. Text files larger than the configured internal read threshold are not parsed as source, and binary files are skipped. + +Every report includes `ScanCoverage`: bounded file, per-file-text, total-text, finding and credential-finding limits plus the observed skipped counts. A limit produces a deterministic partial result and an analysis-coverage warning rather than an unqualified completeness claim or an artificial blocker. + +## Signals + +The v1 scan combines: + +- language and non-empty LOC inventory; +- test-file, test-framework, CI and coverage-artifact detection; +- dependency manifests and lockfiles across JavaScript, Python, Go, Rust and Maven; +- TODO/FIXME/HACK/XXX debt; +- large files/modules and Python function-size/branch heuristics; +- basic credential-assignment detection with values redacted; +- bounded recent Git commit/contributor/churn information. + +Malformed dependency manifests are represented as warning findings instead of aborting the complete scan. CI workflows are classified from supported commands as validation, build or deploy-only rather than file presence alone. Requirements includes and workspace-root lockfiles are handled safely and boundedly. Credential heuristics redact values, ignore obvious templates/environment references, and still flag real-looking fixture literals for review. + +Each finding is enriched by a backend rule definition with confidence, why-it-matters text, recommended action, remediation class and explainable priority. The browser renders retained intelligence; it does not duplicate rule knowledge or mutate external repositories. + +## Architecture graph + +Python imports are parsed using the standard-library AST. Local JavaScript and TypeScript relative imports, bounded `baseUrl`/`paths` aliases from `tsconfig.json`/`jsconfig.json`, and simple local workspace package imports are resolved against known source files. Only local edges are retained; this remains heuristic source analysis, not a TypeScript compiler. The displayed graph is bounded to 160 nodes and 600 edges to keep large repositories responsive; the response explicitly indicates truncation, reports total discovered nodes/edges and distinguishes visible coupling from total coupling used in hotspot ranking. Extensionless, index and `.js`-specifier-to-TypeScript resolution are supported. + +## Hotspot model + +Hotspot score is deterministic and combines source size, debt markers, recent changed-line churn and local inbound/outbound coupling. The UI surfaces both the score and reasons so it is not a black-box ranking. + +## Health and release gate + +Category scores start at 100 and are reduced only by findings with an explicit deduction. The overall health score is the average category score. The release gate separately evaluates blocker security findings, test presence, CI, dependency hygiene (including unbounded and unpinned declarations), repository health and engineering warnings, producing `READY`, `READY WITH WARNINGS` or `BLOCKED`. + +## History and comparison + +Every report is serialized as a snapshot in SQLite using WAL. Lightweight scan summary columns (readiness, counts, primary signal, deltas and triage priority) drive catalog CTE queries without deserializing full reports. Comparison calculates metric/category deltas and added/resolved findings against the chronological previous scan of the same repository identity. Findings carry stable semantic fingerprints so line movement does not appear as a new risk. Trend endpoints return bounded chronological snapshots for UI visualization. By default, up to 200 snapshots are retained per repository, the latest 30 remain detailed, older retained payloads compact to summary evidence, and snapshots older than 365 days are removed. v1 path history is backfilled into a local catalog identity and is never deleted solely for migration. + +## Reporting + +RepoPulse can export the complete JSON report or a standalone HTML report. HTML output escapes repository-derived text, export headers use sanitized RFC 5987 filenames, and neither format includes detected secret values or Gitea credentials. A root `.repopulse.yml`/`.yaml` may configure automatic scanning, minimum health, blocker allowance, required tests/CI and finding-code suppressions. Suppressed observations remain visible and are excluded from deductions and gate blockers. + +## Job state and failure isolation + +The persisted queue records repository, trigger, requested/actual revision, phase, progress, attempt, timestamps, resulting scan and sanitized error information. Claims use `BEGIN IMMEDIATE` plus a status guard, so two processes cannot own the same queued job. They move through `queued → acquiring_source → scanning → analyzing → persisting → completed` or an isolated `failed` outcome. A heartbeat renews each active lease; only a crashed worker’s expired lease returns work to `queued` for recovery. Older unstarted revisions become `superseded`; explicit cancellation states remain representable. One repository failure does not terminate the worker loop or affect other queued repositories. + +## Operator maintenance mode + +The `app_state` key `maintenance_mode` durably records whether operator maintenance is enabled; it uses the existing state table and does not change the SQLite schema version. Operator enter/exit endpoints require `REPOPULSE_MAINTENANCE_TOKEN` (or the existing general operator token on legacy installations). Entering is serialized against API mutations and catalog synchronization so an accepted external write completes before the pause is committed. While paused, mutation requests and webhooks return `423 Locked`; the scheduler skips heartbeat writes, Gitea synchronization, queue claims and new scan-job enqueue operations. Existing queued jobs remain queued for later resumption. A job claimed before the mode transition may complete and persist its result. Queue depth, active count, active-drain state and full-drain state are reported by the authenticated maintenance endpoint and the read-only meta endpoint. A malformed persisted maintenance value fails closed as paused. + +## Operational configuration + +`REPOPULSE_GITEA_URL` and `REPOPULSE_GITEA_TOKEN` enable the provider. `REPOPULSE_GITEA_SYNC_INTERVAL_SECONDS`, `REPOPULSE_GITEA_BRANCH_WORKERS`, `REPOPULSE_MAX_QUEUED_SCANS`, `REPOPULSE_JOB_LEASE_SECONDS`, scan-budget and retention variables, and `REPOPULSE_REPOSITORY_CACHE` bound reconciliation, queue depth, active leases, analysis and cache placement. The durable Gitea runner deliberately has one worker; `/api/v1/meta` exposes requested versus actual capacity. `REPOPULSE_GITEA_WEBHOOK_SECRET` enables signed default-branch push intake; non-push, non-default-branch, invalid and branch-deletion events are acknowledged without scheduling work. `REPOPULSE_OPERATOR_TOKEN` protects mutating endpoints when an authenticated proxy perimeter is not the selected boundary. `/api/v1/meta` exposes repository state counts, queue depth, storage/retention metrics, recovery status, worker heartbeat, connector URL (without token) and last sync result. diff --git a/docs/screenshots/demo-explorer.png b/docs/screenshots/demo-explorer.png new file mode 100644 index 0000000..dbdbd88 Binary files /dev/null and b/docs/screenshots/demo-explorer.png differ diff --git a/docs/screenshots/demo-portfolio.png b/docs/screenshots/demo-portfolio.png new file mode 100644 index 0000000..ac84545 Binary files /dev/null and b/docs/screenshots/demo-portfolio.png differ diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..63bacfe --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:22-alpine AS build + +WORKDIR /app +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci --no-audit --no-fund +COPY frontend/ ./ +RUN npm run build + +FROM nginx:1.27-alpine +COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/frontend/e2e/mock-api.mjs b/frontend/e2e/mock-api.mjs new file mode 100644 index 0000000..39ad1a0 --- /dev/null +++ b/frontend/e2e/mock-api.mjs @@ -0,0 +1,55 @@ +import { createServer } from 'node:http' + +const now = '2026-09-14T08:00:00.000Z' +const policy = { auto_scan: true, min_health_score: 0, max_blockers: 0, require_tests: false, require_ci: false, suppressions: [] } +const guidance = { + remediation_class: 'dependency reproducibility', confidence: 'high', + why: 'A deterministic package-manager lockfile was not found for this workspace.', + recommended_action: 'Commit the package-manager lockfile used by the build.', + priority: 800, priority_rationale: 'Blocker signal; retained rule evidence.', +} +const finding = { code: 'dependency.no-lockfile', category: 'Dependencies', severity: 'blocker', message: 'No lockfile was retained for the declared dependencies.', path: 'package.json', line: 4, deduction: 8, fingerprint: 'finding-alpha', suppressed: false, guidance } +const report = { + id: 'scan-alpha', repository_path: '/scan/Alpha', repository_name: 'Alpha', + source: { origin: 'gitea', provider: 'gitea', repository_id: 'gitea:1', repository_full_name: 'Jens/Alpha', branch: 'main', requested_revision: 'a'.repeat(40), actual_revision: 'a'.repeat(40), trigger: 'manual' }, + policy, scanned_at: now, duration_ms: 42, metrics: { files: 5, source_files: 2, lines_of_code: 20, test_files: 1, todo: 0, fixme: 0, hack: 0, xxx: 0, dependency_manifests: 1, declared_dependencies: 2, large_files: 0, large_source_files: 0 }, + languages: { TypeScript: 20 }, category_scores: { Architecture: 100, Testing: 100, Maintainability: 100, Documentation: 100, Dependencies: 92, 'Repository hygiene': 100, 'Security basics': 100 }, + health_score: 88, release_readiness: 'BLOCKED', findings: [finding], + tests: { frameworks: ['Vitest'], coverage_artifacts: [], ci_workflows: ['.gitea/workflows/validate.yml'], ci_validation_workflows: ['.gitea/workflows/validate.yml'], ci_build_workflows: ['.gitea/workflows/validate.yml'], ci_deploy_only_workflows: [], coverage_evidence: ['.gitea/workflows/validate.yml'], test_source_ratio: 50 }, + git: { available: true, commit_count: 3, contributor_count: 1, latest_commit_at: now, tracked_files: 5, renamed_files: [] }, dependencies: { manifests: ['package.json'], lockfiles: [], declared_dependencies: 2, unbounded_dependencies: 0, unpinned_dependencies: 0, duplicate_dependencies: [], included_requirements: [] }, + architecture: { nodes: [{ id: 'src/app.ts', label: 'app.ts', path: 'src/app.ts', language: 'TypeScript', inbound: 0, outbound: 0, visible_inbound: 0, visible_outbound: 0, hotspot_score: 10 }], edges: [], clusters: { src: 1 }, truncated: false, total_nodes: 1, total_edges: 0 }, hotspots: [], + release_gate: { status: 'BLOCKED', checks: [{ key: 'security', label: 'Security basics', state: 'pass', detail: 'No secret findings' }, { key: 'dependencies', label: 'Dependency hygiene', state: 'block', detail: 'Lockfile missing' }] }, + coverage: { limits: { max_files: 10000, max_text_bytes_per_file: 2000000, max_findings: 2000, max_secret_findings: 20 }, discovered_files: 5, scanned_files: 5, skipped_files_by_budget: 0, skipped_files_by_size: 0, text_bytes_scanned: 256, truncated: false, notes: [] }, compacted: false, +} +function repository(id, fullName, readiness = 'READY') { + const name = fullName.split('/')[1] + const blocked = readiness === 'BLOCKED' + return { id, provider: 'gitea', provider_repository_id: id.slice(-1), owner: 'Jens', name, full_name: fullName, description: `${name} fixture repository`, html_url: null, clone_url: null, default_branch: 'main', private: true, archived: false, available: true, latest_revision: 'a'.repeat(40), latest_revision_at: now, last_synced_at: now, sync_error: null, state: 'current', stale: false, active_job_id: null, last_scan_id: id === 'gitea:1' ? report.id : null, last_scan_at: id === 'gitea:1' ? now : null, last_scan_health: id === 'gitea:1' ? 88 : 96, last_scan_readiness: readiness, last_scan_revision: 'a'.repeat(40), last_scan_blockers: blocked ? 1 : 0, last_scan_warnings: 0, last_scan_primary_signal: blocked ? { code: finding.code, category: finding.category, severity: finding.severity, message: finding.message, count: 1, guidance } : null, last_scan_health_delta: 0, last_scan_added_findings: 0, last_scan_resolved_findings: 0, last_scan_priority: blocked ? 800 : 0, last_scan_priority_rationale: blocked ? guidance.priority_rationale : null, policy } +} +const repositories = [ + repository('gitea:1', 'Jens/Alpha', 'BLOCKED'), + repository('gitea:2', 'Jens/Beta'), + ...Array.from({ length: 23 }, (_unused, index) => repository(`gitea:${index + 3}`, `Jens/Gamma-${String(index + 1).padStart(2, '0')}`)), +] +let jobPolls = 0 +const job = { id: 'job-alpha', repository_id: 'gitea:1', status: 'queued', trigger: 'manual', requested_revision: 'a'.repeat(40), actual_revision: null, phase: 'queued', progress: 0, message: 'Waiting for an available worker', created_at: now, started_at: null, updated_at: now, finished_at: null, duration_ms: null, scan_id: null, error_code: null, error_detail: null, attempt: 1, deduplicated: false } +function send(response, status, body, type = 'application/json') { response.writeHead(status, { 'Content-Type': type }); response.end(type === 'application/json' ? JSON.stringify(body) : body) } +createServer((request, response) => { + const url = new URL(request.url, 'http://127.0.0.1:8001') + const path = decodeURIComponent(url.pathname) + if (path === '/health') return send(response, 200, { status: 'ok' }) + if (path === '/api/v1/meta') return send(response, 200, { name: 'RepoPulse', version: '1.5.0', stage: 'v1.5.0', scan_root_restricted: true, scan_root: '/scan', max_concurrent_scans: 1, requested_max_concurrent_scans: 1, repository_count: repositories.length, repository_states: { current: 2 }, current_repositories: 2, stale_repositories: 0, queue_depth: 0, active_jobs: 0, completed_jobs: 1, failed_jobs: 0, worker_heartbeat: now, gitea: { configured: true, url: 'http://gitea.local', last_sync: { synced_at: now } }, storage: { database_bytes: 1024, journal_bytes: 0, mirror_count: 2, mirror_cache_bytes: 2048, worktree_count: 0, scan_count: 1, compacted_scan_count: 0, oldest_scan_at: now, newest_scan_at: now, history_per_repository: 200, full_reports_per_repository: 30, report_retention_days: 365 }, database_recovery: { status: 'healthy' }, mutation_authentication: 'perimeter_required' }) + if (path === '/api/v1/repositories' && request.method === 'GET') return send(response, 200, repositories) + if (path === '/api/v1/repositories/sync' && request.method === 'POST') return send(response, 200, { provider: 'gitea', discovered: 2, created: 0, updated: 2, unavailable: 0, errors: [], partial: false, branch_failures: 0, listing_complete: true, synced_at: now }) + if (path === '/api/v1/repositories/gitea:1' && request.method === 'GET') return send(response, 200, { ...repositories[0], latest_scan: report }) + if (path === '/api/v1/repositories/gitea:2' && request.method === 'GET') return send(response, 200, { ...repositories[1], latest_scan: null }) + if (path === '/api/v1/repositories/gitea:1/scans' && request.method === 'GET') return send(response, 200, [{ id: report.id, repository_id: 'gitea:1', scanned_at: now, branch: 'main', requested_revision: report.source.requested_revision, actual_revision: report.source.actual_revision, trigger: 'manual', health_score: 88, release_readiness: 'BLOCKED', findings: 1, duration_ms: 42 }]) + if (path === '/api/v1/repositories/gitea:2/scans' && request.method === 'GET') return send(response, 200, []) + if (path.endsWith('/trend')) return send(response, 200, [{ scan_id: report.id, scanned_at: now, health_score: 88, findings: 1, blockers: 1, warnings: 0, debt_markers: 0 }]) + if (path === '/api/v1/scans/scan-alpha/comparison') return send(response, 200, { current_scan_id: report.id, previous_scan_id: null, health_score_delta: null, category_score_deltas: {}, metric_deltas: {}, added_findings: [], resolved_findings: [] }) + if (path === '/api/v1/repositories/gitea:1/scans' && request.method === 'POST') { jobPolls = 0; return send(response, 202, job) } + if (path === '/api/v1/scan-jobs/job-alpha') { jobPolls += 1; if (jobPolls < 2) return send(response, 200, { ...job, status: 'scanning', phase: 'scanning', progress: 40, message: 'Running deterministic repository analysis' }); return send(response, 200, { ...job, status: 'completed', phase: 'completed', progress: 100, message: 'Scan completed', scan_id: report.id, actual_revision: report.source.actual_revision, finished_at: now, duration_ms: 42 }) } + if (path === '/api/v1/repositories/gitea:1/scans/scan-alpha') return send(response, 200, report) + if (path === '/api/v1/scans/scan-alpha/export') return send(response, 200, JSON.stringify(report), 'application/json') + return send(response, 404, { detail: `Unhandled mock endpoint: ${path}` }) +}).listen(8001, '127.0.0.1') diff --git a/frontend/e2e/repopulse.spec.ts b/frontend/e2e/repopulse.spec.ts new file mode 100644 index 0000000..9d5b759 --- /dev/null +++ b/frontend/e2e/repopulse.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test' + +test('portfolio workflow covers navigation, remediation, a scan lifecycle and export', async ({ page }) => { + await page.goto('/') + await expect(page.getByRole('heading', { name: /portfolio signal/i })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Remediation Center' })).toBeVisible() + await expect(page.getByText('A deterministic package-manager lockfile was not found for this workspace.')).toBeVisible() + await page.locator('.remediation-center').getByRole('button', { name: /Jens\/Alpha/ }).click() + await expect(page.getByRole('heading', { name: 'Jens/Alpha' })).toBeVisible() + await page.getByRole('link', { name: 'Dashboard', exact: true }).click() + + await page.getByRole('button', { name: /open repository explorer/i }).click() + await expect(page.getByText('25 matching 25 repositories')).toBeVisible() + await page.getByRole('button', { name: 'Visual grid' }).click() + await page.getByRole('button', { name: 'Pin Jens/Alpha' }).click() + await expect(page.getByRole('button', { name: 'Remove Jens/Alpha from pinned repositories' })).toBeVisible() + await page.getByRole('button', { name: 'Table' }).click() + await expect(page.getByText('Page 1 / 2')).toBeVisible() + await page.getByRole('button', { name: 'Next' }).click() + await expect(page.getByText('Page 2 / 2')).toBeVisible() + await page.getByLabel('Filter by readiness').selectOption('blocked') + await expect(page.getByText('1 matching 25 repositories')).toBeVisible() + await page.getByRole('link', { name: 'Open Jens/Alpha' }).click() + await expect(page.getByRole('heading', { name: 'Jens/Alpha' })).toBeVisible() + + await page.getByRole('link', { name: /findings/i }).click() + await expect(page.getByText('No lockfile was retained for the declared dependencies.')).toBeVisible() + await page.getByRole('link', { name: /architecture/i }).click() + await expect(page.getByText('Local import and dependency topology')).toBeVisible() + await page.getByRole('link', { name: /history/i }).click() + await expect(page.getByText('Repository health over time')).toBeVisible() + + await page.getByRole('button', { name: 'Scan now' }).click() + await expect(page.getByText('Repository analysis completed.')).toBeVisible({ timeout: 10_000 }) + await page.getByText('Reports').click() + const download = page.waitForEvent('download') + await page.getByRole('button', { name: 'Export JSON' }).click() + await expect(download).resolves.toBeTruthy() + + await page.keyboard.press('Control+k') + await page.getByPlaceholder('Search repositories…').fill('Beta') + await page.keyboard.press('ArrowDown') + await page.keyboard.press('Enter') + await expect(page.getByRole('heading', { name: 'Jens/Beta', exact: true })).toBeVisible() +}) diff --git a/frontend/e2e/start-vite.mjs b/frontend/e2e/start-vite.mjs new file mode 100644 index 0000000..81c4298 --- /dev/null +++ b/frontend/e2e/start-vite.mjs @@ -0,0 +1,11 @@ +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const vite = fileURLToPath(new URL('../node_modules/vite/bin/vite.js', import.meta.url)) +const child = spawn(process.execPath, [vite, '--host', '127.0.0.1', '--port', '4173'], { + cwd: new URL('..', import.meta.url), + env: { ...process.env, REPOPULSE_API_PROXY: 'http://127.0.0.1:8001' }, + stdio: 'inherit', +}) + +child.on('exit', (code) => process.exit(code ?? 0)) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0fc001f --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + RepoPulse — Portfolio intelligence + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..a524271 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,23 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /health { + proxy_pass http://backend:8000/health; + } + + location / { + try_files $uri /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..06d27d7 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3336 @@ +{ + "name": "repopulse-frontend", + "version": "1.5.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "repopulse-frontend", + "version": "1.5.0", + "dependencies": { + "@xyflow/react": "^12.8.5", + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@playwright/test": "^1.55.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.3", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.2", + "jsdom": "^30.0.1", + "typescript": "^5.9.2", + "vite": "^7.1.5", + "vitest": "^3.2.4" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz", + "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.13.tgz", + "integrity": "sha512-i9ZylF5QNhmNfPA9l0vHAWK4kPrbIp6g9lKgaiIFsIBz2F/WNB7OLrzlNNcCOm+h42bkaSD2v1PG+IBPHhc3ZA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@playwright/test": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz", + "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.55.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "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 || ^8.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xyflow/react": { + "version": "12.11.6", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.6.tgz", + "integrity": "sha512-9XsEJNHjatKYndszKTF/bsU7FOP9dJ6V/EQwzy3oMdtqgBuUq7BjKSwkEo+C7s4qHstHQfwwoHA3E8QfpPxZZQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.82", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.82", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.82.tgz", + "integrity": "sha512-4DKnL3CGtCGLRSmgDqaajRVgeksMXq/Yw4wPfdMfm7JvdIiWHGzVYOfFUztiATwdBXZqUU6HhehwUdbV9G23PQ==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.22", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz", + "integrity": "sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.427", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", + "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz", + "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.55.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz", + "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz", + "integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.12" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz", + "integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "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/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz", + "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..3ced5d5 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "repopulse-frontend", + "private": true, + "version": "1.5.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:e2e": "playwright test" + }, + "dependencies": { + "@xyflow/react": "^12.8.5", + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@playwright/test": "^1.55.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.3", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.2", + "jsdom": "^30.0.1", + "typescript": "^5.9.2", + "vite": "^7.1.5", + "vitest": "^3.2.4" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..7598543 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + fullyParallel: false, + workers: 6, + reporter: 'list', + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [ + { name: '1920x1080', use: { ...devices['Desktop Chrome'], viewport: { width: 1920, height: 1080 } } }, + { name: '1680x900', use: { ...devices['Desktop Chrome'], viewport: { width: 1680, height: 900 } } }, + { name: '1440x900', use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } } }, + { name: '1366x768', use: { ...devices['Desktop Chrome'], viewport: { width: 1366, height: 768 } } }, + { name: '1024x768', use: { ...devices['Desktop Chrome'], viewport: { width: 1024, height: 768 } } }, + { name: '390x844', use: { ...devices['Desktop Chrome'], viewport: { width: 390, height: 844 } } }, + ], + webServer: [ + { + command: 'node e2e/mock-api.mjs', + url: 'http://127.0.0.1:8001/health', + reuseExistingServer: false, + timeout: 15_000, + }, + { + command: 'node e2e/start-vite.mjs', + url: 'http://127.0.0.1:4173', + reuseExistingServer: false, + timeout: 30_000, + }, + ], +}) diff --git a/frontend/public/brand/repopulse-logo-light.svg b/frontend/public/brand/repopulse-logo-light.svg new file mode 100644 index 0000000..b3bee10 --- /dev/null +++ b/frontend/public/brand/repopulse-logo-light.svg @@ -0,0 +1,7 @@ + + + + + RepoPulse + REPOSITORY INTELLIGENCE + diff --git a/frontend/public/brand/repopulse-logo.svg b/frontend/public/brand/repopulse-logo.svg new file mode 100644 index 0000000..cf9be93 --- /dev/null +++ b/frontend/public/brand/repopulse-logo.svg @@ -0,0 +1,7 @@ + + + + + RepoPulse + REPOSITORY INTELLIGENCE + diff --git a/frontend/public/brand/repopulse-mark-mono.svg b/frontend/public/brand/repopulse-mark-mono.svg new file mode 100644 index 0000000..a64a026 --- /dev/null +++ b/frontend/public/brand/repopulse-mark-mono.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/brand/repopulse-mark.svg b/frontend/public/brand/repopulse-mark.svg new file mode 100644 index 0000000..93f9f33 --- /dev/null +++ b/frontend/public/brand/repopulse-mark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..93f9f33 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/site.webmanifest b/frontend/public/site.webmanifest new file mode 100644 index 0000000..7b15023 --- /dev/null +++ b/frontend/public/site.webmanifest @@ -0,0 +1,9 @@ +{ + "name": "RepoPulse — Repository Intelligence", + "short_name": "RepoPulse", + "description": "Portfolio-level repository intelligence from deterministic scans.", + "display": "standalone", + "background_color": "#07111c", + "theme_color": "#0d2130", + "icons": [{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }] +} diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx new file mode 100644 index 0000000..9e8b14d --- /dev/null +++ b/frontend/src/App.test.tsx @@ -0,0 +1,128 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import App from './App' +import { api, type ScanJob } from './api' +import { testComparison, testDetail, testMeta, testRepository, testScans, testTrend } from './test-fixtures' + +const repository = testRepository() +const secondRepository = testRepository('gitea:65', 'Jens/AgentTestLab', { last_scan_health: 93, last_scan_readiness: 'READY' }) +const queuedJob: ScanJob = { + id: 'job-1', + repository_id: repository.id, + status: 'queued', + trigger: 'manual', + requested_revision: 'revision-1', + actual_revision: null, + phase: 'queued', + progress: 0, + message: 'Analysis queued', + created_at: '2026-09-12T20:00:00Z', + started_at: null, + updated_at: '2026-09-12T20:00:00Z', + finished_at: null, + duration_ms: null, + scan_id: null, + error_code: null, + error_detail: null, + attempt: 0, + deduplicated: false, +} + +let repositoriesMock: ReturnType + +beforeEach(() => { + window.history.replaceState({}, '', '/repositories') + window.localStorage.clear() + Object.defineProperty(window, 'scrollTo', { configurable: true, value: vi.fn() }) + Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: (callback: FrameRequestCallback) => { callback(0); return 0 } }) + + repositoriesMock = vi.spyOn(api, 'repositories').mockResolvedValue([repository, secondRepository]) + vi.spyOn(api, 'meta').mockResolvedValue(testMeta) + vi.spyOn(api, 'repository').mockImplementation(async (id) => id === secondRepository.id ? testDetail(secondRepository) : testDetail(repository)) + vi.spyOn(api, 'repositoryScans').mockResolvedValue(testScans) + vi.spyOn(api, 'repositoryTrend').mockResolvedValue(testTrend) + vi.spyOn(api, 'comparison').mockResolvedValue(testComparison) + vi.spyOn(api, 'scanJob').mockResolvedValue(queuedJob) + vi.spyOn(api, 'enqueueRepositoryScan').mockResolvedValue(queuedJob) + vi.spyOn(api, 'exportReport').mockResolvedValue('report') +}) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('RepoPulse workspace shell', () => { + it('searches, filters and opens a repository detail route', async () => { + render() + await screen.findByRole('heading', { name: /Repositories/ }) + + fireEvent.change(screen.getByPlaceholderText('Search name, owner or full name'), { target: { value: 'RepoPulse' } }) + expect(screen.getByRole('region', { name: 'Repository filters' }).textContent).toContain('1 matching 2 repositories') + fireEvent.change(screen.getByRole('combobox', { name: 'Filter by readiness' }), { target: { value: 'blocked' } }) + expect(screen.getByRole('link', { name: 'Open Jens/RepoPulse' })).toBeTruthy() + + fireEvent.click(screen.getByRole('link', { name: 'Open Jens/RepoPulse' })) + + expect(await screen.findByRole('heading', { name: 'Jens/RepoPulse' })).toBeTruthy() + expect(window.location.pathname).toBe('/repositories/gitea%3A64/overview') + expect(screen.getByRole('button', { name: 'Scan now' })).toBeTruthy() + }) + + it('persists pinned repositories and opens a recent quick-switch selection', async () => { + render() + await screen.findByRole('heading', { name: /Repositories/ }) + + fireEvent.click(screen.getByRole('button', { name: 'Pin Jens/RepoPulse' })) + await waitFor(() => expect(window.localStorage.getItem('repopulse:pinned-repositories')).toContain('gitea:64')) + + fireEvent.click(document.querySelector('.shortcut-button') as HTMLElement) + const switcher = await screen.findByPlaceholderText('Search repositories…') + fireEvent.change(switcher, { target: { value: 'AgentTestLab' } }) + fireEvent.keyDown(window, { key: 'Enter' }) + + expect(await screen.findByRole('heading', { name: 'Jens/AgentTestLab' })).toBeTruthy() + expect(window.location.pathname).toBe('/repositories/gitea%3A65/overview') + expect(window.localStorage.getItem('repopulse:recent-repositories')).toContain('gitea:65') + }) + + it('exposes compact scan and report actions on detail', async () => { + window.history.replaceState({}, '', '/repositories/gitea%3A64/overview') + render() + await screen.findByRole('button', { name: 'Scan now' }) + + fireEvent.click(screen.getByText('Reports', { exact: true })) + expect(screen.getByRole('button', { name: 'Export HTML' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Export JSON' })).toBeTruthy() + + fireEvent.click(screen.getByText('More', { exact: true })) + expect(screen.getByRole('button', { name: 'Force rescan' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'View scan runs' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Advanced info' })).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Scan now' })) + await waitFor(() => expect(api.enqueueRepositoryScan).toHaveBeenCalledWith('gitea:64', false)) + }) + + it('shows an actionable empty detail state for an unscanned repository', async () => { + const emptyRepository = testRepository('gitea:66', 'Jens/EmptyRepo', { + state: 'never_scanned', + last_scan_id: null, + last_scan_at: null, + last_scan_health: null, + last_scan_readiness: null, + last_scan_revision: null, + }) + repositoriesMock.mockResolvedValue([emptyRepository]) + vi.mocked(api.repository).mockResolvedValue({ ...emptyRepository, latest_scan: null }) + window.history.replaceState({}, '', '/repositories/gitea%3A66/overview') + + render() + + expect(await screen.findByText('Jens/EmptyRepo has no persisted scan yet.')).toBeTruthy() + expect(screen.getByRole('button', { name: 'Scan repository' })).toBeTruthy() + }) +}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..b89cb2d --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,179 @@ +import { type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { api, type AppMeta, type RepositorySummary, type ScanComparison, type ScanJob, type ScanReport, type ScanSummary, type TrendPoint } from './api' +import { filterRepositories, orderSwitcherRepositories, paginateRepositories, readinessLabel, repositoryReadiness, sortRepositories, stateLabel, type CatalogFilters, type CatalogSort, type CatalogSortKey, type Readiness } from './catalog' +import { ArchitectureView } from './components/ArchitectureView' +import { BrandLockup, BrandMark } from './components/Brand' +import { DashboardView } from './components/DashboardView' +import { DependenciesView } from './components/DependenciesView' +import { FindingsView } from './components/FindingsView' +import { HistoryView } from './components/HistoryView' +import { HotspotsView } from './components/HotspotsView' +import { OverviewView } from './components/OverviewView' +import { RepositoryVisual } from './components/RepositoryVisual' +import { ScanRunsView } from './components/ScanRunsView' +import { DETAIL_VIEWS, parseRoute, routePath, type DetailView, type Route } from './navigation' + +const ACTIVE_JOB_STATES: ScanJob['status'][] = ['queued', 'acquiring_source', 'scanning', 'analyzing', 'persisting'] +const PINNED_STORAGE_KEY = 'repopulse:pinned-repositories' +const RECENT_STORAGE_KEY = 'repopulse:recent-repositories' +const CATALOG_VIEW_STORAGE_KEY = 'repopulse:catalog-view' +const DEFAULT_FILTERS: CatalogFilters = { query: '', state: 'all', readiness: 'all', owner: 'all', branch: 'all', visibility: 'all', archived: 'active' } + +function readIdList(key: string) { + try { + const parsed = JSON.parse(window.localStorage.getItem(key) ?? '[]') + return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === 'string') : [] + } catch { return [] } +} + +function readCatalogView(): 'table' | 'grid' { + try { return window.localStorage.getItem(CATALOG_VIEW_STORAGE_KEY) === 'grid' ? 'grid' : 'table' } catch { return 'table' } +} + +function writeIdList(key: string, ids: string[]) { try { window.localStorage.setItem(key, JSON.stringify(ids)) } catch { /* preferences are best effort */ } } +function shortRevision(revision: string | null) { return revision ? revision.slice(0, 10) : 'not acquired' } +function statusClass(value: string) { return value.replace(/_/g, '-') } +function formatDate(value: string | null) { if (!value) return 'Never'; const date = new Date(value); return Number.isNaN(date.getTime()) ? 'Unknown' : date.toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) } +function formatRelativeDate(value: string | null) { if (!value) return 'Never scanned'; const difference = Date.now() - new Date(value).getTime(); if (Number.isNaN(difference)) return 'Unknown'; if (difference < 3_600_000) return `${Math.max(1, Math.floor(difference / 60_000))}m ago`; if (difference < 86_400_000) return `${Math.floor(difference / 3_600_000)}h ago`; return `${Math.floor(difference / 86_400_000)}d ago` } +function downloadText(filename: string, content: string, mime: string) { const url = URL.createObjectURL(new Blob([content], { type: mime })); const anchor = document.createElement('a'); anchor.href = url; anchor.download = filename; anchor.style.display = 'none'; document.body.appendChild(anchor); anchor.click(); window.setTimeout(() => { anchor.remove(); URL.revokeObjectURL(url) }, 0) } +function reportFilename(repositoryName: string, format: 'html' | 'json') { const safeName = repositoryName.replace(/[\u0000-\u001f\u007f<>:"/\\|?*]/g, '_').replace(/[ .]+$/g, '').slice(0, 100).replace(/[ .]+$/g, '') || 'repository'; return `repopulse-${safeName}.${format}` } +function detailViewLabel(view: DetailView, report: ScanReport | null) { if (view === 'overview') return 'Overview'; if (view === 'findings') return `Findings (${report?.findings.filter((finding) => !finding.suppressed).length ?? 0})`; if (view === 'architecture') return `Architecture (${report?.architecture.nodes.length ?? 0})`; if (view === 'hotspots') return `Hotspots (${report?.hotspots.length ?? 0})`; if (view === 'dependencies') return 'Dependencies'; if (view === 'history') return 'History'; return 'Scan runs' } + +export default function App() { + const [route, setRoute] = useState(() => parseRoute(window.location.pathname)) + const [repositories, setRepositories] = useState([]) + const [catalogLoading, setCatalogLoading] = useState(true) + const [selectedDetailsLoading, setSelectedDetailsLoading] = useState(false) + const [scanSummaries, setScanSummaries] = useState([]) + const [report, setReport] = useState(null) + const [comparison, setComparison] = useState(null) + const [trend, setTrend] = useState([]) + const [path, setPath] = useState('') + const [job, setJob] = useState(null) + const [meta, setMeta] = useState(null) + const [standaloneReport, setStandaloneReport] = useState(null) + const [pinnedIds, setPinnedIds] = useState(() => readIdList(PINNED_STORAGE_KEY)) + const [recentIds, setRecentIds] = useState(() => readIdList(RECENT_STORAGE_KEY)) + const [catalogFilters, setCatalogFilters] = useState(DEFAULT_FILTERS) + const [catalogSort, setCatalogSort] = useState({ key: 'name', direction: 'asc' }) + const [catalogPage, setCatalogPage] = useState(1) + const [catalogPageSize, setCatalogPageSize] = useState(24) + const [catalogView, setCatalogView] = useState<'table' | 'grid'>(readCatalogView) + const [busy, setBusy] = useState(false) + const [catalogBusy, setCatalogBusy] = useState(false) + const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + const [switcherOpen, setSwitcherOpen] = useState(false) + const [switcherQuery, setSwitcherQuery] = useState('') + const [switcherIndex, setSwitcherIndex] = useState(0) + const [advancedOpen, setAdvancedOpen] = useState(false) + const switcherInputRef = useRef(null) + + const selectedId = route.repositoryId ?? null + const selectedRepository = useMemo(() => repositories.find((repository) => repository.id === selectedId) ?? null, [repositories, selectedId]) + const activeJob = job && job.repository_id === selectedId && ACTIVE_JOB_STATES.includes(job.status) ? job : null + const navigate = useCallback((next: Route, replace = false) => { const href = routePath(next); if (window.location.pathname !== href) window.history[replace ? 'replaceState' : 'pushState']({}, '', href); setRoute(next); setAdvancedOpen(false); window.scrollTo?.({ top: 0, behavior: 'auto' }) }, []) + const handleNavigation = useCallback((event: React.MouseEvent, next: Route) => { if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; event.preventDefault(); navigate(next) }, [navigate]) + const refreshCatalog = useCallback(async () => { const next = await api.repositories(); setRepositories(next); return next }, []) + const refreshMeta = useCallback(async () => { try { setMeta(await api.meta()) } catch { /* catalog navigation must remain usable */ } }, []) + + useEffect(() => { + const onPopState = () => setRoute(parseRoute(window.location.pathname)) + window.addEventListener('popstate', onPopState) + void (async () => { setCatalogLoading(true); try { await refreshCatalog() } catch { try { const legacy = await api.recent(); setStandaloneReport(legacy[0] ?? null); setPath(legacy[0]?.repository_path ?? ''); setError(legacy.length ? 'Repository catalog is unavailable; showing the last local report in Settings.' : 'Could not load the repository catalog.') } catch (err) { setError(err instanceof Error ? err.message : 'Could not load the repository catalog.') } } finally { setCatalogLoading(false) } })() + void refreshMeta() + return () => window.removeEventListener('popstate', onPopState) + }, [refreshCatalog, refreshMeta]) + + useEffect(() => { document.title = route.page === 'dashboard' ? 'RepoPulse — Portfolio intelligence' : 'RepoPulse — Repository intelligence' }, [route.page]) + useEffect(() => { + if (!selectedId) { setReport(null); setScanSummaries([]); setTrend([]); setComparison(null); setSelectedDetailsLoading(false); return } + let live = true + setSelectedDetailsLoading(true); setReport(null); setComparison(null); setTrend([]) + void Promise.all([api.repository(selectedId), api.repositoryScans(selectedId), api.repositoryTrend(selectedId)]).then(([detail, summaries, nextTrend]) => { if (!live) return; setScanSummaries(summaries); setTrend(nextTrend); setReport(detail.latest_scan); setError(null); if (detail.latest_scan?.repository_path) setPath(detail.latest_scan.repository_path) }).catch((err) => { if (live) setError(err instanceof Error ? err.message : 'Could not load repository details.') }).finally(() => { if (live) setSelectedDetailsLoading(false) }) + return () => { live = false } + }, [selectedId]) + useEffect(() => { if (!report) { setComparison(null); return }; let live = true; void api.comparison(report.id).then((next) => { if (live) setComparison(next) }).catch(() => { if (live) setComparison(null) }); return () => { live = false } }, [report]) + useEffect(() => { if (!selectedRepository?.active_job_id || job?.id === selectedRepository.active_job_id) return; let live = true; void api.scanJob(selectedRepository.active_job_id).then((next) => { if (live) setJob(next) }).catch(() => undefined); return () => { live = false } }, [job?.id, selectedRepository]) + useEffect(() => { + const jobId = activeJob?.id + if (!jobId || !selectedId) return + let live = true + let failures = 0 + let timer: number | undefined + const poll = async () => { + try { + const next = await api.scanJob(jobId) + if (!live) return + failures = 0 + if (next.status === 'completed' && next.scan_id) { + const nextReport = await api.repositoryScan(next.repository_id, next.scan_id) + if (!live) return + const [summaries, nextTrend] = await Promise.all([api.repositoryScans(next.repository_id), api.repositoryTrend(next.repository_id)]) + if (!live) return + await refreshCatalog() + if (!live) return + setJob(next) + setReport(nextReport) + setScanSummaries(summaries) + setTrend(nextTrend) + setNotice(next.deduplicated ? 'The selected revision was already analyzed; showing the existing report.' : 'Repository analysis completed.') + return + } + setJob(next) + if (next.status === 'failed') { setError(next.error_detail || 'Repository analysis failed.'); return } + } catch (err) { + failures += 1 + if (!live) return + if (failures >= 5) { setError(err instanceof Error ? `${err.message} Progress polling paused after five failed attempts.` : 'Progress polling paused after five failed attempts.'); return } + } + if (live) timer = window.setTimeout(() => void poll(), 1500) + } + void poll() + return () => { live = false; if (timer) window.clearTimeout(timer) } + }, [activeJob?.id, refreshCatalog, selectedId]) + useEffect(() => { if (selectedId) setRecentIds((current) => [selectedId, ...current.filter((id) => id !== selectedId)].slice(0, 8)) }, [selectedId]) + useEffect(() => writeIdList(PINNED_STORAGE_KEY, pinnedIds), [pinnedIds]) + useEffect(() => writeIdList(RECENT_STORAGE_KEY, recentIds), [recentIds]) + useEffect(() => { try { window.localStorage.setItem(CATALOG_VIEW_STORAGE_KEY, catalogView) } catch { /* preference only */ } }, [catalogView]) + + const filteredRepositories = useMemo(() => filterRepositories(repositories, catalogFilters), [catalogFilters, repositories]) + const sortedRepositories = useMemo(() => sortRepositories(filteredRepositories, catalogSort), [catalogSort, filteredRepositories]) + const catalogWindow = useMemo(() => paginateRepositories(sortedRepositories, catalogPage, catalogPageSize), [catalogPage, catalogPageSize, sortedRepositories]) + const ownerOptions = useMemo(() => [...new Set(repositories.map((repository) => repository.owner))].sort(), [repositories]) + const branchOptions = useMemo(() => [...new Set(repositories.map((repository) => repository.default_branch))].sort(), [repositories]) + const switcherRepositories = useMemo(() => orderSwitcherRepositories(repositories, pinnedIds, recentIds, switcherQuery), [pinnedIds, recentIds, repositories, switcherQuery]) + const currentView = route.view ?? 'overview' + useEffect(() => { if (catalogPage !== catalogWindow.page) setCatalogPage(catalogWindow.page) }, [catalogPage, catalogWindow.page]) + useEffect(() => { if (switcherOpen) { setSwitcherIndex(0); window.requestAnimationFrame(() => switcherInputRef.current?.focus()) } }, [switcherOpen]) + useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); setSwitcherOpen(true); return }; if (!switcherOpen) return; if (event.key === 'Escape') { event.preventDefault(); setSwitcherOpen(false) } else if (event.key === 'ArrowDown') { event.preventDefault(); setSwitcherIndex((index) => Math.min(index + 1, Math.max(0, switcherRepositories.length - 1))) } else if (event.key === 'ArrowUp') { event.preventDefault(); setSwitcherIndex((index) => Math.max(0, index - 1)) } else if (event.key === 'Enter' && switcherRepositories[switcherIndex]) { event.preventDefault(); selectRepository(switcherRepositories[switcherIndex]); setSwitcherOpen(false) } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown) }, [switcherIndex, switcherOpen, switcherRepositories]) + + const selectRepository = (repository: RepositorySummary) => { setError(null); setNotice(null); navigate({ page: 'repositories', repositoryId: repository.id, view: 'overview' }) } + const togglePinned = (repositoryId: string) => setPinnedIds((current) => current.includes(repositoryId) ? current.filter((id) => id !== repositoryId) : [repositoryId, ...current]) + const exploreReadiness = (readiness: Readiness) => { setCatalogFilters({ ...DEFAULT_FILTERS, readiness }); setCatalogPage(1); navigate({ page: 'repositories' }) } + const updateCatalogFilter = (key: K, value: CatalogFilters[K]) => { setCatalogFilters((current) => ({ ...current, [key]: value })); setCatalogPage(1) } + const setCatalogSortKey = (key: CatalogSortKey) => { setCatalogSort((current) => current.key === key ? { key, direction: current.direction === 'asc' ? 'desc' : 'asc' } : { key, direction: key === 'name' || key === 'state' ? 'asc' : 'desc' }); setCatalogPage(1) } + const analyzeRepository = async (repository: RepositorySummary, force = false) => { try { setBusy(true); setError(null); setNotice(null); const next = await api.enqueueRepositoryScan(repository.id, force); setJob(next); setNotice(next.deduplicated ? 'This revision is already in the analysis history.' : 'Analysis queued. Progress will update here.'); if (next.status === 'completed' && next.scan_id) setReport(await api.repositoryScan(repository.id, next.scan_id)) } catch (err) { setError(err instanceof Error ? err.message : 'Could not queue repository analysis.') } finally { setBusy(false) } } + const syncCatalog = async () => { try { setCatalogBusy(true); setError(null); const result = await api.syncRepositories(); await Promise.all([refreshCatalog(), refreshMeta()]); setNotice(result.partial ? `Gitea catalog partially synchronized: ${result.discovered} resolved, ${result.branch_failures ?? 0} branch lookup failure(s). Existing repository evidence was preserved.` : `Gitea catalog synchronized: ${result.discovered} repositories discovered.`) } catch (err) { setError(err instanceof Error ? err.message : 'Gitea catalog synchronization failed.') } finally { setCatalogBusy(false) } } + const scanLocal = async (event: FormEvent) => { event.preventDefault(); try { setBusy(true); setError(null); setNotice(null); const next = await api.scan(path); setStandaloneReport(next); setPath(next.repository_path); await refreshCatalog(); setNotice(`Local analysis completed in ${next.duration_ms} ms. The result is available in Settings.`); navigate({ page: 'settings' }) } catch (err) { setError(err instanceof Error ? err.message : 'Local repository analysis failed.') } finally { setBusy(false) } } + const selectScan = async (summary: ScanSummary) => { try { setBusy(true); setError(null); setReport(await api.repositoryScan(summary.repository_id, summary.id)); navigate({ page: 'repositories', repositoryId: summary.repository_id, view: 'overview' }) } catch (err) { setError(err instanceof Error ? err.message : 'Could not load the selected scan.') } finally { setBusy(false) } } + const exportReport = async (format: 'html' | 'json') => { if (!report) return; try { downloadText(reportFilename(report.repository_name, format), await api.exportReport(report.id, format), format === 'html' ? 'text/html' : 'application/json') } catch (err) { setError(err instanceof Error ? err.message : 'Report export failed.') } } + + const renderGlobalNav = () => + const renderTopbar = () =>
handleNavigation(event, { page: 'dashboard' })} aria-label="RepoPulse dashboard">{renderGlobalNav()}
+ + const renderCatalogPage = () =>
Repository explorer

Repositories {repositories.length}

High-density catalog controls when you need breadth; switch to visual identity tiles when you need pattern recognition.

updateCatalogFilter('query', event.target.value)} placeholder="Search name, owner or full name" />⌘K
{filteredRepositories.length} matching {repositories.length} repositories
{catalogLoading ?
: catalogWindow.items.length ? <>{catalogView === 'table' ?
{catalogWindow.items.map((repository) => { const readiness = repositoryReadiness(repository); const pinned = pinnedIds.includes(repository.id); return selectRepository(repository)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); selectRepository(repository) } }}> })}
ReadinessBranch
{repository.full_name}{repository.private ? 'Private' : 'Public'} · {repository.provider} · {repository.archived ? 'Archived' : 'Active'}
{repository.default_branch}{formatRelativeDate(repository.last_scan_at)}{formatDate(repository.last_scan_at)}{repository.last_scan_health ?? '—'}
:
{catalogWindow.items.map((repository) => { const pinned = pinnedIds.includes(repository.id); const readiness = repositoryReadiness(repository); return