Initial curated RepoPulse source from 2524ccc3c1c27739996dccb2b7992989f88cd26d

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:34:52 +02:00
commit 0adf8ef5d9
90 files changed
+14315

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
.git
.gitea
.env
.venv
__pycache__
.pytest_cache
node_modules
frontend/node_modules
frontend/dist
data
*.db
*.sqlite*
+40
View File
@@ -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=
+28
View File
@@ -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
+9
View File
@@ -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']
+29
View File
@@ -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"]
+3
View File
@@ -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.
+446
View File
@@ -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
}
]
}
+90
View File
@@ -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=<repository-read-token>
REPOPULSE_OPERATOR_TOKEN=<independent-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.
+19
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
"""RepoPulse backend package."""
+264
View File
@@ -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
+340
View File
@@ -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<target>[^'\"]+)['\"]"
)
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],
)
+104
View File
@@ -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]
+314
View File
@@ -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
+330
View File
@@ -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")
+491
View File
@@ -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")
+397
View File
@@ -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()
+98
View File
@@ -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,
}
)
+147
View File
@@ -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)
+75
View File
@@ -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 = """<svg class=\"brand-mark\" viewBox=\"0 0 32 32\" aria-hidden=\"true\" xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"32\" height=\"32\" rx=\"8\" fill=\"#123245\"/><path d=\"M7 9.5h5.1l2.2 2.2H25M7 22.5h5.1l2.2-2.2H25M12.8 16h2.1l1.55-3.1 2.15 6.2 1.35-3.1H25\" fill=\"none\" stroke=\"#91ebe0\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/><circle cx=\"7\" cy=\"9.5\" r=\"2\" fill=\"#91ebe0\"/><circle cx=\"7\" cy=\"22.5\" r=\"2\" fill=\"#91ebe0\"/><circle cx=\"25\" cy=\"16\" r=\"2\" fill=\"#91ebe0\"/></svg>"""
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"<tr><td>{escape(name)}</td><td>{score}/100</td></tr>"
for name, score in report.category_scores.items()
)
finding_rows = "".join(
"<tr>"
f"<td>{escape(item.severity.value)}</td>"
f"<td>{escape(item.category)}</td>"
f"<td>{escape(item.message)}</td>"
f"<td>{escape(item.path or '')}{':' + str(item.line) if item.line else ''}</td>"
"</tr>"
for item in report.findings
)
hotspot_rows = "".join(
"<tr>"
f"<td>{escape(item.path)}</td><td>{escape(item.language)}</td>"
f"<td>{item.score}</td><td>{item.lines_of_code}</td><td>{item.churn}</td>"
"</tr>"
for item in report.hotspots[:25]
)
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>RepoPulse report — {escape(report.repository_name)}</title>
<style>
*{{box-sizing:border-box}}body{{font-family:Inter,ui-sans-serif,system-ui,sans-serif;margin:42px auto;max-width:1120px;color:#172638;line-height:1.45}}
.brand{{display:flex;align-items:center;gap:10px;margin-bottom:34px}}.brand-mark{{width:34px;height:34px;flex:0 0 auto}}.brand strong{{display:block;font-size:15px;letter-spacing:-.03em}}.brand small{{display:block;color:#66808d;font-size:8px;font-weight:700;letter-spacing:.13em;text-transform:uppercase}}
.hero{{display:flex;align-items:end;justify-content:space-between;gap:20px;padding:23px 25px;border:1px solid #dce8e8;border-radius:18px;background:linear-gradient(120deg,#effafa,#f9fcfc)}}h1{{margin:0 0 4px;font-size:30px;letter-spacing:-.04em}}h2{{margin-top:36px;font-size:18px;letter-spacing:-.02em}}.muted{{color:#607588}}.score{{color:#176c70;font-size:54px;font-weight:750;letter-spacing:-.08em}}.readiness{{color:#176c70;font-size:11px;font-weight:750;letter-spacing:.08em;text-transform:uppercase}}
table{{width:100%;border-collapse:collapse;margin:14px 0 34px}}th,td{{text-align:left;padding:10px 9px;border-bottom:1px solid #e4ebef;vertical-align:top}}th{{color:#607588;font-size:10px;letter-spacing:.06em;text-transform:uppercase}}.grid{{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;overflow:hidden;border:1px solid #dce8e8;border-radius:13px;background:#dce8e8}}.card{{padding:15px;background:#fff}}.card strong{{color:#176c70;font-size:24px}}
@media print{{body{{margin:18mm;max-width:none}}.hero{{background:#fff}}}}@media(max-width:760px){{.grid{{grid-template-columns:1fr 1fr}}body{{margin:22px}}.hero{{align-items:start;flex-direction:column}}}}
</style>
</head>
<body>
<div class="brand">{REPORT_MARK}<span><strong>RepoPulse</strong><small>Repository intelligence</small></span></div>
<section class="hero"><div><p class="muted">Deterministic repository intelligence report</p><h1>{escape(report.repository_name)}</h1><p class="muted">{escape(report.repository_path)} · {escape(report.scanned_at.isoformat())}</p></div><div><div class="score">{report.health_score}</div><div class="readiness">{escape(report.release_readiness)}</div></div></section>
<div class="grid">
<div class="card"><strong>{report.metrics.source_files}</strong><br>source files</div>
<div class="card"><strong>{report.metrics.lines_of_code}</strong><br>LOC</div>
<div class="card"><strong>{report.metrics.test_files}</strong><br>test files</div>
<div class="card"><strong>{len(report.findings)}</strong><br>findings</div>
</div>
<h2>Category scores</h2><table><tbody>{category_rows}</tbody></table>
<h2>Hotspots</h2><table><thead><tr><th>Path</th><th>Language</th><th>Score</th><th>LOC</th><th>Churn</th></tr></thead><tbody>{hotspot_rows}</tbody></table>
<h2>Findings</h2><table><thead><tr><th>Severity</th><th>Category</th><th>Finding</th><th>Location</th></tr></thead><tbody>{finding_rows}</tbody></table>
</body>
</html>"""
+83
View File
@@ -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]
+695
View File
@@ -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*
)
[\"']?(?P<name>api[_-]?key|secret|password|passwd|token|access[_-]?key)[\"']?
\s*[:=]\s*[\"']?(?P<value>[^\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,
)
+494
View File
@@ -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,
)
+1527
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -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
+122
View File
@@ -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
+130
View File
@@ -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
+98
View File
@@ -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
+203
View File
@@ -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"
+232
View File
@@ -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)
+175
View File
@@ -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)
+86
View File
@@ -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
+12
View File
@@ -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
+223
View File
@@ -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("<project>", 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
+113
View File
@@ -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)
+59
View File
@@ -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"
+161
View File
@@ -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
+80
View File
@@ -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
+57
View File
@@ -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
+100
View File
@@ -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:<numeric-provider-id>` 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:<path-hash>` 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 767 KiB

+12
View File
@@ -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
+55
View File
@@ -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')
+45
View File
@@ -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()
})
+11
View File
@@ -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))
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="RepoPulse is a premium repository intelligence cockpit for portfolio health, scan operations and architecture signals." />
<meta name="theme-color" content="#0d2130" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="manifest" href="/site.webmanifest" />
<title>RepoPulse — Portfolio intelligence</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+23
View File
@@ -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;
}
}
+3336
View File
File diff suppressed because it is too large. Load diff
+30
View File
@@ -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"
}
}
+36
View File
@@ -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,
},
],
})
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 228 48" role="img" aria-label="RepoPulse Repository Intelligence">
<rect width="48" height="48" rx="12" fill="#e6f4f4"/>
<path d="M10 14h8l3.1 3H38M10 34h8l3.1-3H38M18.4 24h3.2l2-4.5 3.3 9 2-4.5H38" fill="none" stroke="#166d70" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="10" cy="14" r="2.7" fill="#166d70"/><circle cx="10" cy="34" r="2.7" fill="#166d70"/><circle cx="38" cy="24" r="2.7" fill="#166d70"/>
<text x="62" y="22" fill="#102331" font-family="Arial,Helvetica,sans-serif" font-size="20" font-weight="700" letter-spacing="-.5">RepoPulse</text>
<text x="62" y="36" fill="#567080" font-family="Arial,Helvetica,sans-serif" font-size="8" font-weight="700" letter-spacing="1.4">REPOSITORY INTELLIGENCE</text>
</svg>

After

Width:  |  Height:  |  Size: 818 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 228 48" role="img" aria-label="RepoPulse Repository Intelligence">
<rect width="48" height="48" rx="12" fill="#0d2130"/>
<path d="M10 14h8l3.1 3H38M10 34h8l3.1-3H38M18.4 24h3.2l2-4.5 3.3 9 2-4.5H38" fill="none" stroke="#91ebe0" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="10" cy="14" r="2.7" fill="#91ebe0"/><circle cx="10" cy="34" r="2.7" fill="#91ebe0"/><circle cx="38" cy="24" r="2.7" fill="#91ebe0"/>
<text x="62" y="22" fill="#eff7ff" font-family="Arial,Helvetica,sans-serif" font-size="20" font-weight="700" letter-spacing="-.5">RepoPulse</text>
<text x="62" y="36" fill="#91a5bb" font-family="Arial,Helvetica,sans-serif" font-size="8" font-weight="700" letter-spacing="1.4">REPOSITORY INTELLIGENCE</text>
</svg>

After

Width:  |  Height:  |  Size: 818 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path d="M5 8.5h7.1l2.2 2.2H27M5 23.5h7.1l2.2-2.2H27M12.4 16h2.5l1.55-3.4 2.15 6.8 1.4-3.4H27" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="5" cy="8.5" r="2" fill="currentColor"/><circle cx="5" cy="23.5" r="2" fill="currentColor"/><circle cx="27" cy="16" r="2" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 422 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#0d2130"/>
<path d="M7 9.5h5.1l2.2 2.2H25M7 22.5h5.1l2.2-2.2H25M12.8 16h2.1l1.55-3.1 2.15 6.2 1.35-3.1H25" fill="none" stroke="#91ebe0" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="7" cy="9.5" r="2" fill="#91ebe0"/><circle cx="7" cy="22.5" r="2" fill="#91ebe0"/><circle cx="25" cy="16" r="2" fill="#91ebe0"/>
</svg>

After

Width:  |  Height:  |  Size: 460 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#0d2130"/>
<path d="M7 9.5h5.1l2.2 2.2H25M7 22.5h5.1l2.2-2.2H25M12.8 16h2.1l1.55-3.1 2.15 6.2 1.35-3.1H25" fill="none" stroke="#91ebe0" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="7" cy="9.5" r="2" fill="#91ebe0"/><circle cx="7" cy="22.5" r="2" fill="#91ebe0"/><circle cx="25" cy="16" r="2" fill="#91ebe0"/>
</svg>

After

Width:  |  Height:  |  Size: 460 B

+9
View File
@@ -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" }]
}
+128
View File
@@ -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<typeof vi.spyOn>
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('<html>report</html>')
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
describe('RepoPulse workspace shell', () => {
it('searches, filters and opens a repository detail route', async () => {
render(<App />)
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(<App />)
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(<App />)
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(<App />)
expect(await screen.findByText('Jens/EmptyRepo has no persisted scan yet.')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Scan repository' })).toBeTruthy()
})
})
+179
View File
@@ -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<Route>(() => parseRoute(window.location.pathname))
const [repositories, setRepositories] = useState<RepositorySummary[]>([])
const [catalogLoading, setCatalogLoading] = useState(true)
const [selectedDetailsLoading, setSelectedDetailsLoading] = useState(false)
const [scanSummaries, setScanSummaries] = useState<ScanSummary[]>([])
const [report, setReport] = useState<ScanReport | null>(null)
const [comparison, setComparison] = useState<ScanComparison | null>(null)
const [trend, setTrend] = useState<TrendPoint[]>([])
const [path, setPath] = useState('')
const [job, setJob] = useState<ScanJob | null>(null)
const [meta, setMeta] = useState<AppMeta | null>(null)
const [standaloneReport, setStandaloneReport] = useState<ScanReport | null>(null)
const [pinnedIds, setPinnedIds] = useState<string[]>(() => readIdList(PINNED_STORAGE_KEY))
const [recentIds, setRecentIds] = useState<string[]>(() => readIdList(RECENT_STORAGE_KEY))
const [catalogFilters, setCatalogFilters] = useState<CatalogFilters>(DEFAULT_FILTERS)
const [catalogSort, setCatalogSort] = useState<CatalogSort>({ 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<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [switcherOpen, setSwitcherOpen] = useState(false)
const [switcherQuery, setSwitcherQuery] = useState('')
const [switcherIndex, setSwitcherIndex] = useState(0)
const [advancedOpen, setAdvancedOpen] = useState(false)
const switcherInputRef = useRef<HTMLInputElement>(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<HTMLAnchorElement>, 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 = <K extends keyof CatalogFilters>(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 = () => <nav className="global-nav" aria-label="Primary navigation"><a className={route.page === 'dashboard' ? 'active' : ''} aria-current={route.page === 'dashboard' ? 'page' : undefined} href="/" onClick={(event) => handleNavigation(event, { page: 'dashboard' })}>Dashboard</a><a className={route.page === 'repositories' ? 'active' : ''} aria-current={route.page === 'repositories' ? 'page' : undefined} href="/repositories" onClick={(event) => handleNavigation(event, { page: 'repositories' })}>Repositories <span>{repositories.length || '—'}</span></a><a className={route.page === 'activity' ? 'active' : ''} aria-current={route.page === 'activity' ? 'page' : undefined} href="/activity" onClick={(event) => handleNavigation(event, { page: 'activity' })}>Activity</a><a className={route.page === 'queue' ? 'active' : ''} aria-current={route.page === 'queue' ? 'page' : undefined} href="/queue" onClick={(event) => handleNavigation(event, { page: 'queue' })}>Scan queue</a><a className={route.page === 'settings' ? 'active' : ''} aria-current={route.page === 'settings' ? 'page' : undefined} href="/settings" onClick={(event) => handleNavigation(event, { page: 'settings' })}>Settings</a></nav>
const renderTopbar = () => <header className="app-header"><a className="brand-home" href="/" onClick={(event) => handleNavigation(event, { page: 'dashboard' })} aria-label="RepoPulse dashboard"><BrandLockup /></a>{renderGlobalNav()}<div className="header-actions"><button className="shortcut-button" type="button" onClick={() => setSwitcherOpen(true)} aria-keyshortcuts="Control+K Meta+K"><span>Quick switch</span><kbd>⌘K</kbd></button><button className="icon-button sync-icon" type="button" onClick={() => void syncCatalog()} disabled={catalogBusy} aria-label="Synchronize Gitea catalog" title="Synchronize Gitea catalog">↻</button></div></header>
const renderCatalogPage = () => <section className="page-content explorer-page" aria-labelledby="repositories-title"><div className="page-heading"><div><span className="eyebrow">Repository explorer</span><h1 id="repositories-title">Repositories <span className="count-badge">{repositories.length}</span></h1><p className="muted">High-density catalog controls when you need breadth; switch to visual identity tiles when you need pattern recognition.</p></div><div className="page-heading-actions"><button className="ghost" type="button" onClick={() => setSwitcherOpen(true)}>Quick switcher <kbd>⌘K</kbd></button><button type="button" onClick={() => void syncCatalog()} disabled={catalogBusy}>{catalogBusy ? 'Syncing…' : 'Sync Gitea catalog'}</button></div></div><section className="catalog-toolbar section-card" aria-label="Repository filters"><div className="search-control"><span className="search-glyph" aria-hidden="true">⌕</span><label className="sr-only" htmlFor="catalog-search">Search repositories</label><input id="catalog-search" value={catalogFilters.query} onChange={(event) => updateCatalogFilter('query', event.target.value)} placeholder="Search name, owner or full name" /><kbd>⌘K</kbd></div><div className="filter-grid"><label>State<select aria-label="Filter by state" value={catalogFilters.state} onChange={(event) => updateCatalogFilter('state', event.target.value as CatalogFilters['state'])}><option value="all">All states</option><option value="current">Current</option><option value="stale">Stale</option><option value="scanning">Scanning</option><option value="failed">Failed</option><option value="never_scanned">Never scanned</option><option value="archived">Archived</option></select></label><label>Readiness<select aria-label="Filter by readiness" value={catalogFilters.readiness} onChange={(event) => updateCatalogFilter('readiness', event.target.value as CatalogFilters['readiness'])}><option value="all">All readiness</option><option value="ready">Ready</option><option value="warnings">Warnings</option><option value="blocked">Blocked</option></select></label><label>Owner<select aria-label="Filter by owner" value={catalogFilters.owner} onChange={(event) => updateCatalogFilter('owner', event.target.value)}><option value="all">All owners</option>{ownerOptions.map((owner) => <option key={owner} value={owner}>{owner}</option>)}</select></label><label>Branch<select aria-label="Filter by branch" value={catalogFilters.branch} onChange={(event) => updateCatalogFilter('branch', event.target.value)}><option value="all">All branches</option>{branchOptions.map((branch) => <option key={branch} value={branch}>{branch}</option>)}</select></label><label>Visibility<select aria-label="Filter by visibility" value={catalogFilters.visibility} onChange={(event) => updateCatalogFilter('visibility', event.target.value as CatalogFilters['visibility'])}><option value="all">Any visibility</option><option value="private">Private</option><option value="public">Public</option></select></label><label>Lifecycle<select aria-label="Filter by lifecycle" value={catalogFilters.archived} onChange={(event) => updateCatalogFilter('archived', event.target.value as CatalogFilters['archived'])}><option value="active">Active only</option><option value="all">Active and archived</option><option value="archived">Archived only</option></select></label></div><div className="toolbar-footer"><span className="filter-result">{filteredRepositories.length} matching {repositories.length} repositories</span><div className="view-toggle" aria-label="Repository view"><button className={catalogView === 'table' ? 'active' : ''} type="button" onClick={() => setCatalogView('table')} aria-pressed={catalogView === 'table'}>Table</button><button className={catalogView === 'grid' ? 'active' : ''} type="button" onClick={() => setCatalogView('grid')} aria-pressed={catalogView === 'grid'}>Visual grid</button></div><button className="text-button" type="button" onClick={() => { setCatalogFilters(DEFAULT_FILTERS); setCatalogPage(1) }}>Reset filters</button></div></section>{catalogLoading ? <section className="catalog-loading" aria-label="Loading repository catalog"><span /><span /><span /><span /></section> : catalogWindow.items.length ? <>{catalogView === 'table' ? <section className="catalog-table-card section-card" aria-label="Repository catalog results"><div className="table-scroll"><table className="repository-table"><thead><tr><th><button className="sort-button" type="button" onClick={() => setCatalogSortKey('name')}>Repository <span>{catalogSort.key === 'name' ? catalogSort.direction === 'asc' ? '↑' : '↓' : '↕'}</span></button></th><th><button className="sort-button" type="button" onClick={() => setCatalogSortKey('state')}>State <span>{catalogSort.key === 'state' ? catalogSort.direction === 'asc' ? '↑' : '↓' : '↕'}</span></button></th><th>Readiness</th><th>Branch</th><th><button className="sort-button" type="button" onClick={() => setCatalogSortKey('last_sLine truncated
const renderDetailPage = () => {
if (!selectedRepository) return <section className="page-content"><div className="empty-state section-card"><BrandMark size={38} label="" /><h1>This repository is not in the current catalog.</h1><p className="muted">It may have been archived, removed or the link may be stale.</p><a className="button-link" href="/repositories" onClick={(event) => handleNavigation(event, { page: 'repositories' })}>Back to repositories</a></div></section>
const readiness = repositoryReadiness(selectedRepository); const revision = report?.source.actual_revision || selectedRepository.latest_revision
return <section className="page-content detail-page" aria-labelledby="repository-title"><div className="breadcrumb-row"><a href="/repositories" onClick={(event) => handleNavigation(event, { page: 'repositories' })}>Repository explorer</a><span aria-hidden="true">/</span><button type="button" onClick={() => setSwitcherOpen(true)}>{selectedRepository.full_name} <span aria-hidden="true">⌄</span></button></div><section className="workspace-hero"><div className="workspace-identity"><div><span className="eyebrow">{selectedRepository.provider} repository · {selectedRepository.private ? 'private' : 'public'}</span><h1 id="repository-title">{selectedRepository.full_name}</h1><p>{selectedRepository.description || 'No repository description provided.'}</p><div className="workspace-facts"><span><code>{selectedRepository.default_branch}</code></span><span><code>{shortRevision(revision)}</code></span><span className={`readiness-label readiness-${readiness}`}><i aria-hidden="true" />{stateLabel(selectedRepository.state)}</span></div></div><div className="workspace-score"><strong>{report?.health_score ?? selectedRepository.last_scan_health ?? '—'}</strong><span>health score</span><small>{report?.release_readiness ?? readinessLabel(readiness)}</small></div></div><RepositoryVisual repository={selectedRepository} report={report} className="workspace-visual" /><div className="detail-actions"><button type="button" onClick={() => void analyzeRepository(selectedRepository)} disabled={busy || Boolean(activeJob)}>{activeJob ? 'Scanning…' : 'Scan now'}</button><details className="action-menu"><summary>Reports</summary><div className="menu-popover"><button type="button" onClick={() => void exportReport('html')} disabled={!report}>Export HTML</button><button type="button" onClick={() => void exportReport('json')} disabled={!report}>Export JSON</button></div></details><details className="action-menu"><summary>More</summary><div className="menu-popover"><button type="button" onClick={() => void analyzeRepository(selectedRepository, true)} disabled={busy || Boolean(activeJob)}>Force rescan</button><button type="button" onClick={() => navigate({ page: 'repositories', repositoryId: selectedRepository.id, view: 'scans' })}>View scan runs</button><button type="button" onClick={() => setAdvancedOpen((current) => !current)}>Advanced info</button></div></details><button className={`star-button header-star ${pinnedIds.includes(selectedRepository.id) ? 'pinned' : ''}`} type="button" onClick={() => togglePinned(selectedRepository.id)} aria-label={pinnedIds.includes(selectedRepository.id) ? 'Remove from pinned repositories' : 'Pin repository'} aria-pressed={pinnedIds.includes(selectedRepository.id)}><span aria-hidden="true">{pinnedIds.includes(selectedRepository.id) ? '★' : '☆'}</span></button></div></section>{error && <div className="error-banner" role="alert">{error}</div>}{notice && <div className="notice-banner" role="status">{notice}</div>}{activeJob && <div className="job-progress" role="status"><div><strong>{activeJob.message || 'Analysis in progress'}</strong><span>{activeJob.phase.replaceAll('_', ' ')} · {activeJob.progress}%</span></div><div className="progress-track"><i style={{ width: `${activeJob.progress}%` }} /></div></div>}<nav className="detail-tabs" aria-label="Repository analysis views">{DETAIL_VIEWS.map((view) => <a key={view} className={currentView === view ? 'active' : ''} aria-current={currentView === view ? 'page' : undefined} href={routePath({ page: 'repositories', repositoryId: selectedRepository.id, view })} onClick={(event) => handleNavigation(event, { page: 'repositories', repositoryId: selectedRepository.id, view })}>{detailViewLabel(view, report)}</a>)}</nav>{advancedOpen && <section className="section-card advanced-info"><div><span className="eyebrow">Advanced repository info</span><h2>Identity and policy</h2></div><div className="info-grid"><span><strong>Provider ID</strong><code>{selectedRepository.provider_repository_id}</code></span><span><strong>Visibility</strong>{selectedRepository.private ? 'Private' : 'Public'}</span><span><strong>Auto scan</strong>{selectedRepository.policy.auto_scan ? 'Enabled' : 'Disabled'}</span><span><strong>Minimum health</strong>{selectedRepository.policy.min_health_score}</span><span><strong>Latest revision</strong><code>{shortRevision(selectedRepository.latest_revision)}</code></span><span><strong>Catalog sync</strong>{formatDate(selectedRepository.last_synced_at)}</span></div></section>}{selectedDetailsLoading ? <section className="workspace-loading" role="status"><span /><span /><span /></section> : report ? <section className="detail-content">{currentView === 'overview' && <OverviewView report={report} comparison={comparison} />}{currentView === 'architecture' && <ArchitectureView report={report} />}{currentView === 'hotspots' && <HotspotsView report={report} />}{currentView === 'dependencies' && <DependenciesView report={report} />}{currentView === 'findings' &&Line truncated
}
const renderActivityPage = () => { const activity = [...repositories].filter((repository) => repository.last_scan_at || repository.state === 'scanning' || repository.state === 'queued').sort((left, right) => (Date.parse(right.last_scan_at ?? '') || 0) - (Date.parse(left.last_scan_at ?? '') || 0)); return <section className="page-content activity-page" aria-labelledby="activity-title"><div className="page-heading"><div><span className="eyebrow">Operational timeline</span><h1 id="activity-title">Activity</h1><p className="muted">A visual event feed derived from actual catalog state and persisted scan activity.</p></div></div><div className="summary-strip"><div><span>Catalog</span><strong>{repositories.length}</strong></div><div><span>Current</span><strong>{repositories.filter((repository) => repository.state === 'current').length}</strong></div><div><span>Needs attention</span><strong>{repositories.filter((repository) => repositoryReadiness(repository) !== 'ready').length}</strong></div><div><span>Latest scan</span><strong>{activity[0] ? formatRelativeDate(activity[0].last_scan_at) : '—'}</strong></div></div><section className="activity-timeline section-card"><div className="section-heading"><div><span className="eyebrow">Latest changes</span><h2>Repository activity</h2></div><span className="section-note">Each event opens its repository workspace.</span></div>{activity.length ? activity.slice(0, 30).map((repository) => <button type="button" className="timeline-event" key={repository.id} onClick={() => selectRepository(repository)}><span className={`timeline-rail ${repositoryReadiness(repository)}`} /><RepositoryVisual repository={repository} compact /><span><strong>{repository.full_name}</strong><small>{repository.state === 'scanning' || repository.state === 'queued' ? `${stateLabel(repository.state)} · ${repository.default_branch}` : `Scan completed ${formatRelativeDate(repository.last_scan_at)} · ${repository.default_branch}`}</small></span><b>{repository.last_scan_health ?? '—'}</b></button>) : <div className="empty-state compact-empty"><BrandMark size={34} label="" /><h2>No activity yet</h2><p className="muted">Scan a repository to start building its operational timeline.</p></div>}</section></section> }
const renderQueuePage = () => { const queue = repositories.filter((repository) => repository.active_job_id || repository.state === 'queued' || repository.state === 'scanning'); return <section className="page-content queue-page" aria-labelledby="queue-title"><div className="page-heading"><div><span className="eyebrow">Durable background work</span><h1 id="queue-title">Scan queue</h1><p className="muted">Exact revisions are retained while work moves through source acquisition, analysis and scoring.</p></div></div><div className="summary-strip"><div><span>Waiting</span><strong>{queue.filter((repository) => repository.state === 'queued').length}</strong></div><div><span>Active</span><strong>{queue.filter((repository) => repository.state === 'scanning' || repository.active_job_id).length}</strong></div><div><span>Completed</span><strong>{meta?.completed_jobs ?? '—'}</strong></div><div><span>Failed</span><strong>{meta?.failed_jobs ?? '—'}</strong></div></div><section className="queue-workspace section-card"><div className="section-heading"><div><span className="eyebrow">Live queue</span><h2>{queue.length ? 'Scans in progress' : 'Queue is clear'}</h2></div><span className="section-note">No misleading progress percentage is shown before the worker reports one.</span></div>{queue.length ? <div className="queue-list">{queue.map((repository) => <button type="button" className="queue-row" key={repository.id} onClick={() => selectRepository(repository)}><RepositoryVisual repository={repository} compact /><span><strong>{repository.full_name}</strong><small>{repository.default_branch} · {repository.active_job_id ? 'worker active' : 'waiting for worker'}</small></span><span className={`status-label status-${statusClass(repository.state)}`}><i aria-hidden="true" />{stateLabel(repository.state)}</span></button>)}</div> : <div className="queue-empty"><BrandMark size={48} label="" /><div><span className="eyebrow">All clear</span><h2>No scans are waiting</h2><p className="muted">The worker is healthy and the portfolio is up to date. Start a scan from a repository workspace whenever a fresh revision needs analysis.</p><a className="button-link" href="/repositories" onClick={(event) => handleNavigation(event, { page: 'repositories' })}>Browse repositories</a></div></div>}</section></section> }
const renderSettingsPage = () => <section className="page-content settings-page" aria-labelledby="settings-title"><div className="page-heading"><div><span className="eyebrow">Workspace configuration</span><h1 id="settings-title">Settings</h1><p className="muted">Connection, scanning and local preferences are separated into concise operational sections.</p></div></div><div className="settings-sections"><section className="settings-section"><div><span className="eyebrow">Connections</span><h2>Gitea catalog</h2><p>Provider-backed repository discovery and exact revision acquisition.</p></div><dl><div><dt>Status</dt><dd><span className={`status-label status-${meta?.gitea.configured ? 'current' : 'failed'}`}><i aria-hidden="true" />{meta?.gitea.configured ? 'Configured' : 'Not configured'}</span></dd></div><div><dt>Repositories</dt><dd>{meta?.repository_count ?? repositories.length}</dd></div><div><dt>Last sync</dt><dd>{meta?.gitea.last_sync && typeof meta.gitea.last_sync === 'object' && 'synced_at' in meta.gitea.last_sync ? formatDate(String(meta.gitea.last_sync.synced_at)) : 'Not available'}</dd></div></dl><button className="ghost" type="button" onClick={() => void syncCatalog()} disabled={catalogBusy}>{catalogBusy ? 'Syncing…' : 'Sync catalog now'}</button></section><section className="settings-section"><div><span className="eyebrow">Scanning & analysis</span><h2>Safe source controls</h2><p>The normal repository flow stays Gitea-native. The advanced path remains available only for mounted local sources.</p></div><dl><div><dt>Scan root</dt><dd>{meta?.scan_root_restricted ? 'Restricted' : 'Configured by server'}</dd></div><div><dt>Concurrency</dt><dd>{meta?.max_concurrent_scans ?? '—'} worker</dd></div><div><dt>Automation</dt><dd>Scheduled discovery enabled</dd></div></dl><form className="local-form" onSubmit={scanLocal}><label><span>Advanced repository path</span><input aria-label="Repository path" value={path} onChange={(event) => setPath(event.target.value)} placeholder="/scan/my-project" required /></label><button disabled={busy}>{busy ? 'Analyzing…' : 'Analyze local source'}</button></form>{standaloneReport && <div className="local-result" role="status"><strong>{standaloneReport.repository_name}</strong><span>{standaloneReport.health_score}/100 · {standaloneReport.release_readiness} · {formatDate(standaloneReport.scanned_at)}</span></div>}</section><section className="settings-section appearance"><div><span className="eyebrow">Appearance</span><h2>Personal workspace preferences</h2><p>Dark signal topology is the primary theme. View modes and navigation preferences are stored locally in this browser.</p></div><dl><div><dt>Explorer view</dt><dd><div className="view-toggle"><button className={catalogView === 'table' ? 'active' : ''} type="button" onClick={() => setCatalogView('table')}>Table</button><button className={catalogView === 'grid' ? 'active' : ''} type="button" onClick={() => setCatalogView('grid')}>Visual grid</button></div></dd></div><div><dt>Pinned repositories</dt><dd>{pinnedIds.length}</dd></div><div><dt>Recent repositories</dt><dd>{recentIds.length}</dd></div></dl></section></div></section>
return <main className="app-shell">{renderTopbar()}{error && !route.repositoryId && <div className="error-banner global-banner" role="alert">{error}</div>}{notice && !route.repositoryId && <div className="notice-banner global-banner" role="status">{notice}</div>}{route.page === 'dashboard' && <DashboardView repositories={repositories} pinnedIds={pinnedIds} onOpenRepository={selectRepository} onExploreReadiness={exploreReadiness} onOpenExplorer={() => navigate({ page: 'repositories' })} />}{route.page === 'activity' && renderActivityPage()}{route.page === 'queue' && renderQueuePage()}{route.page === 'settings' && renderSettingsPage()}{route.page === 'repositories' && (route.repositoryId ? renderDetailPage() : renderCatalogPage())}{switcherOpen && <div className="modal-backdrop" role="presentation" onMouseDown={() => setSwitcherOpen(false)}><section className="switcher-modal" role="dialog" aria-modal="true" aria-labelledby="switcher-title" onMouseDown={(event) => event.stopPropagation()}><div className="switcher-heading"><div><span className="eyebrow">Repository navigation</span><h2 id="switcher-title">Quick switcher</h2></div><button className="icon-button" type="button" onClick={() => setSwitcherOpen(false)} aria-label="Close quick switcher">×</button></div><div className="command-row"><button type="button" onClick={() => { navigate({ page: 'dashboard' }); setSwitcherOpen(false) }}>Open dashboard</button><button type="button" onClick={() => { navigate({ page: 'queue' }); setSwitcherOpen(false) }}>Open scan queue</button><button type="button" onClick={() => void syncCatalog()} disabled={catalogBusy}>Sync Gitea</button></div><label className="switcher-search"><span className="sr-only">Search repositories</span><input ref={switcherInputRef} value={switcherQuery} onChange={(event) => { setSwitcherQuery(event.target.value); setSwitcherIndex(0) }} placeholder="Search repositories…" /><kbd>Esc</kbd></label>{switcherRepositories.length ? <div className="switcher-results" role="listbox" aria-label="Repositories">{switcherRepositories.map((repository, index) => <button className={index === switcherIndex ? 'switcher-item active' : 'switcher-item'} type="button" role="option" aria-selected={index === switcherIndex} key={repository.id} onMouseEnter={() => setSwitcherIndex(index)} onClick={() => { selectRepository(repository); setSwitcherOpen(false) }}><RepositoryVisual repository={repository} compact /><span><strong>{repository.full_name}</strong><small>{repository.owner} · {repository.default_branch} · {repository.last_scan_health ?? 'not scanned'}</small></span><span className={`readiness-label readiness-${repositoryReadiness(repository)}`}><i aria-hidden="true" />{readinessLabel(repositoryReadiness(repository))}</span></button>)}</div> : <div className="empty-state compact-empty"><h2>No repository matches</h2><p className="muted">Try the owner, repository name or branch.</p></div>}<div className="switcher-footer"><span><kbd>↑</kbd><kbd>↓</kbd> Navigate</span><span><kbd>Enter</kbd> Open</span><span><kbd>Esc</kbd> Close</span></div></section></div>}</main>
}
+112
View File
@@ -0,0 +1,112 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { ScanReport } from './api'
import { FindingsView } from './components/FindingsView'
const report: ScanReport = {
id: 'scan-1',
repository_path: '/scan/repopulse',
repository_name: 'RepoPulse',
source: {
origin: 'gitea',
provider: 'gitea',
repository_id: '64',
repository_full_name: 'Jens/RepoPulse',
branch: 'main',
requested_revision: 'revision-1',
actual_revision: 'revision-1',
trigger: 'scheduled',
},
policy: {
auto_scan: true,
min_health_score: 70,
max_blockers: 0,
require_tests: true,
require_ci: true,
suppressions: [],
},
scanned_at: '2026-09-12T20:00:00Z',
duration_ms: 1200,
metrics: {
files: 2,
source_files: 2,
lines_of_code: 20,
test_files: 1,
todo: 0,
fixme: 0,
hack: 0,
xxx: 0,
dependency_manifests: 1,
declared_dependencies: 1,
large_files: 0,
large_source_files: 0,
},
languages: { TypeScript: 20 },
category_scores: { 'Security basics': 20, Maintainability: 80 },
health_score: 70,
release_readiness: 'BLOCKED',
findings: [
{
code: 'security.secret-assignment',
category: 'Security basics',
severity: 'blocker',
message: 'Potential credential assignment detected; value intentionally redacted',
path: 'backend/app/main.py',
line: 245,
deduction: 20,
fingerprint: 'fingerprint-1',
suppressed: false,
},
{
code: 'maintainability.long-function',
category: 'Maintainability',
severity: 'warning',
message: 'Function is longer than the configured threshold',
path: 'backend/app/signals.py',
line: 111,
deduction: 4,
fingerprint: 'fingerprint-2',
suppressed: false,
},
],
tests: { frameworks: ['pytest'], coverage_artifacts: [], ci_workflows: [], test_source_ratio: null },
git: { available: true, commit_count: 1, contributor_count: 1, latest_commit_at: null, tracked_files: 2, renamed_files: [] },
dependencies: { manifests: ['package.json'], lockfiles: ['package-lock.json'], declared_dependencies: 1, unbounded_dependencies: 0, unpinned_dependencies: 0, duplicate_dependencies: [] },
architecture: { nodes: [], edges: [], clusters: {}, truncated: false, total_nodes: 0, total_edges: 0 },
hotspots: [],
release_gate: null,
}
afterEach(() => cleanup())
describe('FindingsView', () => {
it('keeps findings scanable while exposing expandable rule details', () => {
const { container } = render(<FindingsView report={report} />)
const firstFinding = container.querySelector('details.finding') as HTMLDetailsElement
expect(firstFinding.open).toBe(false)
expect(screen.getByText('Potential credential assignment detected; value intentionally redacted')).toBeTruthy()
fireEvent.click(screen.getByText('Potential credential assignment detected; value intentionally redacted'))
expect(firstFinding.open).toBe(true)
expect(screen.getByText('−20 points')).toBeTruthy()
expect(screen.getByText('backend/app/main.py:245')).toBeTruthy()
expect(screen.getByText('security.secret-assignment')).toBeTruthy()
})
it('filters by severity and reports an actionable empty state', () => {
render(<FindingsView report={report} />)
const severity = screen.getByRole('combobox', { name: 'Filter findings by severity' })
const query = screen.getByRole('textbox', { name: 'Filter findings' })
fireEvent.change(severity, { target: { value: 'warning' } })
expect(screen.getByText(/Showing 1 of 2/)).toBeTruthy()
fireEvent.change(query, { target: { value: 'does-not-exist' } })
expect(screen.getByText('No findings match the current filters.')).toBeTruthy()
})
})
+50
View File
@@ -0,0 +1,50 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { OverviewView } from './components/OverviewView'
import { testComparison, testReport } from './test-fixtures'
afterEach(() => cleanup())
describe('OverviewView', () => {
it('prioritizes readiness and surfaces context for a low-signal repository', () => {
const report = testReport({
metrics: {
files: 0,
source_files: 0,
lines_of_code: 0,
test_files: 0,
todo: 0,
fixme: 0,
hack: 0,
xxx: 0,
dependency_manifests: 0,
declared_dependencies: 0,
large_files: 0,
large_source_files: 0,
},
dependencies: {
manifests: [],
lockfiles: [],
declared_dependencies: 0,
unbounded_dependencies: 0,
unpinned_dependencies: 0,
duplicate_dependencies: [],
},
languages: {},
findings: [],
release_readiness: 'READY',
release_gate: null,
architecture: { nodes: [], edges: [], clusters: {}, truncated: false, total_nodes: 0, total_edges: 0 },
hotspots: [],
})
const { container } = render(<OverviewView report={report} comparison={testComparison} />)
expect(screen.getByRole('status').textContent).toContain('little or no analyzable source code')
expect(screen.getAllByText('READY').length).toBeGreaterThan(0)
expect(container.querySelector('.metric-grid.low-signal')).toBeTruthy()
})
})
+81
View File
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { api } from './api'
afterEach(() => {
vi.unstubAllGlobals()
})
describe('RepoPulse API client', () => {
it('submits a local repository scan request', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ id: 'scan-1' }), {
status: 201,
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', fetchMock)
await api.scan('/scan/project')
expect(fetchMock).toHaveBeenCalledWith(
'/api/v1/scans',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ path: '/scan/project' }),
}),
)
})
it('encodes repository paths for trend requests', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', fetchMock)
await api.trend('/scan/Project With Space')
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/trend?path=%2Fscan%2FProject%20With%20Space&limit=50')
})
it('uses the comparison endpoint for persisted scan deltas', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({
current_scan_id: 'scan-2',
previous_scan_id: 'scan-1',
health_score_delta: 4,
category_score_deltas: {},
metric_deltas: {},
added_findings: [],
resolved_findings: [],
}), { status: 200, headers: { 'Content-Type': 'application/json' } }),
)
vi.stubGlobal('fetch', fetchMock)
const result = await api.comparison('scan-2')
expect(result.health_score_delta).toBe(4)
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/scans/scan-2/comparison')
})
it('uses repository identity and queues a revision-aware scan without a path', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ id: 'job-1', status: 'queued' }), {
status: 202,
headers: { 'Content-Type': 'application/json' },
}),
)
vi.stubGlobal('fetch', fetchMock)
await api.enqueueRepositoryScan('gitea:64')
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/repositories/gitea%3A64/scans')
expect(fetchMock.mock.calls[0][1]).toEqual(expect.objectContaining({
method: 'POST',
body: JSON.stringify({ force: false }),
}))
})
})
+386
View File
@@ -0,0 +1,386 @@
export type Severity = 'info' | 'warning' | 'blocker'
export type GateState = 'pass' | 'warn' | 'block'
export type SourceOrigin = 'local' | 'gitea'
export type ScanTrigger = 'manual' | 'scheduled' | 'webhook' | 'local'
export type ScanJobStatus = 'queued' | 'acquiring_source' | 'scanning' | 'analyzing' | 'persisting' | 'completed' | 'failed' | 'cancelled' | 'superseded'
export type RepositoryState = 'never_scanned' | 'current' | 'stale' | 'queued' | 'scanning' | 'failed' | 'unavailable' | 'archived'
export type Finding = {
code: string
category: string
severity: Severity
message: string
path: string | null
line: number | null
deduction: number
fingerprint: string | null
suppressed: boolean
guidance?: FindingGuidance | null
}
export type FindingGuidance = {
remediation_class: string
confidence: string
why: string
recommended_action: string
priority: number
priority_rationale: string
}
export type RepositoryMetrics = {
files: number
source_files: number
lines_of_code: number
test_files: number
todo: number
fixme: number
hack: number
xxx: number
dependency_manifests: number
declared_dependencies: number
large_files: number
large_source_files: number
}
export type TestSignals = {
frameworks: string[]
coverage_artifacts: string[]
ci_workflows: string[]
ci_validation_workflows?: string[]
ci_build_workflows?: string[]
ci_deploy_only_workflows?: string[]
coverage_evidence?: string[]
test_source_ratio: number | null
}
export type GitSignals = {
available: boolean
commit_count: number | null
contributor_count: number | null
latest_commit_at: string | null
tracked_files: number | null
renamed_files: string[]
}
export type DependencySignals = {
manifests: string[]
lockfiles: string[]
declared_dependencies: number
unbounded_dependencies: number
unpinned_dependencies: number
duplicate_dependencies: string[]
included_requirements?: string[]
}
export type ArchitectureNode = {
id: string
label: string
path: string
language: string
inbound: number
outbound: number
visible_inbound: number
visible_outbound: number
hotspot_score: number
}
export type ArchitectureEdge = {
source: string
target: string
kind: string
}
export type ArchitectureGraph = {
nodes: ArchitectureNode[]
edges: ArchitectureEdge[]
clusters: Record<string, number>
truncated: boolean
total_nodes: number
total_edges: number
}
export type Hotspot = {
path: string
language: string
lines_of_code: number
debt_markers: number
churn: number
inbound: number
outbound: number
score: number
reasons: string[]
}
export type GateCheck = {
key: string
label: string
state: GateState
detail: string
}
export type ReleaseGate = {
status: string
checks: GateCheck[]
}
export type RepositoryPolicy = {
auto_scan: boolean
min_health_score: number
max_blockers: number
require_tests: boolean
require_ci: boolean
suppressions: string[]
}
export type RemediationSignal = {
code: string
category: string
severity: Severity
message: string
count: number
guidance?: FindingGuidance | null
}
export type ScanLimits = {
max_files: number
max_text_bytes_per_file: number
max_total_text_bytes?: number
max_findings: number
max_secret_findings: number
}
export type ScanCoverage = {
limits: ScanLimits
discovered_files: number
scanned_files: number
skipped_files_by_budget: number
skipped_files_by_size: number
skipped_files_by_text_budget?: number
text_bytes_scanned: number
truncated: boolean
notes: string[]
}
export type ScanSource = {
origin: SourceOrigin
provider: string | null
repository_id: string | null
repository_full_name: string | null
branch: string | null
requested_revision: string | null
actual_revision: string | null
trigger: ScanTrigger
}
export type ScanReport = {
id: string
repository_path: string
repository_name: string
source: ScanSource
policy: RepositoryPolicy
scanned_at: string
duration_ms: number
metrics: RepositoryMetrics
languages: Record<string, number>
category_scores: Record<string, number>
health_score: number
release_readiness: string
findings: Finding[]
tests: TestSignals
git: GitSignals
dependencies: DependencySignals
architecture: ArchitectureGraph
hotspots: Hotspot[]
release_gate: ReleaseGate | null
coverage?: ScanCoverage | null
compacted?: boolean
}
export type RepositorySummary = {
id: string
provider: string
provider_repository_id: string
owner: string
name: string
full_name: string
description: string | null
html_url: string | null
clone_url: string | null
default_branch: string
private: boolean
archived: boolean
available: boolean
latest_revision: string | null
latest_revision_at: string | null
last_synced_at: string | null
sync_error: string | null
state: RepositoryState
stale: boolean
active_job_id: string | null
last_scan_id: string | null
last_scan_at: string | null
last_scan_health: number | null
last_scan_readiness: string | null
last_scan_revision: string | null
last_scan_blockers: number
last_scan_warnings: number
last_scan_primary_signal: RemediationSignal | null
last_scan_health_delta: number | null
last_scan_added_findings: number
last_scan_resolved_findings: number
last_scan_priority?: number
last_scan_priority_rationale?: string | null
policy: RepositoryPolicy
}
export type RepositoryDetail = RepositorySummary & { latest_scan: ScanReport | null }
export type AppMeta = {
name: string
version: string
stage: string
scan_root_restricted: boolean
scan_root: string | null
max_concurrent_scans: number
repository_count: number
repository_states: Record<string, number>
current_repositories: number
stale_repositories: number
queue_depth: number
active_jobs: number
completed_jobs: number
failed_jobs: number
worker_heartbeat: string | null
gitea: {
configured: boolean
url: string | null
last_sync: unknown
}
storage?: {
database_bytes: number
journal_bytes: number
mirror_count: number
mirror_cache_bytes: number
worktree_count: number
scan_count: number
compacted_scan_count: number
oldest_scan_at: string | null
newest_scan_at: string | null
history_per_repository: number
full_reports_per_repository: number
report_retention_days: number
last_schema_migration_backup?: string | null
}
database_recovery?: Record<string, unknown>
mutation_authentication?: 'operator_token' | 'perimeter_required'
}
export type ScanJob = {
id: string
repository_id: string
status: ScanJobStatus
trigger: ScanTrigger
requested_revision: string | null
actual_revision: string | null
phase: string
progress: number
message: string | null
created_at: string
started_at: string | null
updated_at: string
finished_at: string | null
duration_ms: number | null
scan_id: string | null
error_code: string | null
error_detail: string | null
attempt: number
deduplicated: boolean
}
export type ScanSummary = {
id: string
repository_id: string
scanned_at: string
branch: string | null
requested_revision: string | null
actual_revision: string | null
trigger: ScanTrigger
health_score: number
release_readiness: string
findings: number
duration_ms: number
}
export type RepositorySyncResult = {
provider: string
discovered: number
created: number
updated: number
unavailable: number
errors: string[]
partial?: boolean
branch_failures?: number
listing_complete?: boolean
synced_at: string
}
export type ScanComparison = {
current_scan_id: string
previous_scan_id: string | null
health_score_delta: number | null
category_score_deltas: Record<string, number>
metric_deltas: Record<string, number>
added_findings: Finding[]
resolved_findings: Finding[]
}
export type TrendPoint = {
scan_id: string
scanned_at: string
health_score: number
findings: number
blockers: number
warnings: number
debt_markers: number
}
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...init?.headers },
...init,
})
if (!response.ok) {
const payload = await response.json().catch(() => null)
throw new Error(payload?.detail || `Request failed (${response.status})`)
}
return response.json() as Promise<T>
}
const repositoryPath = (id: string) => encodeURIComponent(id)
export const api = {
scan: (path: string) => request<ScanReport>('/api/v1/scans', {
method: 'POST',
body: JSON.stringify({ path }),
}),
recent: () => request<ScanReport[]>('/api/v1/scans?limit=20'),
meta: () => request<AppMeta>('/api/v1/meta'),
repositories: () => request<RepositorySummary[]>('/api/v1/repositories?limit=1000'),
repository: (id: string) => request<RepositoryDetail>(`/api/v1/repositories/${repositoryPath(id)}`),
repositoryScans: (id: string) => request<ScanSummary[]>(`/api/v1/repositories/${repositoryPath(id)}/scans?limit=200`),
repositoryTrend: (id: string) => request<TrendPoint[]>(`/api/v1/repositories/${repositoryPath(id)}/trend?limit=100`),
syncRepositories: () => request<RepositorySyncResult>('/api/v1/repositories/sync', { method: 'POST' }),
enqueueRepositoryScan: (id: string, force = false) => request<ScanJob>(`/api/v1/repositories/${repositoryPath(id)}/scans`, {
method: 'POST',
body: JSON.stringify({ force }),
}),
scanJob: (id: string) => request<ScanJob>(`/api/v1/scan-jobs/${encodeURIComponent(id)}`),
repositoryScan: (repositoryId: string, scanId: string) => request<ScanReport>(`/api/v1/repositories/${repositoryPath(repositoryId)}/scans/${encodeURIComponent(scanId)}`),
comparison: (scanId: string) => request<ScanComparison>(`/api/v1/scans/${encodeURIComponent(scanId)}/comparison`),
trend: (path: string) => request<TrendPoint[]>(`/api/v1/trend?path=${encodeURIComponent(path)}&limit=50`),
exportReport: async (scanId: string, format: 'html' | 'json') => {
const response = await fetch(`/api/v1/scans/${encodeURIComponent(scanId)}/export?format=${format}`)
if (!response.ok) throw new Error(`Export failed (${response.status})`)
return response.text()
},
}
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import { filterRepositories, orderSwitcherRepositories, paginateRepositories, sortRepositories } from './catalog'
import type { RepositorySummary } from './api'
function repository(index: number): RepositorySummary {
return {
id: `gitea:${index}`,
provider: 'gitea',
provider_repository_id: String(index),
owner: index % 2 ? 'Jens' : 'Team',
name: `repo-${String(index).padStart(3, '0')}`,
full_name: `${index % 2 ? 'Jens' : 'Team'}/repo-${String(index).padStart(3, '0')}`,
description: null,
html_url: null,
clone_url: null,
default_branch: index % 3 ? 'main' : 'develop',
private: index % 4 === 0,
archived: index % 17 === 0,
available: true,
latest_revision: `revision-${index}`,
latest_revision_at: '2026-09-12T10:00:00Z',
last_synced_at: '2026-09-12T10:00:00Z',
sync_error: null,
state: index % 11 === 0 ? 'never_scanned' : 'current',
stale: false,
active_job_id: null,
last_scan_id: index % 11 === 0 ? null : `scan-${index}`,
last_scan_at: index % 11 === 0 ? null : '2026-09-12T10:00:00Z',
last_scan_health: index % 11 === 0 ? null : 80 + (index % 20),
last_scan_readiness: index % 11 === 0 ? null : 'READY',
last_scan_revision: `revision-${index}`,
last_scan_blockers: 0,
last_scan_warnings: 0,
last_scan_primary_signal: null,
last_scan_health_delta: null,
last_scan_added_findings: 0,
last_scan_resolved_findings: 0,
policy: { auto_scan: true, min_health_score: 70, max_blockers: 0, require_tests: false, require_ci: false, suppressions: [] },
}
}
describe('repository catalog helpers', () => {
it('filters by owner, branch, visibility and readiness', () => {
const repositories = Array.from({ length: 20 }, (_, index) => repository(index + 1))
const result = filterRepositories(repositories, {
query: 'repo-0',
state: 'all',
readiness: 'ready',
owner: 'Jens',
branch: 'main',
visibility: 'public',
archived: 'active',
})
expect(result.every((item) => item.owner === 'Jens' && item.default_branch === 'main' && !item.private && !item.archived)).toBe(true)
expect(result.length).toBeGreaterThan(0)
})
it('sorts deterministically and paginates large catalogs without rendering all rows', () => {
const repositories = Array.from({ length: 500 }, (_, index) => repository(index + 1))
const sorted = sortRepositories(repositories, { key: 'name', direction: 'asc' })
const page = paginateRepositories(sorted, 7, 25)
expect(page.total).toBe(500)
expect(page.pageCount).toBe(20)
expect(page.page).toBe(7)
expect(page.items).toHaveLength(25)
expect(page.items[0].full_name).toBe('Jens/repo-301')
})
it('puts pinned and recent repositories first in the quick switcher', () => {
const repositories = [repository(1), repository(2), repository(3)]
const result = orderSwitcherRepositories(repositories, ['gitea:3'], ['gitea:2'], 'repo')
expect(result.map((item) => item.id)).toEqual(['gitea:3', 'gitea:2', 'gitea:1'])
})
})
+118
View File
@@ -0,0 +1,118 @@
import type { RepositoryState, RepositorySummary } from './api'
export type Readiness = 'ready' | 'warnings' | 'blocked'
export type CatalogFilters = {
query: string
state: 'all' | RepositoryState
readiness: 'all' | Readiness
owner: string
branch: string
visibility: 'all' | 'private' | 'public'
archived: 'all' | 'active' | 'archived'
}
export type CatalogSortKey = 'name' | 'health' | 'last_scan' | 'state'
export type CatalogSort = {
key: CatalogSortKey
direction: 'asc' | 'desc'
}
export type CatalogPage = {
items: RepositorySummary[]
total: number
page: number
pageSize: number
pageCount: number
}
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
export function repositoryReadiness(repository: RepositorySummary): Readiness {
if (!repository.available || repository.state === 'failed' || repository.state === 'unavailable') return 'blocked'
if (!repository.last_scan_id || repository.state === 'never_scanned' || repository.last_scan_health == null) return 'warnings'
const persistedReadiness = repository.last_scan_readiness?.toUpperCase() ?? ''
if (persistedReadiness.includes('BLOCK')) return 'blocked'
if (persistedReadiness.includes('WARNING')) return 'warnings'
if (persistedReadiness === 'READY') return 'ready'
if (repository.last_scan_health < 60) return 'blocked'
if (repository.stale || repository.state === 'stale' || repository.state === 'queued' || repository.state === 'scanning' || repository.last_scan_health < 80) return 'warnings'
return 'ready'
}
export function readinessLabel(readiness: Readiness) {
if (readiness === 'ready') return 'Ready'
if (readiness === 'warnings') return 'Warnings'
return 'Blocked'
}
export function stateLabel(state: RepositoryState) {
if (state === 'scanning') return 'Scanning'
if (state === 'queued') return 'Queued'
if (state === 'never_scanned') return 'Never scanned'
if (state === 'current') return 'Current'
if (state === 'stale') return 'Stale'
if (state === 'archived') return 'Archived'
if (state === 'unavailable') return 'Unavailable'
return 'Failed'
}
export function filterRepositories(repositories: RepositorySummary[], filters: CatalogFilters) {
const query = filters.query.trim().toLowerCase()
return repositories.filter((repository) => {
if (query && !`${repository.full_name} ${repository.owner} ${repository.name} ${repository.provider} ${repository.default_branch}`.toLowerCase().includes(query)) return false
if (filters.state !== 'all' && repository.state !== filters.state) return false
if (filters.readiness !== 'all' && repositoryReadiness(repository) !== filters.readiness) return false
if (filters.owner !== 'all' && repository.owner !== filters.owner) return false
if (filters.branch !== 'all' && repository.default_branch !== filters.branch) return false
if (filters.visibility === 'private' && !repository.private) return false
if (filters.visibility === 'public' && repository.private) return false
if (filters.archived === 'active' && repository.archived) return false
if (filters.archived === 'archived' && !repository.archived) return false
return true
})
}
export function sortRepositories(repositories: RepositorySummary[], sort: CatalogSort) {
const sorted = [...repositories].sort((left, right) => {
let comparison = 0
if (sort.key === 'name') comparison = collator.compare(left.full_name, right.full_name)
if (sort.key === 'health') comparison = (left.last_scan_health ?? -1) - (right.last_scan_health ?? -1)
if (sort.key === 'last_scan') comparison = (Date.parse(left.last_scan_at ?? '') || 0) - (Date.parse(right.last_scan_at ?? '') || 0)
if (sort.key === 'state') comparison = collator.compare(stateLabel(left.state), stateLabel(right.state))
if (comparison === 0) comparison = collator.compare(left.full_name, right.full_name)
return sort.direction === 'asc' ? comparison : -comparison
})
return sorted
}
export function paginateRepositories(repositories: RepositorySummary[], page: number, pageSize: number): CatalogPage {
const safePageSize = Math.max(1, pageSize)
const pageCount = Math.max(1, Math.ceil(repositories.length / safePageSize))
const safePage = Math.min(Math.max(1, page), pageCount)
const start = (safePage - 1) * safePageSize
return {
items: repositories.slice(start, start + safePageSize),
total: repositories.length,
page: safePage,
pageSize: safePageSize,
pageCount,
}
}
export function orderSwitcherRepositories(repositories: RepositorySummary[], pinnedIds: string[], recentIds: string[], query = '') {
const needle = query.trim().toLowerCase()
return repositories
.filter((item) => !needle || `${item.full_name} ${item.owner} ${item.name} ${item.default_branch}`.toLowerCase().includes(needle))
.sort((left, right) => {
const leftPinned = pinnedIds.includes(left.id) ? 0 : 1
const rightPinned = pinnedIds.includes(right.id) ? 0 : 1
if (leftPinned !== rightPinned) return leftPinned - rightPinned
const leftRecent = recentIds.indexOf(left.id)
const rightRecent = recentIds.indexOf(right.id)
if (leftRecent !== rightRecent) return (leftRecent < 0 ? 99 : leftRecent) - (rightRecent < 0 ? 99 : rightRecent)
return left.full_name.localeCompare(right.full_name)
})
.slice(0, 50)
}
@@ -0,0 +1,42 @@
import { useMemo, useState } from 'react'
import { Background, Controls, MiniMap, ReactFlow, type Edge, type Node } from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import type { ArchitectureNode, ScanReport } from '../api'
export function ArchitectureView({ report }: { report: ScanReport }) {
const [query, setQuery] = useState('')
const [selectedId, setSelectedId] = useState<string | null>(null)
const visibleSource = useMemo(() => {
const needle = query.trim().toLocaleLowerCase()
return report.architecture.nodes.filter((node) => !needle || `${node.label} ${node.path} ${node.language}`.toLocaleLowerCase().includes(needle))
}, [query, report.architecture.nodes])
const grouped = useMemo(() => {
const groups = new Map<string, ArchitectureNode[]>()
for (const node of visibleSource) {
const cluster = node.path.includes('/') ? node.path.split('/')[0] : 'root'
groups.set(cluster, [...(groups.get(cluster) ?? []), node])
}
return groups
}, [visibleSource])
const nodes = useMemo(() => {
const result: Node[] = []
Array.from(grouped.entries()).forEach(([cluster, items], clusterIndex) => {
const columns = 8
const clusterWidth = columns * 230 + 260
const orderedItems = [...items].sort((left, right) => right.hotspot_score - left.hotspot_score || left.path.localeCompare(right.path))
orderedItems.forEach((item, index) => result.push({
id: item.id,
position: { x: clusterIndex * clusterWidth + (index % columns) * 230, y: Math.floor(index / columns) * 150 },
data: { label: `${item.label} · ${item.hotspot_score}` },
className: item.id === selectedId ? 'arch-selected' : item.hotspot_score >= 70 ? 'arch-hot' : item.hotspot_score >= 40 ? 'arch-warm' : 'arch-calm',
}))
})
return result
}, [grouped, selectedId])
const visible = new Set(nodes.map((node) => node.id))
const edges: Edge[] = report.architecture.edges.filter((edge) => visible.has(edge.source) && visible.has(edge.target)).map((edge, index) => ({ id: `${edge.source}-${edge.target}-${index}`, source: edge.source, target: edge.target, animated: edge.source === selectedId || edge.target === selectedId }))
const selected = visibleSource.find((node) => node.id === selectedId) ?? null
return <div className="view-stack"><section className="section-card architecture-summary"><div><span className="eyebrow">Architecture intelligence</span><h2>Local import and dependency topology</h2><p className="muted">Search, pan and zoom the bounded graph. Selecting a module reveals its local coupling and hotspot signals.</p></div><div className="architecture-stats"><div><strong>{nodes.length}<small>/{report.architecture.total_nodes || report.architecture.nodes.length}</small></strong><span>visible / total nodes</span></div><div><strong>{edges.length}<small>/{report.architecture.total_edges || report.architecture.edges.length}</small></strong><span>visible / total edges</span></div><div><strong>{grouped.size}</strong><span>clusters</span></div></div></section><section className="architecture-workspace section-card"><div className="architecture-toolbar"><label><span className="sr-only">Search architecture</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search module, path or language" /></label><span>{visibleSource.length} modules visible</span></div><div className="architecture-main"><div className="architecture-canvas">{nodes.length ? <ReactFlow nodes={nodes} edges={edges} fitView minZoom={0.1} onNodeClick={(_, node) => setSelectedId(node.id)} onPaneClick={() => setSelectedId(null)}><Background gap={20} /><MiniMap pannable zoomable /><Controls /></ReactFlow> : <div className="empty-inline">No modules match this architecture query.</div>}</div><aside className="architecture-inspector" aria-live="polite">{selected ? <><span className="eyebrow">Selected module</span><h3>{selected.label}</h3><code>{selected.path}</code><dl><div><dt>Language</dt><dd>{selected.language}</dd></div><div><dt>Hotspot score</dt><dd>{selected.hotspot_score}</dd></div><div><dt>Inbound edges</dt><dd>{selected.inbound}</dd></div><div><dt>Outbound edges</dt><dd>{selected.outbound}</dd></div></dl></> : <><span className="eyebrow">Module inspector</span><h3>Explore the topology</h3><p>Click a node to see its path, language and coupling. The graph stays bounded for a responsive workspace.</p></>}</aside></div></section>{report.architecture.truncated && <div className="notice-banner">The graph is intentionally bounded for responsiveness; hotspot analysis still considers all supported source files.</div>}</div>
}
+32
View File
@@ -0,0 +1,32 @@
type BrandMarkProps = {
size?: number
label?: string
className?: string
}
/**
* RepoPulse's mark combines a bounded repository topology with a measured
* signal. It is deliberately geometric so it stays legible at favicon size.
*/
export function BrandMark({ size = 32, label = 'RepoPulse', className = '' }: BrandMarkProps) {
return (
<svg className={`brand-svg ${className}`} width={size} height={size} viewBox="0 0 32 32" role={label ? 'img' : undefined} aria-label={label || undefined} xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="8" fill="currentColor" opacity=".14" />
<path d="M7 9.5h5.1l2.2 2.2H25" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
<path d="M7 22.5h5.1l2.2-2.2H25" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
<path d="M12.8 16h2.1l1.55-3.1 2.15 6.2 1.35-3.1H25" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="7" cy="9.5" r="2" fill="currentColor" />
<circle cx="7" cy="22.5" r="2" fill="currentColor" />
<circle cx="25" cy="16" r="2" fill="currentColor" />
</svg>
)
}
export function BrandLockup({ compact = false }: { compact?: boolean }) {
return (
<span className="brand-lockup-copy">
<BrandMark size={compact ? 28 : 32} label="RepoPulse mark" />
{!compact && <span><strong>RepoPulse</strong><small>repository intelligence</small></span>}
</span>
)
}
@@ -0,0 +1,32 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { DashboardView } from './DashboardView'
import { testRepository } from '../test-fixtures'
afterEach(() => cleanup())
describe('DashboardView', () => {
it('aggregates repository readiness and carries intelligence interactions into navigation', () => {
const blocked = testRepository('gitea:10', 'Jens/Blocked', { last_scan_health: 58, last_scan_readiness: 'BLOCKED', last_scan_added_findings: 2, last_scan_priority: 800, last_scan_primary_signal: { code: 'dependency.no-lockfile', category: 'Dependencies', severity: 'blocker', message: 'No lockfile was retained for the declared dependencies.', count: 1, guidance: { remediation_class: 'dependency reproducibility', confidence: 'high', why: 'The workspace has no retained package-manager lockfile.', recommended_action: 'Commit the package-manager lockfile for a repeatable build.', priority: 800, priority_rationale: 'Blocker signal.' } } })
const ready = testRepository('gitea:11', 'Jens/Ready', { last_scan_health: 96, last_scan_readiness: 'READY' })
const warning = testRepository('gitea:12', 'Jens/Warning', { last_scan_health: 85, last_scan_readiness: 'READY WITH WARNINGS' })
const onOpenRepository = vi.fn()
const onExploreReadiness = vi.fn()
render(<DashboardView repositories={[blocked, ready, warning]} pinnedIds={[ready.id]} onOpenRepository={onOpenRepository} onExploreReadiness={onExploreReadiness} onOpenExplorer={vi.fn()} />)
expect(screen.getByRole('heading', { name: '80/100 portfolio signal' })).toBeTruthy()
expect(screen.getByText('Why attention is needed')).toBeTruthy()
expect(screen.getByRole('heading', { name: 'Remediation Center' })).toBeTruthy()
expect(screen.getAllByText('No lockfile was retained for the declared dependencies.')).toHaveLength(2)
expect(screen.getAllByText('Commit the package-manager lockfile for a repeatable build.')).toHaveLength(2)
fireEvent.click(screen.getByRole('button', { name: 'Open 1 blocked repositories in Explorer' }))
expect(onExploreReadiness).toHaveBeenCalledWith('blocked')
fireEvent.click(screen.getByRole('button', { name: /Open Jens\/Blocked/ }))
expect(onOpenRepository).toHaveBeenCalledWith(blocked)
})
})
+104
View File
@@ -0,0 +1,104 @@
import type { RepositorySummary } from '../api'
import { readinessLabel, repositoryReadiness, type Readiness } from '../catalog'
import { PortfolioAtlas } from './PortfolioAtlas'
import { RemediationCenter } from './RemediationCenter'
import { RepositoryVisual } from './RepositoryVisual'
type DashboardViewProps = {
repositories: RepositorySummary[]
pinnedIds: string[]
onOpenRepository: (repository: RepositorySummary) => void
onExploreReadiness: (readiness: Readiness) => void
onOpenExplorer: () => void
}
function relativeTime(value: string | null) {
if (!value) return 'Not scanned'
const difference = Date.now() - Date.parse(value)
if (Number.isNaN(difference)) return 'Unknown scan time'
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 attentionReason(repository: RepositorySummary) {
if (repository.state === 'scanning') return 'Analysis is in progress — wait for the retained result before triage.'
if (repository.stale) return 'The retained scan is stale — refresh it before relying on the release signal.'
if (!repository.last_scan_at) return 'No retained scan exists yet — establish an evidence baseline.'
if (repository.last_scan_primary_signal) return repository.last_scan_primary_signal.message
if (repositoryReadiness(repository) === 'blocked') return `Release readiness is blocked at ${repository.last_scan_health ?? '—'} health.`
return `Warnings remain at ${repository.last_scan_health ?? '—'} health; review them before the next release.`
}
function remediationPath(repository: RepositorySummary) {
return repository.last_scan_primary_signal?.guidance?.recommended_action ?? 'Open the retained evidence and first safe action.'
}
function changeContext(repository: RepositorySummary) {
if (repository.last_scan_added_findings > 0) return `${repository.last_scan_added_findings} new signal${repository.last_scan_added_findings === 1 ? '' : 's'} since prior scan`
if (repository.last_scan_resolved_findings > 0) return `${repository.last_scan_resolved_findings} resolved since prior scan`
return repository.last_scan_health_delta == null ? 'baseline signal' : 'longstanding signal'
}
function triagePriority(repository: RepositorySummary) {
return repository.last_scan_priority ?? 0
}
function readinessDescription(readiness: Readiness) {
if (readiness === 'blocked') return 'needs intervention'
if (readiness === 'warnings') return 'needs review'
return 'ready to ship'
}
export function DashboardView({ repositories, pinnedIds, onOpenRepository, onExploreReadiness, onOpenExplorer }: DashboardViewProps) {
const portfolio = repositories.filter((repository) => !repository.archived)
const counts = portfolio.reduce<Record<Readiness, number>>((result, repository) => {
result[repositoryReadiness(repository)] += 1
return result
}, { ready: 0, warnings: 0, blocked: 0 })
const scored = portfolio.filter((repository) => repository.last_scan_health != null)
const averageHealth = scored.length ? Math.round(scored.reduce((sum, repository) => sum + (repository.last_scan_health ?? 0), 0) / scored.length) : 0
const attention = [...portfolio].filter((repository) => repositoryReadiness(repository) !== 'ready').sort((left, right) => triagePriority(right) - triagePriority(left) || (left.last_scan_health ?? -1) - (right.last_scan_health ?? -1) || (Date.parse(right.last_scan_at ?? '') || 0) - (Date.parse(left.last_scan_at ?? '') || 0)).slice(0, 5)
const recent = [...portfolio].filter((repository) => repository.last_scan_at).sort((left, right) => (Date.parse(right.last_scan_at ?? '') || 0) - (Date.parse(left.last_scan_at ?? '') || 0)).slice(0, 4)
const pinned = pinnedIds.map((id) => portfolio.find((repository) => repository.id === id)).filter((repository): repository is RepositorySummary => Boolean(repository)).slice(0, 3)
const stale = portfolio.filter((repository) => repository.stale).length
const nextRepository = attention[0] ?? recent[0]
const improved = portfolio.filter((repository) => repository.last_scan_resolved_findings > repository.last_scan_added_findings || (repository.last_scan_health_delta ?? 0) > 0).length
const regressed = portfolio.filter((repository) => repository.last_scan_added_findings > 0 || (repository.last_scan_health_delta ?? 0) < 0).length
return (
<section className="page-content dashboard-page cockpit-page" aria-labelledby="dashboard-title">
<header className="dashboard-command">
<div className="command-copy"><span className="eyebrow">Portfolio command deck</span><h1 id="dashboard-title"><span>{averageHealth}</span><small>/100</small> portfolio signal</h1><p>{portfolio.length} retained repository signals · {counts.blocked + counts.warnings} require a deliberate decision · {stale} stale</p><div className="command-trend" aria-label={`${improved} improved repositories and ${regressed} repositories with new signals`}><span><i className="trend-up" />{improved} improving</span><span><i className="trend-watch" />{regressed} new signals</span></div></div>
<div className="command-pulse" aria-label={`${averageHealth} average portfolio health`}><svg viewBox="0 0 230 110" aria-hidden="true"><path className="pulse-field" d="M0 81C29 59 43 92 68 65s32-46 57-23 36 14 54-9 31-28 51-10" /><path className="pulse-line" d="M0 79h26l14-22 17 39 21-62 19 42h30l16-24 20 28h37" /><circle cx="203" cy="80" r="5" /></svg><strong>{averageHealth}</strong><span>health signal</span></div>
<dl className="command-context"><div><dt>Blockers</dt><dd>{counts.blocked}</dd></div><div><dt>Warnings</dt><dd>{counts.warnings}</dd></div><div><dt>Clear</dt><dd>{counts.ready}</dd></div></dl>
</header>
<nav className="dashboard-readiness-rail" aria-label="Portfolio readiness summary">
{(['blocked', 'warnings', 'ready'] as Readiness[]).map((readiness, index) => <button key={readiness} type="button" className={`readiness-rail-segment ${readiness}`} onClick={() => onExploreReadiness(readiness)} aria-label={`Open ${counts[readiness]} ${readinessLabel(readiness).toLowerCase()} repositories in Explorer`}><span>{String(index + 1).padStart(2, '0')} / {readinessLabel(readiness)}</span><strong>{counts[readiness]}</strong><small>{readinessDescription(readiness)} <b>→</b></small></button>)}
<button type="button" className="readiness-rail-segment all" onClick={onOpenExplorer}><span>04 / Catalog</span><strong>{portfolio.length}</strong><small>Open repository explorer <b>→</b></small></button>
</nav>
<div className="cockpit-focus-grid">
<section className="dashboard-section attention-section" aria-labelledby="attention-title">
<div className="section-heading"><div><span className="eyebrow">Triage, in priority order</span><h2 id="attention-title">Why attention is needed</h2></div><button className="text-button" type="button" onClick={() => onExploreReadiness('blocked')}>Open blocked lane</button></div>
{attention.length ? <div className="attention-list">{attention.map((repository, index) => <button type="button" className="attention-row" key={repository.id} onClick={() => onOpenRepository(repository)}><span className="attention-rank">{String(index + 1).padStart(2, '0')}</span><RepositoryVisual repository={repository} compact /><span className="attention-copy"><strong>{repository.full_name}</strong><small>{attentionReason(repository)}</small><span className="attention-context"><i>{repository.last_scan_primary_signal?.category ?? readinessLabel(repositoryReadiness(repository))}</i><em>{changeContext(repository)}</em></span></span><span className="attention-score"><b>{repository.last_scan_health ?? '—'}</b><small>{relativeTime(repository.last_scan_at)}</small></span></button>)}</div> : <p className="empty-inline">No repository needs attention right now.</p>}
</section>
<section className="dashboard-section next-move" aria-labelledby="next-move-title">
<span className="eyebrow">Recommended next move</span><h2 id="next-move-title">{nextRepository ? nextRepository.full_name : 'Portfolio is clear'}</h2>
{nextRepository ? <button type="button" className="next-move-action" onClick={() => onOpenRepository(nextRepository)}><RepositoryVisual repository={nextRepository} /><span><strong>{nextRepository.last_scan_health ?? '—'} health</strong><small>{attentionReason(nextRepository)}</small><em className="next-move-context"><i>{nextRepository.last_scan_primary_signal?.category ?? 'Portfolio signal'}</i>{remediationPath(nextRepository)}</em><b>Open workspace →</b></span></button> : <p className="muted">Keep the next immutable scan as the portfolio baseline.</p>}
</section>
</div>
<RemediationCenter repositories={portfolio} onOpenRepository={onOpenRepository} />
<PortfolioAtlas repositories={portfolio} onOpenRepository={onOpenRepository} />
<div className="dashboard-bottom-grid">
<section className="dashboard-section activity-section" aria-labelledby="recent-title"><div className="section-heading"><div><span className="eyebrow">Change feed</span><h2 id="recent-title">Recently scanned</h2></div><button className="text-button" type="button" onClick={onOpenExplorer}>Open explorer</button></div><div className="activity-rail">{recent.map((repository) => <button type="button" key={repository.id} className="activity-rail-item" onClick={() => onOpenRepository(repository)}><span className={`status-orb ${repositoryReadiness(repository)}`} /><span><strong>{repository.full_name}</strong><small>{repository.state === 'scanning' ? 'Analysis in progress' : `Retained scan ${relativeTime(repository.last_scan_at)}`} · {repository.default_branch}</small></span><b>{repository.last_scan_health ?? '—'}</b></button>)}</div></section>
<section className="dashboard-section pinned-section" aria-labelledby="pinned-title"><div className="section-heading"><div><span className="eyebrow">Your focus</span><h2 id="pinned-title">Pinned repositories</h2></div></div>{pinned.length ? <div className="pinned-grid">{pinned.map((repository) => <button key={repository.id} type="button" className="pinned-tile" onClick={() => onOpenRepository(repository)}><RepositoryVisual repository={repository} compact /><span><strong>{repository.name}</strong><small>{repository.last_scan_health ?? '—'} health · {readinessLabel(repositoryReadiness(repository))}</small></span><b>→</b></button>)}</div> : <div className="pin-empty"><span>Pin repositories in Explorer to keep active work in this command deck.</span><button className="text-button" type="button" onClick={onOpenExplorer}>Browse repositories</button></div>}</section>
</div>
</section>
)
}
@@ -0,0 +1,12 @@
import type { ScanReport } from '../api'
export function DependenciesView({ report }: { report: ScanReport }) {
const dependencySignals = [
['Declared dependencies', report.dependencies.declared_dependencies],
['Dependency manifests', report.dependencies.manifests.length],
['Lockfiles', report.dependencies.lockfiles.length],
['Unbounded versions', report.dependencies.unbounded_dependencies],
['Unpinned versions', report.dependencies.unpinned_dependencies],
]
return <div className="view-stack"><section className="section-card dependency-hero"><span className="eyebrow">Dependency intelligence</span><h2>{report.dependencies.declared_dependencies} declared dependencies</h2><p className="muted">Manifest and lockfile signals from the selected immutable repository revision.</p><div className="dependency-metrics">{dependencySignals.map(([label, value]) => <div key={label as string}><strong>{value}</strong><span>{label}</span></div>)}</div></section><section className="section-card"><div className="section-heading"><div><span className="eyebrow">Inventory</span><h3>Manifest coverage</h3></div></div>{report.dependencies.manifests.length ? <div className="manifest-list">{report.dependencies.manifests.map((manifest) => <code key={manifest}>{manifest}</code>)}</div> : <div className="empty-inline">No supported dependency manifest was detected in this snapshot.</div>}<div className="section-heading dependency-lock-heading"><div><span className="eyebrow">Reproducibility</span><h3>Lockfiles</h3></div></div>{report.dependencies.lockfiles.length ? <div className="manifest-list">{report.dependencies.lockfiles.map((lockfile) => <code key={lockfile}>{lockfile}</code>)}</div> : <div className="empty-inline">No lockfile was detected for this snapshot.</div>}</section></div>
}
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useMemo, useState } from 'react'
import type { Finding, ScanReport, Severity } from '../api'
function findingLocation(finding: Finding) {
if (!finding.path) return 'Repository-level'
return `${finding.path}${finding.line ? `:${finding.line}` : ''}`
}
export function FindingsView({ report }: { report: ScanReport }) {
const [severity, setSeverity] = useState<'all' | Severity>('all')
const [category, setCategory] = useState('all')
const [query, setQuery] = useState('')
const categories = useMemo(() => [...new Set(report.findings.map((item) => item.category))].sort(), [report.findings])
const suppressedCount = report.findings.filter((item) => item.suppressed).length
useEffect(() => {
if (category !== 'all' && !categories.includes(category)) setCategory('all')
}, [category, categories])
const findings = useMemo(() => {
const needle = query.trim().toLowerCase()
return report.findings.filter((item) => {
if (severity !== 'all' && item.severity !== severity) return false
if (category !== 'all' && item.category !== category) return false
if (!needle) return true
return `${item.message} ${item.path ?? ''} ${item.code}`.toLowerCase().includes(needle)
})
}, [report.findings, severity, category, query])
return (
<section className="section-card">
<div className="section-heading findings-heading">
<div><span className="eyebrow">Findings</span><h3>Explain every deduction</h3></div>
<span className="section-note">Showing {findings.length} of {report.findings.length}. {suppressedCount ? `${suppressedCount} accepted-risk finding(s) suppressed. ` : ''}Potential secret values are intentionally redacted.</span>
</div>
<div className="finding-filters">
<input aria-label="Filter findings" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Filter message, code or path" />
<select aria-label="Filter findings by severity" value={severity} onChange={(event) => setSeverity(event.target.value as 'all' | Severity)}>
<option value="all">All severities</option>
<option value="blocker">Blockers</option>
<option value="warning">Warnings</option>
<option value="info">Info</option>
</select>
<select aria-label="Filter findings by category" value={category} onChange={(event) => setCategory(event.target.value)}>
<option value="all">All categories</option>
{categories.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</div>
<div className="findings-list">
{findings.map((finding, index) => (
<details className={`finding ${finding.suppressed ? 'suppressed' : ''}`} key={`${finding.code}-${finding.path}-${finding.line}-${index}`}>
<summary className="finding-summary">
<span className={`severity severity-${finding.severity}`}>{finding.suppressed ? 'accepted' : finding.severity}</span>
<span className="finding-copy">
<strong>{finding.message}</strong>
<span>{finding.category} · {finding.code}{finding.path ? ` · ${findingLocation(finding)}` : ''}</span>
</span>
<span className="deduction">{finding.deduction ? `−${finding.deduction}` : 'info'}</span>
</summary>
<div className="finding-details">
<dl className="finding-detail-grid">
<div><dt>Severity</dt><dd>{finding.suppressed ? 'Accepted risk' : finding.severity}</dd></div>
<div><dt>Rule</dt><dd><code>{finding.code}</code></dd></div>
<div><dt>Category</dt><dd>{finding.category}</dd></div>
<div><dt>Location</dt><dd><code>{findingLocation(finding)}</code></dd></div>
<div><dt>Deduction</dt><dd>{finding.deduction ? `−${finding.deduction} points` : 'Informational'}</dd></div>
<div><dt>Status</dt><dd>{finding.suppressed ? 'Suppressed' : 'Active'}</dd></div>
</dl>
</div>
</details>
))}
{!findings.length && <div className="empty-inline">No findings match the current filters.</div>}
</div>
</section>
)
}
+72
View File
@@ -0,0 +1,72 @@
import type { ScanComparison, ScanSummary, TrendPoint } from '../api'
export function HistoryView({ trend, comparison, scanSummaries = [], currentScanId, onSelectScan }: { trend: TrendPoint[]; comparison: ScanComparison | null; scanSummaries?: ScanSummary[]; currentScanId?: string; onSelectScan?: (summary: ScanSummary) => void }) {
return (
<div className="view-stack">
<section className="section-card">
<div className="section-heading">
<div><span className="eyebrow">History</span><h3>Repository health over time</h3></div>
<span className="section-note">Each bar is one persisted scan snapshot.</span>
</div>
{trend.length ? (
<div className="trend-chart" aria-label="Repository health trend">
{trend.map((point) => (
<div className="trend-column" key={point.scan_id} title={`${new Date(point.scanned_at).toLocaleString()} · ${point.health_score}/100`}>
<span>{point.health_score}</span>
<div className="trend-track"><i style={{ height: `${Math.max(4, point.health_score)}%` }} /></div>
<small>{new Date(point.scanned_at).toLocaleDateString()}</small>
</div>
))}
</div>
) : <div className="empty-inline">Run multiple scans of the same repository to build a trend.</div>}
</section>
<section className="comparison-grid">
<article className="section-card">
<span className="eyebrow">Since previous scan</span>
<h3>{comparison?.health_score_delta == null ? 'No baseline yet' : `${comparison.health_score_delta >= 0 ? '+' : ''}${comparison.health_score_delta} health points`}</h3>
{comparison?.previous_scan_id ? (
<div className="delta-grid">
{Object.entries(comparison.category_score_deltas).map(([name, value]) => (
<div key={name}><span>{name}</span><strong className={value > 0 ? 'positive' : value < 0 ? 'negative' : ''}>{value > 0 ? '+' : ''}{value}</strong></div>
))}
</div>
) : <p className="muted">The first scan establishes the baseline for comparisons.</p>}
</article>
<article className="section-card">
<span className="eyebrow">Finding delta</span>
<h3>{comparison?.added_findings.length ?? 0} added · {comparison?.resolved_findings.length ?? 0} resolved</h3>
<div className="delta-findings">
{comparison?.resolved_findings.slice(0, 6).map((item, index) => (
<div className="resolved" key={`resolved-${item.code}-${index}`}>Resolved · {item.message}</div>
))}
{comparison?.added_findings.slice(0, 6).map((item, index) => (
<div className="added" key={`added-${item.code}-${index}`}>Added · {item.message}</div>
))}
{comparison?.previous_scan_id && !comparison.added_findings.length && !comparison.resolved_findings.length && (
<div className="empty-inline">No finding-level changes since the previous scan.</div>
)}
</div>
</article>
</section>
<section className="section-card scan-run-card">
<div className="section-heading">
<div><span className="eyebrow">Persisted snapshots</span><h3>Scan runs</h3></div>
<span className="section-note">Select a revision to inspect its report.</span>
</div>
{scanSummaries.length ? (
<div className="scan-run-list">
{scanSummaries.map((item) => (
<button type="button" className={`scan-run-row ${item.id === currentScanId ? 'selected' : ''}`} key={item.id} onClick={() => onSelectScan?.(item)} disabled={!onSelectScan}>
<span><strong>{item.actual_revision ? item.actual_revision.slice(0, 10) : 'not acquired'}</strong><small>{new Date(item.scanned_at).toLocaleString()} · {item.trigger} · {item.findings} finding(s)</small></span>
<strong>{item.health_score}</strong>
</button>
))}
</div>
) : <div className="empty-inline">Analyze this repository to start its history.</div>}
</section>
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
import type { ScanReport } from '../api'
export function HotspotsView({ report }: { report: ScanReport }) {
return (
<section className="section-card">
<div className="section-heading">
<div>
<span className="eyebrow">Engineering hotspots</span>
<h3>Where maintenance risk concentrates</h3>
</div>
<span className="section-note">Score combines size, debt markers, recent Git churn and local coupling.</span>
</div>
<div className="hotspot-list">
{report.hotspots.map((hotspot, index) => (
<article className="hotspot-row" key={hotspot.path}>
<div className="hotspot-rank">{index + 1}</div>
<div className="hotspot-main">
<div className="hotspot-title"><strong>{hotspot.path}</strong><span>{hotspot.language}</span></div>
<div className="hotspot-reasons">
{hotspot.reasons.length ? hotspot.reasons.map((reason) => <span key={reason}>{reason}</span>) : <span>No dominant risk signal</span>}
</div>
<div className="bar hotspot-bar"><i style={{ width: `${hotspot.score}%` }} /></div>
</div>
<div className="hotspot-metrics">
<strong>{hotspot.score}</strong>
<small>{hotspot.lines_of_code} LOC</small>
<small>{hotspot.churn} churn</small>
<small>{hotspot.inbound + hotspot.outbound} edges</small>
</div>
</article>
))}
{!report.hotspots.length && <div className="empty-inline">No supported source files were available for hotspot scoring.</div>}
</div>
</section>
)
}
+71
View File
@@ -0,0 +1,71 @@
import type { ScanComparison, ScanReport } from '../api'
export function OverviewView({ report, comparison, onShowFindings }: { report: ScanReport; comparison: ScanComparison | null; onShowFindings?: () => void }) {
const debt = report.metrics.todo + report.metrics.fixme + report.metrics.hack + report.metrics.xxx
const activeFindings = report.findings.filter((item) => !item.suppressed)
const blockers = activeFindings.filter((item) => item.severity === 'blocker').length
const warnings = activeFindings.filter((item) => item.severity === 'warning').length
const languageTotal = Object.values(report.languages).reduce((sum, value) => sum + value, 0)
const primaryFinding = activeFindings.find((item) => item.severity === 'blocker') ?? activeFindings.find((item) => item.severity === 'warning')
const healthDelta = comparison?.health_score_delta
const readinessState = blockers ? 'blocked' : warnings ? 'warnings' : 'ready'
const readinessCopy = blockers ? `${blockers} blocker(s) need attention before release.` : warnings ? `${warnings} warning(s) are worth reviewing before release.` : 'No active blockers or warnings in this snapshot.'
const lowSignal = report.metrics.source_files === 0 && report.metrics.lines_of_code === 0 && report.metrics.test_files === 0 && report.dependencies.declared_dependencies === 0
const topFindings = activeFindings.slice().sort((left, right) => (left.severity === 'blocker' ? -1 : 0) - (right.severity === 'blocker' ? -1 : 0) || right.deduction - left.deduction).slice(0, 4)
return (
<div className="view-stack">
{lowSignal && <div className="notice-banner low-signal-notice" role="status">This repository contains little or no analyzable source code. Zero-value metrics are kept as context while readiness and the next scan remain the useful signals.</div>}
<section className={`metric-grid ${lowSignal ? 'low-signal' : ''}`} aria-label="Repository metrics">
<article><span>Source files</span><strong>{report.metrics.source_files.toLocaleString()}</strong><small>Analyzed source footprint</small></article>
<article><span>Lines of code</span><strong>{report.metrics.lines_of_code.toLocaleString()}</strong><small>Across supported files</small></article>
<article><span>Tests</span><strong>{report.metrics.test_files.toLocaleString()}</strong><small>{report.tests.frameworks.length ? report.tests.frameworks.join(', ') : 'No test framework detected'}</small></article>
<article><span>Dependencies</span><strong>{report.dependencies.declared_dependencies.toLocaleString()}</strong><small>{report.dependencies.manifests.length ? `${report.dependencies.manifests.length} manifest(s)` : 'No manifests detected'}</small></article>
<article><span>Debt markers</span><strong>{debt.toLocaleString()}</strong><small>{debt ? 'TODO, FIXME and related markers' : 'No markers detected'}</small></article>
<article><span>Findings</span><strong>{activeFindings.length.toLocaleString()}</strong><small>{report.findings.length - activeFindings.length} suppressed</small></article>
</section>
<section className="insight-grid" aria-label="Repository insights">
<article className={`insight-card insight-${readinessState}`}><span className="eyebrow">Readiness signal</span><strong>{report.release_readiness}</strong><p>{readinessCopy}</p></article>
<article className="insight-card"><span className="eyebrow">What to fix first</span><strong>{primaryFinding ? primaryFinding.message : 'No active finding requires attention'}</strong><p>{primaryFinding ? `${primaryFinding.category} · ${primaryFinding.code}${primaryFinding.path ? ` · ${primaryFinding.path}` : ''}` : 'Keep the next scan as your baseline.'}</p></article>
<article className="insight-card"><span className="eyebrow">Changed since previous</span><strong>{healthDelta == null ? 'Baseline snapshot' : `${healthDelta >= 0 ? '+' : ''}${healthDelta} health points`}</strong><p>{healthDelta == null ? 'A later scan will unlock comparison context.' : `${comparison?.added_findings.length ?? 0} added · ${comparison?.resolved_findings.length ?? 0} resolved finding(s).`}</p></article>
</section>
<section className="overview-intelligence">
<article className="section-card change-brief">
<div className="section-heading"><div><span className="eyebrow">Since previous revision</span><h3>What changed?</h3></div>{healthDelta != null && <strong className={healthDelta >= 0 ? 'positive' : 'negative'}>{healthDelta >= 0 ? '+' : ''}{healthDelta} health</strong>}</div>
{comparison?.previous_scan_id ? <div className="change-grid"><span><b>{comparison.health_score_delta == null ? '—' : `${comparison.health_score_delta >= 0 ? '+' : ''}${comparison.health_score_delta}`}</b>health</span><span><b>{comparison.added_findings.length ? `+${comparison.added_findings.length}` : '0'}</b>findings added</span><span><b>{comparison.resolved_findings.length ? `−${comparison.resolved_findings.length}` : '0'}</b>findings resolved</span><span><b>{comparison.metric_deltas.lines_of_code == null ? '—' : `${comparison.metric_deltas.lines_of_code >= 0 ? '+' : ''}${comparison.metric_deltas.lines_of_code}`}</b>lines of code</span></div> : <p className="muted">This retained snapshot establishes the comparison baseline for the next immutable revision.</p>}
</article>
<article className="section-card top-findings-card">
<div className="section-heading"><div><span className="eyebrow">Priority review</span><h3>Top findings</h3></div>{onShowFindings ? <button className="text-button" type="button" onClick={onShowFindings}>View all {activeFindings.length} findings</button> : <span className="section-note">All {activeFindings.length} findings are available in Findings.</span>}</div>
{topFindings.length ? <div className="top-findings-list">{topFindings.map((finding) => <div key={`${finding.code}-${finding.fingerprint ?? finding.path}`}><span className={`severity severity-${finding.severity}`}>{finding.severity}</span><span><strong>{finding.message}</strong><small>{finding.category}{finding.path ? ` · ${finding.path}` : ''}</small></span><b>−{finding.deduction}</b></div>)}</div> : <div className="empty-inline">No active findings need attention in this snapshot.</div>}
</article>
</section>
<section className="overview-columns">
<article className="section-card">
<div className="section-heading"><div><span className="eyebrow">Health model</span><h3>Category score rows</h3></div>{healthDelta != null && <span className={`delta ${healthDelta >= 0 ? 'positive' : 'negative'}`}>{healthDelta >= 0 ? '+' : ''}{healthDelta} since previous</span>}</div>
<div className="score-grid">
{Object.entries(report.category_scores).map(([name, value]) => (
<article className="score-card" key={name}>
<div><span>{name}</span><strong>{value}</strong></div>
<div className="bar"><i style={{ width: `${value}%` }} /></div>
{comparison?.category_score_deltas[name] != null && comparison.category_score_deltas[name] !== 0 && <small className={comparison.category_score_deltas[name] > 0 ? 'positive' : 'negative'}>{comparison.category_score_deltas[name] > 0 ? '+' : ''}{comparison.category_score_deltas[name]} since previous</small>}
</article>
))}
</div>
</article>
<article className="section-card release-gate-card"><span className="eyebrow">Release gate</span><h3>{report.release_gate?.status ?? report.release_readiness}</h3><div className="gate-list">{report.release_gate?.checks.length ? report.release_gate.checks.map((check) => <div className="gate-check" key={check.key}><span className={`gate-state gate-${check.state}`}>{check.state}</span><div><strong>{check.label}</strong><small>{check.detail}</small></div></div>) : <div className="empty-inline">No release-gate checks were returned for this snapshot.</div>}</div></article>
</section>
<section className="overview-columns signals-columns">
<article className="section-card"><span className="eyebrow">Languages</span><h3>Code footprint</h3><div className="language-list">{Object.entries(report.languages).slice(0, 12).map(([language, lines]) => <div className="language" key={language}><div><span>{language}</span><strong>{lines.toLocaleString()} LOC</strong></div><div className="bar"><i style={{ width: `${languageTotal ? (lines / languageTotal) * 100 : 0}%` }} /></div></div>)}{!Object.keys(report.languages).length && <div className="empty-inline">No language signals were detected.</div>}</div></article>
<div className="signal-stack">
<article className="section-card signal-card"><span className="eyebrow">Testing & CI</span><h3>{report.tests.frameworks.length ? report.tests.frameworks.join(', ') : 'Testing signals unavailable'}</h3><dl className="compact-dl"><div><dt>Test/source ratio</dt><dd>{report.tests.test_source_ratio != null ? `${report.tests.test_source_ratio}%` : 'Not measured'}</dd></div><div><dt>CI workflows</dt><dd>{report.tests.ci_workflows.length || 'None detected'}</dd></div><div><dt>Coverage artifacts</dt><dd>{report.tests.coverage_artifacts.length || 'None detected'}</dd></div></dl></article>
<article className="section-card signal-card"><span className="eyebrow">Quick signals</span><h3>Repository context</h3><dl className="compact-dl"><div><dt>Git commits</dt><dd>{report.git.commit_count ?? 'Unavailable'}</dd></div><div><dt>Contributors</dt><dd>{report.git.contributor_count ?? 'Unavailable'}</dd></div><div><dt>Manifests</dt><dd>{report.dependencies.manifests.length || 'None detected'}</dd></div><div><dt>Lockfiles</dt><dd>{report.dependencies.lockfiles.length || 'None detected'}</dd></div><div><dt>Graph edges</dt><dd>{report.architecture.total_edges || report.architecture.edges.length || 'None detected'}</dd></div></dl></article>
</div>
</section>
</div>
)
}
@@ -0,0 +1,25 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PortfolioAtlas } from './PortfolioAtlas'
import { testRepository } from '../test-fixtures'
afterEach(() => cleanup())
describe('PortfolioAtlas', () => {
it('keeps every repository operable while using the bounded compact detail mode at portfolio scale', () => {
const repositories = Array.from({ length: 500 }, (_, index) => testRepository(`gitea:${index}`, `Jens/Repo-${index}`, {
last_scan_health: 40 + (index % 60),
last_scan_readiness: index % 3 === 0 ? 'BLOCKED' : index % 3 === 1 ? 'READY WITH WARNINGS' : 'READY',
}))
const onOpenRepository = vi.fn()
const { container } = render(<PortfolioAtlas repositories={repositories} onOpenRepository={onOpenRepository} />)
expect(container.querySelector('.portfolio-atlas')?.getAttribute('data-density')).toBe('compact')
expect(screen.getAllByRole('button', { name: /Open Jens\/Repo-/ })).toHaveLength(500)
fireEvent.click(screen.getByRole('button', { name: /Open Jens\/Repo-0/ }))
expect(onOpenRepository).toHaveBeenCalledWith(repositories[0])
})
})
@@ -0,0 +1,75 @@
import type { CSSProperties } from 'react'
import type { RepositorySummary } from '../api'
import { readinessLabel, repositoryReadiness, type Readiness } from '../catalog'
type PortfolioAtlasProps = {
repositories: RepositorySummary[]
onOpenRepository: (repository: RepositorySummary) => void
}
const laneOrder: Readiness[] = ['blocked', 'warnings', 'ready']
const laneCopy: Record<Readiness, { title: string; description: string }> = {
blocked: { title: 'Intervention lane', description: 'Release-blocking repository signals' },
warnings: { title: 'Review lane', description: 'Warnings worth resolving before the next release' },
ready: { title: 'Clear lane', description: 'Repositories with a clear release signal' },
}
function scanRhythm(repository: RepositorySummary) {
if (repository.state === 'scanning') return 'scan in progress'
if (repository.stale) return 'stale scan'
if (!repository.last_scan_at) return 'not scanned'
const difference = Date.now() - Date.parse(repository.last_scan_at)
if (Number.isNaN(difference)) return 'scan time unknown'
if (difference < 3_600_000) return 'scanned this hour'
if (difference < 86_400_000) return `scanned ${Math.floor(difference / 3_600_000)}h ago`
return `scanned ${Math.floor(difference / 86_400_000)}d ago`
}
function signalStrength(repository: RepositorySummary) {
return Math.max(16, Math.min(100, repository.last_scan_health ?? 36))
}
export function PortfolioAtlas({ repositories, onOpenRepository }: PortfolioAtlasProps) {
const ordered = [...repositories].sort((left, right) => {
const lane = laneOrder.indexOf(repositoryReadiness(left)) - laneOrder.indexOf(repositoryReadiness(right))
return lane || (left.last_scan_health ?? -1) - (right.last_scan_health ?? -1) || left.full_name.localeCompare(right.full_name)
})
const density = ordered.length > 160 ? 'compact' : ordered.length > 96 ? 'dense' : 'comfortable'
return (
<section className="portfolio-atlas dashboard-section" aria-labelledby="atlas-title" data-density={density}>
<div className="section-heading atlas-heading">
<div><span className="eyebrow">Portfolio topology</span><h2 id="atlas-title">Repository signal landscape</h2></div>
<span className="section-note">{ordered.length} repositories · every signal opens its workspace</span>
</div>
<div className="atlas-field">
<svg className="atlas-contours" viewBox="0 0 1200 330" aria-hidden="true" preserveAspectRatio="none"><path d="M-20 270C118 216 190 318 335 246S525 172 656 230s250 38 390-55 154 3 210-38" /><path d="M-30 178c127-71 214 32 350-44s283 9 425-53 248 24 478-72" /><path d="M-10 90c156 27 230-67 390-8s209-18 362 24 252-56 475-13" /></svg>
<div className="atlas-bands">
{laneOrder.map((lane, laneIndex) => {
const laneRepositories = ordered.filter((repository) => repositoryReadiness(repository) === lane)
const copy = laneCopy[lane]
return <section className={`atlas-band ${lane}`} key={lane} aria-labelledby={`atlas-${lane}-title`} data-lane={lane}>
<header><i className="atlas-lane-beacon" aria-hidden="true" /><span>{String(laneIndex + 1).padStart(2, '0')} / {copy.title}</span><strong id={`atlas-${lane}-title`}>{laneRepositories.length}</strong><small>{copy.description}</small></header>
<div className="atlas-river"><ol className="atlas-tiles" aria-label={`${readinessLabel(lane)} repositories`}>
{laneRepositories.map((repository, index) => {
const health = repository.last_scan_health ?? 0
const style = { '--signal-strength': `${signalStrength(repository)}%`, '--signal-index': index, '--signal-phase': index % 7 } as CSSProperties
const primary = repository.last_scan_primary_signal
const signalDetail = primary ? `${primary.count} ${primary.category.toLowerCase()} signal${primary.count === 1 ? '' : 's'}` : 'no primary finding retained'
return <li key={repository.id}><button type="button" className={`atlas-repository ${repository.stale ? 'stale' : ''} ${repository.state === 'scanning' ? 'scanning' : ''}`} onClick={() => onOpenRepository(repository)} style={style} aria-label={`Open ${repository.full_name}: ${health || 'not scanned'} health, ${readinessLabel(lane)}, ${signalDetail}, ${scanRhythm(repository)}`}>
<span className="atlas-repository-orbit" aria-hidden="true" />
<span className="atlas-repository-name">{repository.name.slice(0, 3).toUpperCase()}</span>
<span className="atlas-repository-health">{health || '—'}</span>
<i aria-hidden="true" />
</button></li>
})}
</ol></div>
</section>
})}
</div>
</div>
<p className="atlas-legend"><span><i className="blocked" />Health bar height follows the retained score.</span><span><i className="stale" />A dashed frame means the scan needs refreshing.</span><span><i className="scanning" />A live ring means analysis is in progress.</span></p>
</section>
)
}
@@ -0,0 +1,49 @@
import type { RepositorySummary } from '../api'
import { repositoryReadiness } from '../catalog'
type RemediationCenterProps = {
repositories: RepositorySummary[]
onOpenRepository: (repository: RepositorySummary) => void
}
export function RemediationCenter({ repositories, onOpenRepository }: RemediationCenterProps) {
const candidates = [...repositories]
.filter((repository) => !repository.archived && repository.last_scan_primary_signal && repositoryReadiness(repository) !== 'ready')
.sort((left, right) => (right.last_scan_priority ?? 0) - (left.last_scan_priority ?? 0) || left.full_name.localeCompare(right.full_name))
.slice(0, 5)
if (!candidates.length) return null
return (
<section className="dashboard-section remediation-center" aria-labelledby="remediation-center-title">
<div className="section-heading">
<div>
<span className="eyebrow">Backend-ranked candidates</span>
<h2 id="remediation-center-title">Remediation Center</h2>
</div>
<span className="remediation-live">retained evidence</span>
</div>
<ol className="remediation-list">
{candidates.map((repository) => {
const signal = repository.last_scan_primary_signal
const guidance = signal?.guidance
if (!signal) return null
return (
<li key={repository.id}>
<button type="button" className="remediation-row" onClick={() => onOpenRepository(repository)}>
<span className="remediation-priority" aria-label={`Priority ${repository.last_scan_priority ?? 0}`}>{repository.last_scan_priority ?? 0}</span>
<span className="remediation-copy">
<strong>{repository.full_name}</strong>
<small>{guidance?.remediation_class ?? signal.category} · {guidance?.confidence ?? 'review'} confidence</small>
<em>{guidance?.why ?? signal.message}</em>
<b>{guidance?.recommended_action ?? 'Open the retained evidence and select the smallest safe next action.'}</b>
</span>
<span className="remediation-arrow" aria-hidden="true">→</span>
</button>
</li>
)
})}
</ol>
</section>
)
}
@@ -0,0 +1,20 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { RepositoryVisual } from './RepositoryVisual'
import { testRepository } from '../test-fixtures'
afterEach(() => cleanup())
describe('RepositoryVisual', () => {
it('renders a stable, labelled fallback fingerprint from repository data', () => {
const repository = testRepository('gitea:23', 'Jens/SignalMap', { last_scan_revision: 'a-repeatable-revision' })
const { container } = render(<><RepositoryVisual repository={repository} /><RepositoryVisual repository={repository} /></>)
const visuals = screen.getAllByRole('img', { name: /SignalMap repository fingerprint/ })
expect(visuals).toHaveLength(2)
expect(container.querySelectorAll('.repository-visual svg')[0].innerHTML).toBe(container.querySelectorAll('.repository-visual svg')[1].innerHTML)
})
})
@@ -0,0 +1,100 @@
import type { RepositorySummary, ScanReport } from '../api'
import { readinessLabel, repositoryReadiness } from '../catalog'
type RepositoryVisualProps = {
repository: RepositorySummary
report?: ScanReport | null
compact?: boolean
className?: string
}
type VisualNode = { x: number; y: number; radius: number; hot: boolean; weight: number }
function hash(seed: string) {
let result = 2166136261
for (let index = 0; index < seed.length; index += 1) {
result ^= seed.charCodeAt(index)
result = Math.imul(result, 16777619)
}
return result >>> 0
}
function random(seed: number) {
let value = seed >>> 0
return () => {
value += 0x6d2b79f5
let output = value
output = Math.imul(output ^ (output >>> 15), output | 1)
output ^= output + Math.imul(output ^ (output >>> 7), output | 61)
return ((output ^ (output >>> 14)) >>> 0) / 4294967296
}
}
function nodesFor(repository: RepositorySummary, report?: ScanReport | null): VisualNode[] {
const source = report?.architecture.nodes
if (source?.length) {
return [...source]
.sort((left, right) => right.hotspot_score - left.hotspot_score || (right.inbound + right.outbound) - (left.inbound + left.outbound))
.slice(0, 14)
.map((node, index, nodes) => {
const weight = node.inbound + node.outbound + 1
const angle = (index / nodes.length) * Math.PI * 2 - Math.PI / 2
const ring = 24 + Math.min(17, weight * 1.7)
return { x: 80 + Math.cos(angle) * ring, y: 48 + Math.sin(angle) * ring * .74, radius: 2.5 + Math.min(5.4, Math.sqrt(weight) * .95), hot: node.hotspot_score >= 55, weight }
})
}
const take = random(hash(`${repository.id}:${repository.last_scan_revision ?? repository.latest_revision ?? ''}`))
const health = repository.last_scan_health ?? 54
const count = 7 + Math.floor(take() * 6)
return Array.from({ length: count }, (_, index) => {
const angle = (index / count) * Math.PI * 2 + take() * .44
const ring = 20 + take() * 28
return { x: 80 + Math.cos(angle) * ring, y: 48 + Math.sin(angle) * ring * .72, radius: 2.4 + take() * 3.8, hot: index === 0 || (health < 70 && index % 3 === 0), weight: 1 + Math.round(take() * 6) }
})
}
function primaryLanguage(report?: ScanReport | null) {
return Object.entries(report?.languages ?? {}).sort((left, right) => right[1] - left[1])[0]?.[0]
}
/** A deterministic project illustration driven by real architecture when available. */
export function RepositoryVisual({ repository, report, compact = false, className = '' }: RepositoryVisualProps) {
const nodes = nodesFor(repository, report)
const readiness = repositoryReadiness(repository)
const architectureBacked = Boolean(report?.architecture.nodes.length)
const seed = hash(repository.id).toString(36)
const health = repository.last_scan_health ?? 0
const language = primaryLanguage(report)
const healthLine = 65 - Math.max(0, Math.min(100, health)) * .3
const label = `${repository.full_name} ${architectureBacked ? 'architecture-backed' : 'repository'} fingerprint, ${readinessLabel(readiness)} readiness${language ? `, ${language} primary language` : ''}`
const core = nodes[0] ?? { x: 80, y: 48, radius: 4, hot: false, weight: 1 }
const nodeLinks = nodes.slice(1).map((node, index) => ({ from: nodes[index % Math.max(1, nodes.length - 1)] ?? core, to: node }))
return (
<div className={`repository-visual ${compact ? 'compact' : ''} state-${readiness} ${architectureBacked ? 'architecture-backed' : 'catalog-backed'} ${className}`} aria-label={label} role="img">
<svg viewBox="0 0 160 96" aria-hidden="true" focusable="false">
<defs>
<linearGradient id={`visual-wash-${seed}`} x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="currentColor" stopOpacity=".3" /><stop offset="1" stopColor="currentColor" stopOpacity="0" /></linearGradient>
<linearGradient id={`visual-flow-${seed}`} x1="0" y1="0" x2="1" y2="0"><stop offset="0" stopColor="currentColor" stopOpacity="0" /><stop offset=".5" stopColor="currentColor" stopOpacity=".78" /><stop offset="1" stopColor="currentColor" stopOpacity=".08" /></linearGradient>
</defs>
<rect className="visual-wash" x="1" y="1" width="158" height="94" rx="13" fill={`url(#visual-wash-${seed})`} />
<path className="visual-gridline visual-gridline-one" d="M10 19H150M10 48H150M10 77H150" fill="none" stroke="currentColor" strokeOpacity=".07" strokeWidth=".7" />
<ellipse className="visual-orbit orbit-one" cx={core.x} cy={core.y} rx="26" ry="14" fill="none" stroke="currentColor" strokeOpacity=".12" strokeWidth=".9" strokeDasharray="2 5" />
<ellipse className="visual-orbit orbit-two" cx={core.x} cy={core.y} rx="39" ry="24" fill="none" stroke="currentColor" strokeOpacity=".08" strokeWidth=".8" />
<path className="visual-contour contour-one" d="M6 71C34 46 42 83 73 60S119 31 154 46" fill="none" stroke="currentColor" strokeOpacity=".17" strokeWidth="1" />
<path className="visual-contour contour-two" d="M-3 30C34 12 52 42 86 24S132 18 164 34" fill="none" stroke="currentColor" strokeOpacity=".1" strokeWidth="1" strokeDasharray="3 5" />
{nodeLinks.map(({ from, to }, index) => { const bend = index % 2 ? -7 : 7; return <path className="visual-link" key={`${from.x}-${to.x}-${index}`} d={`M${from.x} ${from.y} Q${(from.x + to.x) / 2 + bend} ${(from.y + to.y) / 2 - bend} ${to.x} ${to.y}`} fill="none" stroke={`url(#visual-flow-${seed})`} strokeWidth={Math.min(1.8, .65 + to.weight * .1)} /> })}
<path className="visual-health-line" d={`M7 ${healthLine + 12} C34 ${healthLine - 8}, 50 ${healthLine + 8}, 78 ${healthLine} S124 ${healthLine - 12}, 153 ${healthLine - 4}`} fill="none" stroke="currentColor" strokeOpacity=".82" strokeWidth="1.35" />
{nodes.map((node, index) => <g className={`visual-node ${node.hot ? 'hot' : ''}`} key={`${node.x}-${node.y}-${index}`}><circle cx={node.x} cy={node.y} r={node.radius + (node.hot ? 2.3 : 1.2)} fill="currentColor" fillOpacity={node.hot ? '.12' : '.07'} /><circle cx={node.x} cy={node.y} r={node.radius} fill="currentColor" fillOpacity={node.hot ? '.94' : '.63'} /></g>)}
<circle className="visual-core-halo" cx={core.x} cy={core.y} r="10.5" fill="none" stroke="currentColor" strokeOpacity=".25" strokeWidth="1" />
<circle className="visual-core" cx={core.x} cy={core.y} r="5.2" fill="var(--surface-strong)" stroke="currentColor" strokeWidth="1.6" />
<circle className="visual-core-beacon" cx={core.x} cy={core.y} r="2" fill="currentColor" />
<path d={`M${core.x - 3.4} ${core.y}h1.8l1.45-3.1 2.05 6.2 1.5-3.1h3.4`} fill="none" stroke="currentColor" strokeWidth="1.45" strokeLinecap="round" strokeLinejoin="round" />
<rect className="visual-state-bar" x="8" y="84" width={`${Math.max(12, health * 1.42)}`} height="2" rx="1" fill="currentColor" fillOpacity=".85" />
</svg>
{!compact && <span className="visual-caption" aria-hidden="true"><i>{architectureBacked ? 'ARCH' : 'SIGNAL'}</i><b>{language ?? `${health || '—'} health`}</b></span>}
{repository.state === 'scanning' && <span className="visual-scan-pulse" aria-hidden="true" />}
</div>
)
}
+5
View File
@@ -0,0 +1,5 @@
import type { ScanSummary } from '../api'
export function ScanRunsView({ scanSummaries, currentScanId, onSelectScan }: { scanSummaries: ScanSummary[]; currentScanId?: string; onSelectScan: (summary: ScanSummary) => void }) {
return <section className="section-card scan-runs-workspace"><div className="section-heading"><div><span className="eyebrow">Immutable history</span><h2>Scan runs</h2></div><span className="section-note">Select a revision to open the retained report.</span></div>{scanSummaries.length ? <div className="scan-run-list">{scanSummaries.map((item) => <button type="button" className={`scan-run-row ${item.id === currentScanId ? 'selected' : ''}`} key={item.id} onClick={() => onSelectScan(item)}><span><strong>{item.actual_revision ? item.actual_revision.slice(0, 10) : 'not acquired'}</strong><small>{new Date(item.scanned_at).toLocaleString()} · {item.trigger} · {item.findings} finding(s) · {Math.round(item.duration_ms)}ms</small></span><span className={`run-readiness ${item.release_readiness.toLowerCase().includes('block') ? 'blocked' : item.release_readiness.toLowerCase().includes('warning') ? 'warnings' : 'ready'}`}>{item.release_readiness}</span><strong>{item.health_score}</strong></button>)}</div> : <div className="empty-inline">Analyze this repository to create its immutable scan history.</div>}</section>
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { parseRoute, routePath } from './navigation'
describe('client navigation', () => {
it('round-trips repository identities and detail views', () => {
const path = routePath({ page: 'repositories', repositoryId: 'gitea:64', view: 'findings' })
expect(path).toBe('/repositories/gitea%3A64/findings')
expect(parseRoute(path)).toEqual({ page: 'repositories', repositoryId: 'gitea:64', view: 'findings' })
})
it('maps global destinations and safe repository defaults', () => {
expect(parseRoute('/')).toEqual({ page: 'dashboard' })
expect(routePath({ page: 'dashboard' })).toBe('/')
expect(parseRoute('/activity')).toEqual({ page: 'activity' })
expect(parseRoute('/repositories')).toEqual({ page: 'repositories' })
expect(parseRoute('/repositories/gitea%3A64/unknown')).toEqual({ page: 'repositories', repositoryId: 'gitea:64', view: 'overview' })
expect(routePath({ page: 'settings' })).toBe('/settings')
})
})
+32
View File
@@ -0,0 +1,32 @@
export type DetailView = 'overview' | 'architecture' | 'hotspots' | 'dependencies' | 'findings' | 'history' | 'scans'
export type Route = { page: 'dashboard' | 'repositories' | 'activity' | 'queue' | 'settings'; repositoryId?: string; view?: DetailView }
export const DETAIL_VIEWS: DetailView[] = ['overview', 'findings', 'architecture', 'hotspots', 'dependencies', 'history', 'scans']
export function parseRoute(pathname: string): Route {
const segments = pathname.split('/').filter(Boolean)
if (!segments.length || segments[0] === 'dashboard') return { page: 'dashboard' }
if (segments[0] === 'activity') return { page: 'activity' }
if (segments[0] === 'queue') return { page: 'queue' }
if (segments[0] === 'settings') return { page: 'settings' }
if (segments[0] === 'repositories' && segments[1]) {
let repositoryId = segments[1]
try {
repositoryId = decodeURIComponent(repositoryId)
} catch {
// Keep the raw segment in the not-found state if a malformed URL is supplied.
}
const view = DETAIL_VIEWS.includes(segments[2] as DetailView) ? segments[2] as DetailView : 'overview'
return { page: 'repositories', repositoryId, view }
}
return { page: 'repositories' }
}
export function routePath(route: Route): string {
if (route.page === 'dashboard') return '/'
if (route.page === 'activity') return '/activity'
if (route.page === 'queue') return '/queue'
if (route.page === 'settings') return '/settings'
if (route.repositoryId) return `/repositories/${encodeURIComponent(route.repositoryId)}/${route.view ?? 'overview'}`
return '/repositories'
}
+636
View File
@@ -0,0 +1,636 @@
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #e8eff8;
background: #070d16;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* { box-sizing: border-box; }
html { min-width: 320px; background: #070d16; }
body { min-width: 320px; min-height: 100vh; margin: 0; background: radial-gradient(circle at 12% -10%, rgba(42, 76, 112, .24), transparent 34%), #070d16; }
body, button, input, select { font: inherit; }
button, a, input, select { -webkit-tap-highlight-color: transparent; }
a { color: #9fc4e7; text-decoration: none; }
a:hover { color: #d9ecff; }
button { border: 1px solid #315272; border-radius: 8px; padding: 9px 13px; color: #ecf5ff; background: #173553; cursor: pointer; transition: border-color .16s ease, background .16s ease, transform .16s ease; }
button:hover:not(:disabled) { border-color: #6d99c3; background: #1d4164; }
button:active:not(:disabled) { transform: translateY(1px); }
button:disabled { cursor: wait; opacity: .55; }
button.ghost { background: #0c1726; }
button.text-button { border: 0; padding: 5px 0; color: #9cc2e8; background: transparent; }
button.text-button:hover:not(:disabled) { color: #e3f1ff; background: transparent; }
input, select { width: 100%; min-height: 38px; border: 1px solid #294661; border-radius: 8px; padding: 8px 10px; color: #eef6ff; background: #0b1625; outline: none; }
input::placeholder { color: #627891; }
input:focus, select:focus, button:focus-visible, a:focus-visible, summary:focus-visible { border-color: #80b2df; outline: 3px solid rgba(91, 157, 210, .23); outline-offset: 1px; }
code { color: #b8d7f1; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: .88em; }
h1, h2, h3, p { margin-top: 0; }
h1, h2, h3 { color: #eff6ff; }
h1 { margin-bottom: 8px; font-size: clamp(28px, 3vw, 38px); letter-spacing: -.035em; line-height: 1.05; }
h2 { margin-bottom: 8px; font-size: clamp(20px, 2vw, 27px); letter-spacing: -.025em; }
h3 { margin-bottom: 7px; font-size: 17px; letter-spacing: -.015em; }
.muted { max-width: 72ch; margin-bottom: 0; color: #8da2b9; font-size: 13px; line-height: 1.55; overflow-wrap: anywhere; }
.eyebrow { color: #82a5c8; font-size: 10px; font-weight: 650; letter-spacing: .13em; text-transform: uppercase; }
kbd { display: inline-flex; align-items: center; justify-content: center; min-width: 22px; border: 1px solid #35506d; border-bottom-color: #213a53; border-radius: 5px; padding: 2px 5px; color: #9fb8d1; background: #0a1523; font-family: inherit; font-size: 10px; line-height: 1.2; }
.app-shell { min-height: 100vh; }
.app-header { position: sticky; z-index: 20; top: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 26px; width: min(100% - 48px, 1600px); min-height: 68px; margin: 0 auto; border-bottom: 1px solid rgba(49, 76, 104, .56); background: rgba(7, 13, 22, .9); backdrop-filter: blur(18px); }
.brand-lockup { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
.brand-lockup > a { display: flex; align-items: center; gap: 9px; color: #f1f7ff; }
.brand-lockup > a > span:last-child { display: grid; gap: 1px; }
.brand-lockup strong { font-size: 16px; letter-spacing: -.02em; }
.brand-lockup small { color: #718aa4; font-size: 9px; letter-spacing: .08em; text-transform: uppercase; }
.brand-mark { display: grid; width: 29px; height: 29px; place-items: center; border: 1px solid #4d78a1; border-radius: 8px; color: #d9edff; background: linear-gradient(145deg, #1c4b72, #11243a); font-size: 10px; font-weight: 800; letter-spacing: .04em; }
.version-badge { border: 1px solid #294861; border-radius: 99px; padding: 4px 7px; color: #87abc9; background: #0c1b2c; font-size: 9px; font-weight: 700; letter-spacing: .07em; }
.global-nav { display: flex; align-items: center; align-self: stretch; gap: 4px; min-width: 0; }
.global-nav a { display: inline-flex; align-items: center; gap: 7px; align-self: stretch; border-bottom: 2px solid transparent; padding: 0 11px; color: #8ba1b8; font-size: 12px; white-space: nowrap; }
.global-nav a span { color: #6f8aa6; font-size: 10px; }
.global-nav a:hover { color: #e4f2ff; }
.global-nav a.active { border-bottom-color: #62b2d0; color: #f2f7fd; background: linear-gradient(180deg, transparent, rgba(55, 112, 145, .11)); }
.header-actions { display: flex; align-items: center; gap: 7px; }
.shortcut-button { display: inline-flex; align-items: center; gap: 12px; border-color: #294560; padding: 7px 8px 7px 11px; color: #a8bed5; background: #0b1625; font-size: 11px; }
.shortcut-button kbd { margin-left: 2px; }
.icon-button { display: grid; width: 35px; height: 35px; place-items: center; padding: 0; border-color: #294560; color: #a5c2dc; background: #0b1625; font-size: 18px; line-height: 1; }
.sync-icon { font-size: 21px; }
.page-content { width: min(100% - 48px, 1600px); margin: 0 auto; padding: 29px 0 60px; }
.page-heading { display: flex; align-items: end; justify-content: space-between; gap: 28px; margin-bottom: 22px; }
.page-heading h1 { display: flex; align-items: baseline; gap: 10px; }
.page-heading-actions { display: flex; flex-wrap: wrap; justify-content: end; gap: 8px; }
.count-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 28px; height: 24px; border: 1px solid #31516d; border-radius: 6px; color: #9dc0df; background: #0e2238; font-size: 12px; letter-spacing: 0; }
.global-banner { width: min(100% - 48px, 1600px); margin: 17px auto -3px; }
.error-banner, .notice-banner { border-radius: 8px; padding: 10px 12px; font-size: 12px; line-height: 1.45; }
.error-banner { border: 1px solid #713a49; color: #ffdce2; background: #2d141d; }
.notice-banner { border: 1px solid #2d625a; color: #c8f0e1; background: #0e2927; }
.positive { color: #77e0a6 !important; }
.negative { color: #ff9ba8 !important; }
.section-card { min-width: 0; border: 1px solid #21384f; border-radius: 11px; padding: 18px; background: rgba(11, 22, 37, .94); box-shadow: 0 14px 42px rgba(0, 0, 0, .12); }
.catalog-toolbar { display: grid; gap: 14px; margin-bottom: 14px; }
.search-control, .switcher-search { display: flex; align-items: center; gap: 8px; min-width: 0; border: 1px solid #294863; border-radius: 8px; padding: 0 8px 0 11px; background: #091522; }
.search-control:focus-within, .switcher-search:focus-within { border-color: #6f9fc8; box-shadow: 0 0 0 3px rgba(83, 142, 190, .12); }
.search-control input, .switcher-search input { min-height: 42px; border: 0; padding-left: 0; background: transparent; box-shadow: none; }
.search-glyph { color: #78a0c1; font-size: 20px; line-height: 1; }
.search-control kbd { flex: 0 0 auto; }
.filter-grid { display: grid; grid-template-columns: repeat(6, minmax(110px, 1fr)); gap: 9px; }
.filter-grid label, .pagination-controls label { display: grid; gap: 6px; color: #7791ab; font-size: 10px; }
.filter-grid select { min-height: 35px; padding: 6px 8px; font-size: 12px; }
.toolbar-footer, .pagination-bar { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: #7992ac; font-size: 11px; }
.filter-result { color: #9bb2c8; }
.catalog-table-card { padding: 0; overflow: hidden; }
.table-scroll { overflow-x: auto; }
.repository-table { width: 100%; min-width: 890px; border-collapse: collapse; text-align: left; }
.repository-table th { border-bottom: 1px solid #254159; padding: 13px 16px; color: #7590aa; font-size: 10px; font-weight: 650; letter-spacing: .09em; text-transform: uppercase; white-space: nowrap; }
.repository-table td { border-bottom: 1px solid rgba(35, 58, 80, .7); padding: 13px 16px; color: #b3c5d8; font-size: 12px; vertical-align: middle; }
.repository-table tbody tr:last-child td { border-bottom: 0; }
.repository-table tbody tr:hover { background: rgba(26, 57, 87, .22); }
.sort-button { display: inline-flex; align-items: center; gap: 7px; border: 0; padding: 0; color: inherit; background: transparent; font-size: inherit; font-weight: inherit; letter-spacing: inherit; text-transform: inherit; }
.sort-button:hover:not(:disabled) { border: 0; color: #d8eafb; background: transparent; }
.sort-button span { color: #83b6d4; font-size: 13px; }
.repo-cell { display: flex; align-items: center; gap: 10px; min-width: 235px; }
.repo-cell > div:last-child { min-width: 0; }
.repo-link { display: block; max-width: 330px; overflow: hidden; border: 0; padding: 0; color: #e8f2fc; background: transparent; font-size: 13px; font-weight: 650; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.repo-link:hover:not(:disabled) { border: 0; color: #9cdbf5; background: transparent; }
.repo-cell small, .table-date small { display: block; margin-top: 4px; color: #718aa4; font-size: 10px; }
.star-button { display: grid; flex: 0 0 auto; width: 27px; height: 27px; place-items: center; border: 0; padding: 0; color: #5f7893; background: transparent; font-size: 18px; line-height: 1; }
.star-button:hover:not(:disabled) { border: 0; color: #f0ce7b; background: transparent; }
.star-button.pinned { color: #f0c86f; }
.status-label, .readiness-label { display: inline-flex; align-items: center; gap: 7px; color: #9bafc4; font-size: 11px; white-space: nowrap; }
.status-label i, .readiness-label i, .activity-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #71859b; box-shadow: 0 0 0 3px rgba(113, 133, 155, .08); }
.status-current i, .status-label.status-current i { background: #64d3a0; box-shadow: 0 0 0 3px rgba(100, 211, 160, .1); }
.status-scanning i, .status-queued i, .status-stale i { background: #e6b766; box-shadow: 0 0 0 3px rgba(230, 183, 102, .1); }
.status-failed i, .status-unavailable i { background: #e77f8e; box-shadow: 0 0 0 3px rgba(231, 127, 142, .1); }
.readiness-ready i { background: #64d3a0; }
.readiness-warnings i { background: #e6b766; }
.readiness-blocked i { background: #e77f8e; }
.health-value { color: #d4e8fa; font-size: 14px; }
.table-date { color: #b7cadc; white-space: nowrap; }
.table-action { padding: 6px 9px; border-color: #2c4c69; color: #a6c6e1; background: #0f2237; font-size: 11px; }
.pagination-bar { border-top: 1px solid #21384f; padding: 12px 16px; }
.pagination-controls { display: flex; align-items: center; gap: 9px; }
.pagination-controls label { display: flex; align-items: center; grid-template-columns: auto auto; gap: 6px; }
.pagination-controls select { width: auto; min-height: 30px; padding: 4px 7px; font-size: 11px; }
.pagination-controls button { padding: 6px 9px; font-size: 11px; }
.page-number { min-width: 74px; color: #a1b8ce; text-align: center; }
.loading-state { display: flex; align-items: center; gap: 10px; min-height: 130px; color: #9db6cc; font-size: 13px; }
.loading-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: #68c8ca; box-shadow: 0 0 0 5px rgba(104, 200, 202, .1); animation: pulse 1.4s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: .35; transform: scale(.72); } }
.empty-state { display: grid; justify-items: start; align-content: center; min-height: 260px; padding: 34px; }
.empty-state h1, .empty-state h2 { max-width: 620px; }
.empty-state .muted { margin-bottom: 17px; }
.empty-icon { display: grid; width: 36px; height: 36px; place-items: center; margin-bottom: 15px; border: 1px solid #345474; border-radius: 9px; color: #91c2df; background: #102b42; font-size: 20px; }
.button-link { display: inline-flex; align-items: center; min-height: 38px; border: 1px solid #315272; border-radius: 8px; padding: 8px 13px; color: #ecf5ff; background: #173553; }
.button-link:hover { border-color: #6d99c3; color: #fff; background: #1d4164; }
.compact-empty { min-height: 180px; padding: 22px 0; }
.breadcrumb-row { display: flex; align-items: center; gap: 9px; margin-bottom: 11px; color: #6f89a2; font-size: 11px; }
.breadcrumb-row button { border: 0; padding: 0; color: #b8cce0; background: transparent; font-size: 11px; }
.breadcrumb-row button:hover:not(:disabled) { border: 0; color: #eff7ff; background: transparent; }
.detail-header { display: grid; grid-template-columns: minmax(300px, 1.35fr) minmax(270px, 1fr) auto auto; align-items: center; gap: 19px; min-height: 112px; padding: 16px 18px; }
.detail-identity { display: flex; align-items: center; min-width: 0; gap: 12px; }
.detail-identity > div:last-child { min-width: 0; }
.provider-mark { display: grid; flex: 0 0 27px; width: 27px; height: 27px; place-items: center; border: 1px solid #36597e; border-radius: 7px; color: #b8d7f2; background: #10243b; font-size: 11px; font-weight: 750; }
.provider-mark.large { width: 36px; height: 36px; border-radius: 9px; font-size: 13px; }
.detail-header h1 { margin: 3px 0 4px; overflow: hidden; font-size: clamp(20px, 2.2vw, 29px); text-overflow: ellipsis; white-space: nowrap; }
.detail-description { max-width: 52ch; margin: 0; overflow: hidden; color: #8098b2; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.detail-meta { display: flex; flex-wrap: wrap; gap: 10px 16px; }
.detail-meta span { display: grid; gap: 4px; color: #c2d3e2; font-size: 11px; }
.detail-meta strong { color: #718ca7; font-size: 9px; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; }
.detail-status { display: grid; justify-items: end; gap: 3px; min-width: 102px; }
.detail-status > strong { color: #ebf5fd; font-size: 18px; letter-spacing: -.02em; }
.detail-status small { max-width: 130px; overflow: hidden; color: #7f96ad; font-size: 9px; text-align: right; text-overflow: ellipsis; white-space: nowrap; }
.detail-actions { display: flex; align-items: center; justify-content: end; gap: 6px; }
.detail-actions > button:first-child { min-width: 77px; }
.action-menu { position: relative; }
.action-menu summary { list-style: none; border: 1px solid #315272; border-radius: 8px; padding: 9px 11px; color: #d7e8f8; background: #0e2339; cursor: pointer; font-size: 12px; }
.action-menu summary::-webkit-details-marker { display: none; }
.action-menu summary::after { display: inline-block; margin-left: 7px; color: #7398b8; content: '⌄'; }
.action-menu[open] summary { border-color: #79a6cf; }
.menu-popover { position: absolute; z-index: 4; top: calc(100% + 7px); right: 0; display: grid; min-width: 155px; padding: 5px; border: 1px solid #31516c; border-radius: 8px; background: #0c1b2b; box-shadow: 0 15px 34px rgba(0, 0, 0, .35); }
.menu-popover button { border: 0; border-radius: 5px; padding: 8px 9px; color: #b9cde0; background: transparent; text-align: left; font-size: 11px; }
.menu-popover button:hover:not(:disabled) { border: 0; color: #eff7ff; background: #173653; }
.header-star { width: 32px; height: 35px; border: 1px solid #294560; border-radius: 8px; background: #0b1625; }
.header-star:hover:not(:disabled) { border: 1px solid #6d99c3; background: #173653; }
.detail-tabs { display: flex; gap: 3px; margin: 14px 0; border-bottom: 1px solid #20384f; overflow-x: auto; }
.detail-tabs a { position: relative; display: inline-flex; align-items: center; min-height: 38px; border-bottom: 2px solid transparent; padding: 0 12px; color: #7f96ad; font-size: 12px; white-space: nowrap; }
.detail-tabs a:hover { color: #dcecfb; }
.detail-tabs a.active { border-bottom-color: #6ab9d0; color: #eff7ff; }
.advanced-info { display: grid; grid-template-columns: 220px minmax(0, 1fr); align-items: center; gap: 25px; margin-bottom: 14px; }
.advanced-info h2 { margin: 5px 0 0; font-size: 19px; }
.info-grid { display: grid; grid-template-columns: repeat(3, minmax(120px, 1fr)); gap: 13px 20px; }
.info-grid span, .settings-list div { display: grid; gap: 4px; color: #bed0e0; font-size: 11px; }
.info-grid strong, .settings-list dt { color: #7089a3; font-size: 9px; font-weight: 650; letter-spacing: .07em; text-transform: uppercase; }
.detail-content { min-width: 0; }
.job-progress { display: grid; gap: 9px; margin-top: 12px; border: 1px solid #315875; border-radius: 9px; padding: 11px 14px; background: #0b1d2d; }
.job-progress > div:first-child { display: flex; justify-content: space-between; gap: 12px; color: #cde0f1; font-size: 12px; }
.job-progress > div:first-child span { color: #85a0ba; text-transform: capitalize; }
.progress-track { height: 5px; overflow: hidden; border-radius: 99px; background: #1b3147; }
.progress-track i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #64c6d1, #78daa2); transition: width .25s ease; }
.repository-facts { display: flex; flex-wrap: wrap; gap: 7px; margin: 18px 0; }
.repository-facts span { border: 1px solid #284a67; border-radius: 6px; padding: 7px 9px; color: #b2c9df; background: #0b1c2d; font-size: 11px; }
.repository-empty { min-height: 330px; justify-items: start; }
.view-stack { display: grid; gap: 14px; min-width: 0; }
.low-signal-notice { margin: 0; }
.metric-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; }
.metric-grid article { min-width: 0; border: 1px solid #203a53; border-radius: 9px; padding: 13px; background: #0a1727; }
.metric-grid.low-signal article { border-color: transparent; padding: 9px 10px; background: transparent; }
.metric-grid span { display: block; min-height: 27px; color: #87a0b8; font-size: 10px; letter-spacing: .04em; text-transform: uppercase; }
.metric-grid strong { display: block; margin-top: 7px; color: #e9f4fe; font-size: 24px; letter-spacing: -.03em; }
.metric-grid.low-signal strong { color: #a5b9cb; font-size: 21px; }
.metric-grid small { display: block; min-height: 25px; margin-top: 4px; color: #718aa4; font-size: 9px; line-height: 1.35; }
.insight-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 9px; }
.insight-card { min-width: 0; border-left: 3px solid #3d6688; border-top: 1px solid #213b55; border-right: 1px solid #213b55; border-bottom: 1px solid #213b55; border-radius: 8px; padding: 14px 15px; background: #0b1929; }
.insight-card.insight-ready { border-left-color: #65d3a0; }
.insight-card.insight-warnings { border-left-color: #e6b766; }
.insight-card.insight-blocked { border-left-color: #e77f8e; }
.insight-card strong { display: block; margin: 6px 0 4px; overflow: hidden; color: #e3f1fc; font-size: 14px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
.insight-card p { margin: 0; overflow: hidden; color: #8199b1; font-size: 11px; line-height: 1.4; text-overflow: ellipsis; white-space: nowrap; }
.overview-columns { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(310px, .85fr); gap: 14px; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 15px; }
.section-heading h3 { margin: 5px 0 0; }
.section-note { max-width: 39ch; color: #7890a9; font-size: 10px; line-height: 1.45; text-align: right; }
.delta { border-radius: 5px; padding: 5px 8px; color: #a9bfd3; background: #0a1827; font-size: 10px; white-space: nowrap; }
.score-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.score-card { min-width: 0; border: 1px solid #203a53; border-radius: 8px; padding: 12px; background: #0a1726; }
.score-card > div:first-child, .language > div:first-child { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.score-card span, .language span { color: #a0b4c9; font-size: 11px; }
.score-card strong { color: #e4f0fb; font-size: 20px; }
.score-card small { display: block; margin-top: 7px; color: #859cb4; font-size: 10px; }
.bar { height: 5px; margin-top: 10px; overflow: hidden; border-radius: 99px; background: #1b2b3e; }
.bar i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #5686b5, #7abfd0); }
.release-gate-card { align-content: start; }
.gate-list { display: grid; gap: 7px; margin-top: 15px; }
.gate-check { display: grid; grid-template-columns: 50px minmax(0, 1fr); gap: 9px; align-items: start; border: 1px solid #20384f; border-radius: 7px; padding: 9px; background: #091522; }
.gate-state { display: inline-flex; justify-content: center; border: 1px solid currentColor; border-radius: 5px; padding: 4px 5px; font-size: 9px; letter-spacing: .06em; text-transform: uppercase; }
.gate-pass { color: #77dca3; }
.gate-warn { color: #ecc16e; }
.gate-block { color: #ff929f; }
.gate-check strong { display: block; color: #dceaf7; font-size: 11px; }
.gate-check small { display: block; margin-top: 4px; color: #7d94ad; font-size: 10px; line-height: 1.35; }
.signals-columns { grid-template-columns: minmax(0, 1.35fr) minmax(310px, .85fr); }
.language-list { display: grid; gap: 12px; margin-top: 17px; }
.language strong { color: #dce7f5; font-size: 11px; }
.signal-stack { display: grid; gap: 14px; }
.signal-card h3 { margin-bottom: 5px; }
.compact-dl { display: grid; gap: 8px; margin: 15px 0 0; }
.compact-dl div { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; border-bottom: 1px solid rgba(44, 63, 86, .44); padding-bottom: 6px; }
.compact-dl dt { color: #8298b2; font-size: 11px; }
.compact-dl dd { margin: 0; color: #d9e7f4; font-size: 11px; text-align: right; }
.empty-inline { padding: 16px 0 6px; color: #8095ac; font-size: 11px; line-height: 1.45; }
.finding-filters { display: grid; grid-template-columns: minmax(220px, 1fr) 150px 180px; gap: 8px; margin-bottom: 13px; }
.finding-filters input, .finding-filters select { width: 100%; }
.findings-list, .hotspot-list, .scan-run-list { display: grid; gap: 7px; }
.finding { display: block; overflow: hidden; border: 1px solid #20384f; border-radius: 8px; background: #091522; }
.finding-summary { display: grid; grid-template-columns: 69px minmax(0, 1fr) 42px 14px; align-items: start; gap: 11px; padding: 11px; cursor: pointer; list-style: none; }
.finding-summary::-webkit-details-marker { display: none; }
.finding-summary::after { align-self: center; color: #7f9ab4; content: '+'; font-size: 15px; line-height: 1; text-align: right; }
.finding[open] .finding-summary::after { content: '−'; }
.finding-copy { min-width: 0; }
.finding-copy strong, .finding-copy > span { display: block; }
.finding strong { color: #e3effa; font-size: 12px; font-weight: 600; line-height: 1.35; }
.finding-copy > span { margin-top: 4px; color: #7f94ae; font-size: 10px; overflow-wrap: anywhere; }
.severity { display: inline-flex; justify-content: center; border: 1px solid currentColor; border-radius: 5px; padding: 4px 5px; font-size: 9px; letter-spacing: .05em; text-transform: uppercase; }
.severity-blocker { color: #ff909b; }
.severity-warning { color: #efc16e; }
.severity-info { color: #91acc7; }
.deduction { justify-self: end; color: #90a5be; font-size: 10px; }
.finding-details { border-top: 1px solid #20384f; padding: 12px 13px 13px 93px; background: #0b1a2a; }
.finding-detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 11px 18px; margin: 0; }
.finding-detail-grid div { display: grid; min-width: 0; gap: 4px; }
.finding-detail-grid dt { color: #718ba5; font-size: 9px; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; }
.finding-detail-grid dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: #c4d5e4; font-size: 10px; line-height: 1.35; }
.finding.suppressed { opacity: .64; }
.finding.suppressed .severity { color: #b8a36b; border-color: #66552d; background: #2b2412; }
.hotspot-row { display: grid; grid-template-columns: 37px minmax(0, 1fr) 100px; gap: 12px; align-items: center; border: 1px solid #20384f; border-radius: 8px; padding: 11px; background: #091522; }
.hotspot-rank { display: grid; width: 29px; height: 29px; place-items: center; border-radius: 7px; color: #9bb5ce; background: #13283e; font-size: 12px; font-weight: 700; }
.hotspot-title { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.hotspot-title strong { overflow-wrap: anywhere; color: #e1edf8; font-size: 12px; }
.hotspot-title span { color: #8197b0; font-size: 10px; }
.hotspot-reasons { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; }
.hotspot-reasons span { border-radius: 5px; padding: 3px 5px; color: #8da3bc; background: #102137; font-size: 9px; }
.hotspot-bar i { background: linear-gradient(90deg, #628fc0, #c4766f); }
.hotspot-metrics { display: grid; justify-items: end; gap: 2px; }
.hotspot-metrics strong { color: #e2effa; font-size: 23px; }
.hotspot-metrics small { color: #7f95ae; font-size: 9px; }
.architecture-summary { display: flex; align-items: end; justify-content: space-between; gap: 20px; }
.architecture-stats { display: grid; grid-template-columns: repeat(3, 85px); gap: 7px; }
.architecture-stats div { border: 1px solid #203b58; border-radius: 8px; padding: 10px; background: #091624; }
.architecture-stats strong { display: block; color: #e8f4fe; font-size: 21px; }
.architecture-stats strong small { color: #6f89a6; font-size: 11px; font-weight: 500; }
.architecture-stats span { color: #8196af; font-size: 9px; line-height: 1.2; text-transform: uppercase; }
.architecture-canvas { height: 650px; padding: 0; overflow: hidden; }
.react-flow__node { min-width: 150px; border: 1px solid #36587e !important; border-radius: 8px !important; padding: 10px 12px !important; color: #edf6ff !important; background: #10233a !important; box-shadow: 0 10px 28px rgba(0, 0, 0, .25); font-size: 11px; }
.react-flow__node.arch-hot { border-color: #a24b59 !important; }
.react-flow__node.arch-warm { border-color: #9b7634 !important; }
.react-flow__node.arch-calm { border-color: #37638a !important; }
.react-flow__edge-path { stroke: #668ab3; stroke-width: 1.7; }
.react-flow__controls { overflow: hidden; border: 1px solid #274563; border-radius: 8px; }
.react-flow__controls button { border-radius: 0; color: #fff; background: #10233a; }
.react-flow__minimap { overflow: hidden; border: 1px solid #274563; border-radius: 8px; background: #08131f !important; }
.trend-chart { display: flex; align-items: end; gap: 7px; min-height: 250px; overflow-x: auto; padding: 18px 6px 1px; }
.trend-column { display: grid; flex: 1 0 40px; grid-template-rows: 19px 180px 30px; gap: 5px; min-width: 40px; max-width: 70px; text-align: center; }
.trend-column > span { color: #9fb2c8; font-size: 10px; }
.trend-track { display: flex; align-items: end; overflow: hidden; border-radius: 6px; background: #0a1726; }
.trend-track i { display: block; width: 100%; border-radius: 6px 6px 0 0; background: linear-gradient(180deg, #6d9bc8, #365d84); }
.trend-column small { color: #748aa4; font-size: 9px; line-height: 1.2; }
.comparison-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.delta-grid { display: grid; gap: 7px; margin-top: 14px; }
.delta-grid div { display: flex; justify-content: space-between; gap: 12px; border-bottom: 1px solid rgba(44, 63, 86, .45); padding-bottom: 6px; }
.delta-grid span { color: #8499b2; font-size: 11px; }
.delta-grid strong { font-size: 11px; }
.delta-findings { display: grid; gap: 7px; margin-top: 14px; }
.delta-findings > div { border-radius: 6px; padding: 8px 9px; font-size: 10px; line-height: 1.4; }
.delta-findings .resolved { color: #a5dac3; background: #102922; }
.delta-findings .added { color: #efacb4; background: #2d171c; }
.scan-run-card { margin-top: 0; }
.scan-run-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; border-color: #20384f; border-radius: 7px; padding: 10px 11px; color: #d8e7f3; background: #091522; text-align: left; }
.scan-run-row:hover:not(:disabled), .scan-run-row.selected { border-color: #5684ad; background: #122a42; }
.scan-run-row span { min-width: 0; }
.scan-run-row span strong, .scan-run-row span small { display: block; }
.scan-run-row span strong { font-size: 12px; }
.scan-run-row span small { margin-top: 3px; color: #7e94ab; font-size: 10px; }
.scan-run-row > strong { font-size: 18px; }
.summary-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 9px; margin-bottom: 14px; }
.summary-strip > div { border: 1px solid #20384f; border-radius: 8px; padding: 13px; background: #0a1726; }
.summary-strip span { display: block; color: #7c95ad; font-size: 10px; letter-spacing: .06em; text-transform: uppercase; }
.summary-strip strong { display: block; margin-top: 6px; color: #e5f1fb; font-size: 21px; letter-spacing: -.02em; }
.activity-card, .queue-card { min-height: 280px; }
.activity-list, .queue-list { display: grid; gap: 5px; }
.activity-row, .queue-row { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto auto; align-items: center; gap: 11px; width: 100%; border-color: #20384f; border-radius: 7px; padding: 11px 12px; background: #091522; text-align: left; }
.activity-row:hover:not(:disabled) { border-color: #5684ad; background: #122a42; }
.activity-row > span:nth-child(2) { min-width: 0; }
.activity-row strong, .queue-row strong { display: block; overflow: hidden; color: #dbeaf6; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.activity-row small, .queue-row small { display: block; margin-top: 3px; color: #7e94ab; font-size: 10px; }
.activity-health { color: #d9eafb !important; font-size: 15px !important; }
.activity-dot { width: 7px; height: 7px; }
.row-chevron { color: #7190ac; font-size: 16px; }
.queue-row { grid-template-columns: 110px minmax(0, 1fr) auto; }
.queue-row > span:nth-child(2) { min-width: 0; }
.settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.settings-grid h2, .settings-note h2 { margin-top: 6px; font-size: 20px; }
.settings-list { display: grid; gap: 11px; margin: 22px 0; }
.settings-list div { display: flex; align-items: baseline; justify-content: space-between; gap: 15px; border-bottom: 1px solid rgba(44, 63, 86, .45); padding-bottom: 8px; }
.settings-list dd { margin: 0; color: #d3e2ef; font-size: 11px; text-align: right; }
.settings-list .status-label { font-size: 11px; }
.local-form { display: flex; align-items: end; gap: 8px; margin-top: 20px; }
.local-form label { display: grid; flex: 1; gap: 6px; color: #91a8c0; font-size: 10px; }
.local-form button { white-space: nowrap; }
.local-result { display: grid; gap: 4px; margin-top: 17px; border-left: 3px solid #65d3a0; border-radius: 6px; padding: 10px 11px; color: #a9c0d5; background: #0b2022; font-size: 11px; }
.local-result strong { color: #e4f3fb; }
.settings-note { margin-top: 14px; }
.settings-counts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 17px; }
.settings-counts span { border: 1px solid #294861; border-radius: 6px; padding: 7px 9px; color: #91aac2; font-size: 11px; }
.settings-counts strong { color: #dff0fc; }
.modal-backdrop { position: fixed; z-index: 40; inset: 0; display: grid; place-items: start center; padding: min(17vh, 150px) 20px 20px; background: rgba(2, 6, 11, .72); backdrop-filter: blur(5px); }
.switcher-modal { width: min(620px, 100%); overflow: hidden; border: 1px solid #3c607d; border-radius: 11px; background: #0b1828; box-shadow: 0 25px 80px rgba(0, 0, 0, .5); }
.switcher-heading { display: flex; align-items: start; justify-content: space-between; gap: 15px; padding: 18px 18px 13px; }
.switcher-heading h2 { margin: 5px 0 0; font-size: 22px; }
.switcher-search { margin: 0 15px 11px; }
.switcher-results { display: grid; max-height: min(54vh, 440px); overflow-y: auto; padding: 0 7px 7px; }
.switcher-item { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; align-items: center; gap: 9px; width: 100%; border: 1px solid transparent; border-radius: 7px; padding: 10px 9px; color: #dceaf6; background: transparent; text-align: left; }
.switcher-item:hover:not(:disabled), .switcher-item.active { border-color: #365e7f; background: #15314b; }
.switcher-item > span:nth-child(2) { min-width: 0; }
.switcher-item strong, .switcher-item small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.switcher-item strong { font-size: 12px; }
.switcher-item small { margin-top: 3px; color: #839ab2; font-size: 10px; }
.switcher-star { color: #607990; font-size: 16px; text-align: center; }
.switcher-star.pinned { color: #f0c86f; }
.switcher-footer { display: flex; flex-wrap: wrap; gap: 15px; border-top: 1px solid #20384f; padding: 11px 17px; color: #7892ab; font-size: 10px; }
.switcher-footer span { display: inline-flex; align-items: center; gap: 4px; }
.sr-only { position: absolute; left: -9999px; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 1250px) {
.app-header { grid-template-columns: auto 1fr; gap: 17px; }
.global-nav { grid-column: 1 / -1; grid-row: 2; min-height: 38px; border-top: 1px solid rgba(49, 76, 104, .42); overflow-x: auto; }
.global-nav a { min-height: 38px; }
.header-actions { grid-column: 2; grid-row: 1; justify-content: end; }
.app-header { padding-top: 4px; }
.metric-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.detail-header { grid-template-columns: minmax(250px, 1.4fr) minmax(250px, 1fr) auto; }
.detail-actions { grid-column: 1 / -1; justify-content: start; }
.detail-status { justify-items: start; }
.detail-status small { text-align: left; }
.filter-grid { grid-template-columns: repeat(3, minmax(140px, 1fr)); }
}
@media (max-width: 900px) {
.page-heading { align-items: start; flex-direction: column; }
.page-heading-actions { justify-content: start; }
.detail-header { grid-template-columns: 1fr 1fr; }
.detail-identity { grid-column: 1 / -1; }
.detail-meta { align-content: start; }
.detail-status { align-self: start; }
.detail-actions { grid-column: 1 / -1; }
.overview-columns, .signals-columns { grid-template-columns: 1fr; }
.advanced-info { grid-template-columns: 1fr; gap: 14px; }
.info-grid { grid-template-columns: repeat(3, minmax(110px, 1fr)); }
.summary-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.settings-grid { grid-template-columns: 1fr; }
}
@media (max-width: 680px) {
.app-header, .page-content, .global-banner { width: min(100% - 24px, 1600px); }
.app-header { min-height: 60px; gap: 10px; }
.brand-lockup small, .shortcut-button span { display: none; }
.brand-lockup { gap: 6px; }
.shortcut-button { padding: 7px; }
.global-nav { gap: 0; }
.global-nav a { padding: 0 9px; font-size: 11px; }
.page-content { padding-top: 22px; }
.page-heading { margin-bottom: 17px; }
.page-heading-actions { width: 100%; }
.page-heading-actions button { flex: 1; }
.section-card { padding: 14px; }
.filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.toolbar-footer, .pagination-bar { align-items: start; flex-direction: column; }
.pagination-controls { width: 100%; flex-wrap: wrap; }
.pagination-controls label { margin-right: auto; }
.repository-table th, .repository-table td { padding-right: 12px; padding-left: 12px; }
.detail-header { grid-template-columns: 1fr; gap: 13px; }
.detail-identity, .detail-actions { grid-column: auto; }
.detail-status { justify-items: start; }
.detail-status small { text-align: left; }
.detail-actions { justify-content: start; flex-wrap: wrap; }
.detail-actions > button:first-child { flex: 1; }
.info-grid { grid-template-columns: repeat(2, minmax(110px, 1fr)); }
.metric-grid, .insight-grid, .summary-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.metric-grid article { padding: 11px; }
.metric-grid strong { font-size: 21px; }
.insight-card { padding: 12px; }
.insight-card strong { font-size: 12px; }
.score-grid, .comparison-grid { grid-template-columns: 1fr; }
.section-heading { align-items: start; flex-direction: column; gap: 7px; }
.section-note { max-width: none; text-align: left; }
.finding-filters { grid-template-columns: 1fr; }
.finding-summary { grid-template-columns: 64px minmax(0, 1fr) 14px; }
.finding-summary .deduction { display: none; }
.finding-details { padding: 11px; }
.finding-detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.hotspot-row { grid-template-columns: 31px minmax(0, 1fr); }
.hotspot-metrics { grid-column: 2; grid-template-columns: repeat(4, auto); align-items: baseline; justify-content: start; gap: 8px; }
.hotspot-metrics strong { font-size: 18px; }
.architecture-summary { align-items: start; flex-direction: column; }
.architecture-stats { width: 100%; grid-template-columns: repeat(3, 1fr); }
.architecture-canvas { height: 520px; }
.activity-row { grid-template-columns: 10px minmax(0, 1fr) auto; }
.row-chevron { display: none; }
.queue-row { grid-template-columns: 1fr auto; }
.queue-row > span:first-child { grid-column: 1 / -1; }
.local-form { align-items: stretch; flex-direction: column; }
.switcher-item { grid-template-columns: 22px minmax(0, 1fr); }
.switcher-item > .status-label { display: none; }
}
@media (max-width: 430px) {
.version-badge { display: none; }
.filter-grid, .metric-grid, .insight-grid, .summary-strip { grid-template-columns: 1fr; }
.page-heading-actions { flex-direction: column; }
.page-heading-actions button { flex: initial; width: 100%; }
.detail-tabs a { padding: 0 9px; font-size: 11px; }
.info-grid { grid-template-columns: 1fr; }
.detail-meta { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.detail-status { grid-row: auto; }
.finding-detail-grid { grid-template-columns: 1fr; }
}
/* v1.2 signal-topology system ------------------------------------------------ */
:root {
--canvas: #07111c;
--canvas-raised: #0a1624;
--surface: #0d1b2b;
--surface-strong: #122237;
--surface-hover: #172a42;
--border: rgba(132, 160, 193, .16);
--border-strong: rgba(144, 189, 218, .34);
--text: #eff7ff;
--text-muted: #91a5bb;
--text-subtle: #6e8299;
--accent: #75d1cc;
--accent-bright: #b1f0df;
--ready: #64d39b;
--warning: #e9bb71;
--blocked: #ed7188;
--radius-sm: 9px;
--radius: 14px;
--radius-lg: 20px;
--shadow: 0 20px 55px rgba(0, 0, 0, .18);
--ease: cubic-bezier(.2, .8, .2, 1);
--motion-fast: 130ms;
--motion: 210ms;
--motion-slow: 360ms;
color: var(--text);
background: var(--canvas);
}
html { background: var(--canvas); }
body {
background:
linear-gradient(rgba(118, 171, 198, .025) 1px, transparent 1px),
linear-gradient(90deg, rgba(118, 171, 198, .025) 1px, transparent 1px),
radial-gradient(ellipse 70% 40% at 50% -10%, rgba(61, 132, 150, .16), transparent 72%),
var(--canvas);
background-size: 44px 44px, 44px 44px, auto, auto;
}
button, a, input, select, summary { transition-duration: var(--motion-fast); transition-timing-function: var(--ease); }
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible { border-color: var(--accent); outline-color: rgba(117, 209, 204, .27); }
button { border-color: var(--border-strong); border-radius: var(--radius-sm); background: #174052; }
button:hover:not(:disabled) { border-color: var(--accent); background: #1a4a5d; }
.app-header { width: min(100% - 56px, 1500px); min-height: 72px; border-bottom-color: var(--border); background: rgba(7, 17, 28, .82); }
.brand-home { color: var(--text); }
.brand-home:hover { color: var(--accent-bright); }
.brand-lockup-copy { display: inline-flex; align-items: center; gap: 10px; }
.brand-lockup-copy > span:last-child { display: grid; gap: 1px; }
.brand-lockup-copy strong { display: block; font-size: 16px; letter-spacing: -.04em; }
.brand-lockup-copy small { color: var(--text-subtle); font-size: 9px; font-weight: 650; letter-spacing: .11em; text-transform: uppercase; }
.brand-svg { display: block; flex: 0 0 auto; color: var(--accent); filter: drop-shadow(0 4px 10px rgba(117, 209, 204, .17)); }
.global-nav { gap: 12px; }
.global-nav a { border-bottom-width: 1px; padding: 0 8px; color: var(--text-muted); }
.global-nav a.active { border-bottom-color: var(--accent); color: var(--text); background: transparent; }
.global-nav a:hover { color: var(--accent-bright); }
.page-content { width: min(100% - 56px, 1500px); padding-top: 34px; }
.eyebrow { color: var(--accent); font-weight: 720; }
.muted { color: var(--text-muted); }
.section-card { border-color: var(--border); border-radius: var(--radius); background: rgba(13, 27, 43, .86); box-shadow: var(--shadow); }
.count-badge { border-color: var(--border-strong); color: var(--accent-bright); background: rgba(117, 209, 204, .08); }
.brand-mark { display: none; }
.header-actions { gap: 8px; }
.shortcut-button, .icon-button { border-color: var(--border); background: rgba(14, 29, 45, .72); }
.shortcut-button:hover:not(:disabled), .icon-button:hover:not(:disabled) { background: var(--surface-hover); }
/* Dashboard: composed sections rather than metric-card soup. */
.dashboard-page { display: grid; gap: 18px; }
.dashboard-hero { position: relative; display: flex; align-items: end; justify-content: space-between; min-height: 190px; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 30px 34px; background: linear-gradient(120deg, rgba(18, 42, 58, .94), rgba(12, 24, 39, .91)); box-shadow: var(--shadow); }
.dashboard-hero::after { position: absolute; inset: auto -10% -70% auto; width: 440px; height: 440px; border: 1px solid rgba(117, 209, 204, .16); border-radius: 50%; box-shadow: 0 0 0 50px rgba(117, 209, 204, .025), 0 0 0 100px rgba(117, 209, 204, .018); content: ''; pointer-events: none; }
.dashboard-hero > div:first-child { position: relative; z-index: 1; }
.dashboard-hero h1 { max-width: 700px; margin: 8px 0 10px; font-size: clamp(32px, 4vw, 52px); }
.dashboard-hero h1 span { color: var(--accent-bright); }
.dashboard-hero p { margin: 0; color: var(--text-muted); font-size: 14px; }
.portfolio-signal { position: relative; z-index: 1; display: grid; justify-items: end; min-width: 230px; color: var(--accent); }
.portfolio-signal svg { width: 210px; height: 100px; filter: drop-shadow(0 0 18px rgba(117, 209, 204, .18)); }
.portfolio-signal strong { margin-top: -53px; color: var(--text); font-size: 26px; letter-spacing: -.05em; }
.portfolio-signal span { color: var(--text-muted); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; }
.dashboard-health-strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--border); }
.health-strip-segment { display: grid; justify-items: start; min-height: 106px; border: 0; border-radius: 0; padding: 17px 20px; color: var(--text); background: rgba(12, 26, 41, .95); text-align: left; }
.health-strip-segment:hover:not(:disabled) { transform: translateY(-1px); border: 0; background: var(--surface-hover); }
.health-strip-segment span { color: var(--text-muted); font-size: 11px; }
.health-strip-segment strong { margin: 5px 0 3px; font-size: 28px; letter-spacing: -.06em; }
.health-strip-segment small { color: var(--text-subtle); font-size: 10px; }
.health-strip-segment.ready strong { color: var(--ready); }
.health-strip-segment.warnings strong { color: var(--warning); }
.health-strip-segment.blocked strong { color: var(--blocked); }
.health-strip-segment.all strong { color: var(--accent-bright); }
.dashboard-layout { display: grid; grid-template-columns: minmax(330px, .92fr) minmax(0, 1.45fr); gap: 18px; }
.dashboard-layout.lower { grid-template-columns: minmax(0, 1.45fr) minmax(330px, .92fr); }
.dashboard-section { min-width: 0; border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; background: rgba(11, 24, 39, .76); }
.dashboard-section h2 { margin: 5px 0 0; font-size: 20px; }
.attention-list, .activity-rail { display: grid; gap: 2px; }
.attention-row, .activity-rail-item { display: grid; grid-template-columns: 45px minmax(0, 1fr) auto; align-items: center; gap: 10px; width: 100%; border: 0; border-radius: 8px; padding: 9px 7px; color: var(--text); background: transparent; text-align: left; }
.attention-row:hover:not(:disabled), .activity-rail-item:hover:not(:disabled) { border: 0; background: var(--surface-hover); }
.attention-row strong, .activity-rail-item strong { display: block; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.attention-row small, .activity-rail-item small { display: block; margin-top: 3px; overflow: hidden; color: var(--text-subtle); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.attention-row b, .activity-rail-item b { color: var(--text); font-size: 18px; letter-spacing: -.05em; }
.repository-visual { position: relative; display: grid; width: 100%; aspect-ratio: 1.6; place-items: center; overflow: hidden; border: 1px solid rgba(155, 184, 214, .11); border-radius: 12px; color: #7999bc; background: radial-gradient(circle at 50% 50%, rgba(94, 144, 168, .14), transparent 65%), #0a1726; }
.repository-visual.compact { width: 42px; aspect-ratio: 1; border-radius: 10px; }
.repository-visual.ready { color: var(--ready); }
.repository-visual.warnings { color: var(--warning); }
.repository-visual.blocked { color: var(--blocked); }
.repository-visual svg { width: 100%; height: 100%; overflow: visible; }
.repository-visual:not(.compact):hover .visual-orbit { stroke-dashoffset: -18px; }
.visual-orbit { transition: stroke-dashoffset var(--motion-slow) var(--ease); }
.visual-node { transform-origin: center; transition: r var(--motion) var(--ease), opacity var(--motion) var(--ease); }
.repository-visual:hover .visual-node.hot { opacity: 1; }
.visual-scan-pulse { position: absolute; inset: 10%; border: 1px solid currentColor; border-radius: inherit; animation: repo-pulse 1.45s var(--ease) infinite; }
@keyframes repo-pulse { 0% { opacity: .8; transform: scale(.72); } 100% { opacity: 0; transform: scale(1.06); } }
.matrix-section { min-height: 294px; }
.portfolio-matrix { display: grid; grid-template-columns: repeat(auto-fit, minmax(28px, 1fr)); gap: 7px; min-height: 180px; align-content: center; padding: 13px 3px 9px; }
.matrix-cell { display: grid; aspect-ratio: 1; min-width: 0; place-items: center; border: 1px solid transparent; border-radius: 7px; padding: 0; color: rgba(255, 255, 255, .85); background: #395b7a; font-size: 8px; font-weight: 700; transform: scale(var(--cell-scale)); }
.matrix-cell span { overflow: hidden; max-width: 100%; text-overflow: clip; }
.matrix-cell.ready { background: rgba(100, 211, 155, .78); }
.matrix-cell.warnings { background: rgba(233, 187, 113, .78); }
.matrix-cell.blocked { background: rgba(237, 113, 136, .8); }
.matrix-cell:hover:not(:disabled), .matrix-cell:focus-visible { position: relative; z-index: 2; border-color: var(--text); background: var(--accent); color: #0a1726; transform: scale(1.17); }
.matrix-legend { display: flex; flex-wrap: wrap; gap: 12px; color: var(--text-subtle); font-size: 10px; }
.matrix-legend span { display: inline-flex; align-items: center; gap: 5px; }
.matrix-legend i { width: 7px; height: 7px; border-radius: 50%; }
.matrix-legend .ready { background: var(--ready); }.matrix-legend .warnings { background: var(--warning); }.matrix-legend .blocked { background: var(--blocked); }
.activity-rail-item { grid-template-columns: 11px minmax(0, 1fr) auto; padding-left: 0; }
.status-orb { width: 7px; height: 7px; border-radius: 50%; background: var(--text-subtle); box-shadow: 0 0 0 4px rgba(134, 153, 174, .09); }
.status-orb.ready { background: var(--ready); box-shadow: 0 0 0 4px rgba(100, 211, 155, .1); }.status-orb.warnings { background: var(--warning); }.status-orb.blocked { background: var(--blocked); }
.pinned-grid { display: grid; gap: 3px; }
.pinned-tile { display: grid; grid-template-columns: 45px minmax(0, 1fr); align-items: center; gap: 10px; border: 0; border-radius: 8px; padding: 9px 7px; background: transparent; text-align: left; }.pinned-tile:hover:not(:disabled) { border: 0; background: var(--surface-hover); }
.pinned-tile strong, .pinned-tile small { display: block; }.pinned-tile strong { font-size: 12px; }.pinned-tile small { margin-top: 3px; color: var(--text-subtle); font-size: 10px; }
.pin-empty { display: grid; gap: 8px; padding: 8px 0; color: var(--text-subtle); font-size: 11px; }
/* Explorer uses two intentional, persistent modes. */
.catalog-toolbar { border-color: var(--border); }
.catalog-toolbar.section-card { padding: 15px; }
.toolbar-footer { min-height: 29px; }
.view-toggle { display: inline-flex; overflow: hidden; border: 1px solid var(--border); border-radius: 8px; background: rgba(5, 13, 23, .46); }
.view-toggle button { border: 0; border-radius: 0; padding: 6px 10px; color: var(--text-muted); background: transparent; font-size: 10px; }.view-toggle button:hover:not(:disabled) { border: 0; background: var(--surface-hover); }.view-toggle button.active { color: #06151d; background: var(--accent); }
.repository-table { min-width: 820px; }.repository-table th { position: sticky; z-index: 2; top: 72px; border-bottom-color: var(--border); background: #0f1e30; }.repository-table td { border-bottom-color: rgba(132, 160, 193, .1); }.repository-table tbody tr { cursor: pointer; }.repository-table tbody tr:hover, .repository-table tbody tr:focus-within { background: rgba(117, 209, 204, .065); }.repository-table tbody tr:focus { outline: 2px solid rgba(117, 209, 204, .72); outline-offset: -2px; }.repo-link { color: var(--text); }.repo-link:hover:not(:disabled) { color: var(--accent-bright); }.catalog-table-card { border-color: var(--border); }.pagination-bar { border-top-color: var(--border); }
.repository-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 13px; }.repository-tile { position: relative; min-width: 0; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: 0 10px 32px rgba(0, 0, 0, .09); }.repository-tile-main { display: grid; width: 100%; gap: 11px; border: 0; border-radius: 0; padding: 10px; color: var(--text); background: transparent; text-align: left; }.repository-tile-main:hover:not(:disabled) { border: 0; background: linear-gradient(140deg, rgba(117, 209, 204, .1), transparent 50%); }.repository-tile-main:hover .repository-visual { transform: translateY(-2px); border-color: var(--border-strong); }.repository-tile .repository-visual { transition: transform var(--motion) var(--ease), border-color var(--motion) var(--ease); }.tile-heading, .tile-meta { display: flex; align-items: center; justify-content: space-between; gap: 9px; }.tile-heading > span { min-width: 0; }.tile-heading strong, .tile-heading small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.tile-heading strong { font-size: 13px; }.tile-heading small, .tile-meta > span:last-child { margin-top: 3px; color: var(--text-subtle); font-size: 10px; }.tile-heading b { color: var(--accent-bright); font-size: 20px; letter-spacing: -.06em; }.tile-pin { position: absolute; top: 14px; right: 14px; z-index: 2; width: 28px; height: 28px; border: 0; border-radius: 7px; padding: 0; color: var(--text-muted); background: rgba(5, 14, 24, .52); }.tile-pin:hover:not(:disabled) { border: 0; color: #ffe29a; background: rgba(5, 14, 24, .82); }.tile-pin.pinned { color: #f4cc75; }
.catalog-loading, .workspace-loading { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }.catalog-loading span, .workspace-loading span { display: block; min-height: 190px; border-radius: var(--radius); background: linear-gradient(100deg, rgba(126, 155, 184, .05) 15%, rgba(126, 155, 184, .14) 50%, rgba(126, 155, 184, .05) 85%); background-size: 200% 100%; animation: skeleton 1.3s linear infinite; }@keyframes skeleton { to { background-position: -200% 0; } }
/* Workspace puts identity, health and true architecture-first visual evidence together. */
.workspace-hero { display: grid; grid-template-columns: minmax(0, 1fr) 190px auto; align-items: center; gap: 25px; min-height: 176px; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 21px 24px; background: linear-gradient(120deg, rgba(14, 33, 48, .94), rgba(12, 24, 39, .95)); box-shadow: var(--shadow); }.workspace-identity { min-width: 0; }.workspace-identity > div:first-child { display: flex; align-items: flex-start; justify-content: space-between; gap: 15px; }.workspace-identity h1 { margin: 5px 0 6px; overflow: hidden; font-size: clamp(24px, 3vw, 36px); text-overflow: ellipsis; white-space: nowrap; }.workspace-identity p { max-width: 650px; margin: 0; color: var(--text-muted); font-size: 12px; line-height: 1.5; }.workspace-facts { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 13px; }.workspace-facts > span { border: 1px solid var(--border); border-radius: 6px; padding: 5px 7px; background: rgba(6, 17, 28, .31); font-size: 10px; }.workspace-score { display: grid; align-content: center; justify-items: end; min-width: 85px; }.workspace-score strong { color: var(--accent-bright); font-size: 42px; letter-spacing: -.09em; line-height: .85; }.workspace-score span { margin-top: 5px; color: var(--text-subtle); font-size: 9px; letter-spacing: .09em; text-transform: uppercase; }.workspace-score small { max-width: 100px; margin-top: 5px; overflow: hidden; color: var(--text-muted); font-size: 9px; text-align: right; text-overflow: ellipsis; white-space: nowrap; }.workspace-visual { width: 180px; align-self: stretch; min-height: 132px; }.workspace-hero .detail-actions { flex-wrap: wrap; max-width: 144px; }.workspace-hero .detail-actions > button:first-child { flex: 1 0 100%; }.detail-tabs { margin-top: 16px; border-bottom-color: var(--border); }.detail-tabs a.active { border-bottom-color: var(--accent); color: var(--accent-bright); }
.dependency-hero { overflow: hidden; background: linear-gradient(120deg, rgba(17, 43, 54, .76), rgba(12, 25, 39, .82)); }.dependency-metrics { display: grid; grid-template-columns: repeat(5, 1fr); gap: 1px; margin: 18px -18px -18px; border-top: 1px solid var(--border); background: var(--border); }.dependency-metrics div { display: grid; gap: 4px; padding: 13px 17px; background: var(--surface); }.dependency-metrics strong { color: var(--accent-bright); font-size: 21px; }.dependency-metrics span { color: var(--text-subtle); font-size: 9px; letter-spacing: .06em; text-transform: uppercase; }.manifest-list { display: flex; flex-wrap: wrap; gap: 7px; }.manifest-list code { border: 1px solid var(--border); border-radius: 7px; padding: 7px 8px; background: rgba(4, 12, 20, .35); }.dependency-lock-heading { margin-top: 24px; }.scan-runs-workspace { min-height: 270px; }.run-readiness { color: var(--text-subtle); font-size: 10px; }.run-readiness.ready { color: var(--ready); }.run-readiness.warnings { color: var(--warning); }.run-readiness.blocked { color: var(--blocked); }
/* Operational views. */
.summary-strip > div { border-color: var(--border); border-radius: var(--radius-sm); background: rgba(13, 27, 43, .74); }.summary-strip strong { color: var(--accent-bright); }.activity-timeline { padding: 20px 24px; }.timeline-event { position: relative; display: grid; grid-template-columns: 14px 44px minmax(0, 1fr) auto; align-items: center; gap: 11px; width: 100%; border: 0; border-radius: 8px; padding: 10px 7px; color: var(--text); background: transparent; text-align: left; }.timeline-event:hover:not(:disabled) { border: 0; background: var(--surface-hover); }.timeline-rail { width: 8px; height: 8px; border: 2px solid var(--canvas); border-radius: 50%; background: var(--text-subtle); box-shadow: 0 0 0 1px var(--border-strong); }.timeline-rail.ready { background: var(--ready); }.timeline-rail.warnings { background: var(--warning); }.timeline-rail.blocked { background: var(--blocked); }.timeline-event::before { position: absolute; top: 0; bottom: 0; left: 10px; width: 1px; background: var(--border); content: ''; }.timeline-event:first-of-type::before { top: 50%; }.timeline-event:last-of-type::before { bottom: 50%; }.timeline-event > * { position: relative; z-index: 1; }.timeline-event strong, .timeline-event small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.timeline-event strong { font-size: 12px; }.timeline-event small { margin-top: 3px; color: var(--text-subtle); font-size: 10px; }.timeline-event b { color: var(--accent-bright); font-size: 20px; }.queue-row { grid-template-columns: 45px minmax(0, 1fr) auto; border-color: var(--border); background: transparent; cursor: pointer; }.queue-row:hover:not(:disabled) { border-color: var(--accent); background: var(--surface-hover); }.queue-empty { display: grid; grid-template-columns: 72px minmax(0, 550px); align-items: start; gap: 18px; min-height: 250px; padding: 32px 5px; }.queue-empty h2 { margin: 5px 0 7px; }.queue-empty .muted { margin-bottom: 17px; }
/* Settings sections signal separation without form-card nesting. */
.settings-sections { display: grid; gap: 13px; }.settings-section { display: grid; grid-template-columns: minmax(210px, .72fr) minmax(260px, 1fr) auto; gap: 24px; align-items: start; border: 1px solid var(--border); border-radius: var(--radius); padding: 21px; background: rgba(13, 27, 43, .78); }.settings-section h2 { margin: 5px 0 7px; }.settings-section p { margin: 0; color: var(--text-muted); font-size: 11px; line-height: 1.5; }.settings-section dl { display: grid; gap: 8px; margin: 0; }.settings-section dl > div { display: flex; justify-content: space-between; gap: 14px; border-bottom: 1px solid var(--border); padding: 7px 0; }.settings-section dt { color: var(--text-subtle); font-size: 10px; }.settings-section dd { margin: 0; color: var(--text); font-size: 11px; text-align: right; }.settings-section .local-form { margin: 0; grid-column: 2 / -1; }.settings-section .local-result { grid-column: 2 / -1; }.settings-section.appearance { grid-template-columns: minmax(210px, .72fr) minmax(260px, 1fr); }.settings-section.appearance dl { grid-column: 2; }
.command-row { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 15px 11px; }.command-row button { border-color: var(--border); padding: 6px 8px; color: var(--text-muted); background: rgba(7, 18, 29, .52); font-size: 10px; }.command-row button:hover:not(:disabled) { color: var(--accent-bright); background: var(--surface-hover); }.switcher-item { grid-template-columns: 38px minmax(0, 1fr) auto; }.switcher-item .repository-visual { width: 32px; }.switcher-modal { border-color: var(--border-strong); border-radius: var(--radius); background: #0d1b2b; }.switcher-item:hover:not(:disabled), .switcher-item.active { border-color: rgba(117, 209, 204, .36); background: rgba(117, 209, 204, .1); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; }
}
@media (max-width: 1180px) { .dashboard-layout, .dashboard-layout.lower { grid-template-columns: 1fr; }.repository-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }.workspace-hero { grid-template-columns: minmax(0, 1fr) 160px; }.workspace-hero .detail-actions { grid-column: 1 / -1; max-width: none; justify-content: flex-start; }.workspace-hero .detail-actions > button:first-child { flex: 0 0 auto; }.settings-section { grid-template-columns: minmax(210px, .7fr) minmax(240px, 1fr); }.settings-section > button { grid-column: 2; justify-self: start; } }
@media (max-width: 760px) { .app-header, .page-content, .global-banner { width: min(100% - 28px, 1500px); }.app-header { grid-template-columns: auto 1fr; }.brand-lockup-copy small { display: none; }.global-nav { grid-column: 1 / -1; grid-row: 2; margin: 0 -2px; scrollbar-width: none; -ms-overflow-style: none; }.global-nav::-webkit-scrollbar { display: none; }.header-actions { justify-content: end; }.dashboard-hero { min-height: 180px; padding: 24px; }.dashboard-hero::after, .portfolio-signal { display: none; }.dashboard-health-strip { grid-template-columns: repeat(2, 1fr); }.dashboard-layout { gap: 13px; }.repository-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.workspace-hero { grid-template-columns: 1fr; gap: 14px; }.workspace-identity > div:first-child { display: block; }.workspace-score { justify-items: start; }.workspace-score small { text-align: left; }.workspace-visual { width: 100%; max-width: 250px; min-height: 150px; }.dependency-metrics { grid-template-columns: repeat(2, 1fr); }.settings-section, .settings-section.appearance { grid-template-columns: 1fr; gap: 14px; }.settings-section dl, .settings-section > button, .settings-section .local-form, .settings-section .local-result, .settings-section.appearance dl { grid-column: auto; }.timeline-event { grid-template-columns: 14px 40px minmax(0, 1fr) auto; }.queue-empty { grid-template-columns: 1fr; gap: 10px; }.queue-empty .brand-svg { display: none; } }
@media (max-width: 470px) { .page-content { padding-top: 23px; }.dashboard-hero { padding: 20px; }.dashboard-hero h1 { font-size: 31px; }.health-strip-segment { min-height: 87px; padding: 14px; }.health-strip-segment strong { font-size: 24px; }.repository-grid { grid-template-columns: 1fr; }.portfolio-matrix { grid-template-columns: repeat(8, 1fr); }.matrix-cell { font-size: 7px; }.toolbar-footer { align-items: flex-start; flex-wrap: wrap; }.view-toggle { order: -1; }.workspace-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }.workspace-facts > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.timeline-event { grid-template-columns: 12px 35px minmax(0, 1fr); }.timeline-event b { display: none; }.dependency-metrics { grid-template-columns: 1fr 1fr; }.settings-section { padding: 17px; } }
/* v1.3 portfolio command deck ------------------------------------------------ */
.cockpit-page { gap: 15px; }
.dashboard-command { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 235px 285px; align-items: center; min-height: 198px; overflow: hidden; border: 1px solid rgba(130, 204, 202, .2); border-radius: 22px; padding: 28px 32px; background: linear-gradient(118deg, rgba(15, 42, 57, .98), rgba(10, 25, 42, .98) 52%, rgba(19, 35, 55, .94)); box-shadow: 0 28px 80px rgba(0, 0, 0, .24); }
.dashboard-command::before { position: absolute; inset: 0; background: linear-gradient(90deg, transparent 0 47%, rgba(117, 209, 204, .09) 47.1%, transparent 47.2%), repeating-linear-gradient(0deg, transparent 0 33px, rgba(133, 176, 201, .04) 34px); content: ''; pointer-events: none; }
.dashboard-command > * { position: relative; z-index: 1; }.command-copy h1 { margin: 7px 0 7px; color: var(--text); font-size: clamp(34px, 4vw, 55px); letter-spacing: -.075em; line-height: .98; }.command-copy h1 span { color: var(--accent-bright); }.command-copy h1 small { margin-left: 8px; color: var(--text-subtle); font-size: .38em; letter-spacing: -.03em; }.command-copy p { margin: 0; color: var(--text-muted); font-size: 12px; }.command-pulse { display: grid; justify-items: center; color: var(--accent); }.command-pulse svg { width: 205px; height: 104px; overflow: visible; }.pulse-field { fill: none; stroke: currentColor; stroke-opacity: .15; stroke-width: 11; }.pulse-line { fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 3; filter: drop-shadow(0 0 10px rgba(117, 209, 204, .35)); }.command-pulse circle { fill: var(--accent-bright); filter: drop-shadow(0 0 8px var(--accent)); }.command-pulse strong { margin-top: -51px; color: var(--text); font-size: 27px; letter-spacing: -.06em; }.command-pulse span { margin-top: 4px; color: var(--text-subtle); font-size: 9px; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; }.command-context { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; margin: 0; overflow: hidden; border: 1px solid rgba(135, 174, 200, .17); border-radius: 12px; background: rgba(135, 174, 200, .12); }.command-context div { display: grid; gap: 5px; padding: 15px 12px; background: rgba(7, 19, 33, .36); }.command-context dt { color: var(--text-subtle); font-size: 9px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }.command-context dd { margin: 0; color: var(--text); font-size: 23px; font-weight: 760; letter-spacing: -.07em; }.command-context div:first-child dd { color: var(--blocked); }.command-context div:nth-child(2) dd { color: var(--warning); }.command-context div:last-child dd { color: var(--ready); }
.dashboard-readiness-rail { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; overflow: hidden; border: 1px solid var(--border); border-radius: 13px; background: var(--border); }.readiness-rail-segment { display: grid; justify-items: start; min-height: 92px; border: 0; border-radius: 0; padding: 14px 19px; color: var(--text); background: rgba(11, 25, 40, .91); text-align: left; }.readiness-rail-segment:hover:not(:disabled) { border: 0; background: linear-gradient(135deg, rgba(117, 209, 204, .16), rgba(19, 42, 61, .9)); }.readiness-rail-segment > span { color: var(--text-subtle); font-size: 9px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; }.readiness-rail-segment strong { margin: 3px 0; font-size: 27px; letter-spacing: -.07em; }.readiness-rail-segment small { color: var(--text-muted); font-size: 10px; }.readiness-rail-segment small b { margin-left: 5px; color: var(--accent); font-size: 14px; }.readiness-rail-segment.blocked strong { color: var(--blocked); }.readiness-rail-segment.warnings strong { color: var(--warning); }.readiness-rail-segment.ready strong { color: var(--ready); }.readiness-rail-segment.all strong { color: var(--accent-bright); }
.cockpit-focus-grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(330px, .75fr); gap: 15px; }.dashboard-section { border-color: rgba(132, 160, 193, .18); background: linear-gradient(135deg, rgba(13, 29, 46, .9), rgba(10, 22, 37, .82)); }.attention-section { padding: 19px; }.attention-list { gap: 3px; }.attention-row { grid-template-columns: 25px 49px minmax(0, 1fr) auto; gap: 10px; min-height: 58px; padding: 7px 8px; border: 1px solid transparent; border-radius: 10px; }.attention-row:hover:not(:disabled), .attention-row:focus-visible { border-color: rgba(117, 209, 204, .36); background: rgba(117, 209, 204, .065); }.attention-rank { color: var(--text-subtle); font-size: 9px; font-weight: 750; letter-spacing: .08em; }.attention-row .repository-visual.compact { width: 46px; height: 46px; }.attention-copy { min-width: 0; }.attention-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.attention-copy small { overflow: hidden; color: var(--text-muted); font-size: 10px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; }.attention-copy em { display: block; margin-top: 3px; color: var(--text-subtle); font-size: 9px; font-style: normal; }.attention-row b { color: var(--blocked); font-size: 22px; }.next-move { position: relative; display: grid; align-content: start; overflow: hidden; padding: 21px; background: linear-gradient(145deg, rgba(18, 47, 59, .88), rgba(9, 22, 38, .96)); }.next-move::after { position: absolute; right: -45px; bottom: -75px; width: 190px; height: 190px; border: 1px solid rgba(117, 209, 204, .15); border-radius: 50%; box-shadow: 0 0 0 28px rgba(117, 209, 204, .025), 0 0 0 58px rgba(117, 209, 204, .018); content: ''; pointer-events: none; }.next-move > * { position: relative; z-index: 1; }.next-move h2 { margin: 6px 0 14px; overflow: hidden; font-size: 20px; text-overflow: ellipsis; white-space: nowrap; }.next-move-action { display: grid; grid-template-columns: 122px minmax(0, 1fr); align-items: center; gap: 13px; width: 100%; border: 1px solid rgba(117, 209, 204, .18); padding: 10px; color: var(--text); background: rgba(5, 17, 29, .38); text-align: left; }.next-move-action:hover:not(:disabled) { border-color: var(--accent); background: rgba(10, 35, 49, .76); }.next-move-action .repository-visual { min-height: 94px; }.next-move-action span { min-width: 0; }.next-move-action strong, .next-move-action small, .next-move-action b { display: block; }.next-move-action strong { color: var(--accent-bright); font-size: 24px; letter-spacing: -.07em; }.next-move-action small { margin-top: 5px; color: var(--text-muted); font-size: 10px; line-height: 1.45; }.next-move-action b { margin-top: 11px; color: var(--accent); font-size: 10px; }
.portfolio-atlas { padding: 20px; }.atlas-heading { align-items: end; }.atlas-field { position: relative; overflow: hidden; border: 1px solid rgba(132, 160, 193, .14); border-radius: 13px; background: radial-gradient(ellipse at 70% 0, rgba(77, 166, 175, .1), transparent 45%), rgba(5, 16, 27, .52); }.atlas-contours { position: absolute; inset: 0; width: 100%; height: 100%; overflow: hidden; pointer-events: none; }.atlas-contours path { fill: none; stroke: rgba(117, 209, 204, .12); stroke-width: 1; }.atlas-contours path:nth-child(2) { stroke: rgba(233, 187, 113, .1); stroke-dasharray: 4 6; }.atlas-contours path:nth-child(3) { stroke: rgba(237, 113, 136, .12); }.atlas-bands { position: relative; z-index: 1; display: grid; gap: 1px; background: rgba(135, 174, 200, .1); }.atlas-band { display: grid; grid-template-columns: 180px minmax(0, 1fr); min-height: 96px; background: rgba(8, 21, 35, .84); }.atlas-band > header { display: grid; align-content: center; gap: 3px; padding: 14px 17px; border-right: 1px solid rgba(135, 174, 200, .1); background: linear-gradient(90deg, rgba(16, 34, 51, .74), transparent); }.atlas-band > header span { color: var(--text-subtle); font-size: 9px; font-weight: 720; letter-spacing: .06em; text-transform: uppercase; }.atlas-band > header strong { color: var(--text); font-size: 28px; letter-spacing: -.07em; }.atlas-band > header small { color: var(--text-muted); font-size: 9px; line-height: 1.25; }.atlas-band.blocked > header strong { color: var(--blocked); }.atlas-band.warnings > header strong { color: var(--warning); }.atlas-band.ready > header strong { color: var(--ready); }.atlas-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(57px, 1fr)); align-content: center; gap: 6px; margin: 0; padding: 10px; list-style: none; }.atlas-repository { position: relative; display: grid; align-content: space-between; width: 100%; min-width: 0; height: 58px; overflow: hidden; border: 1px solid rgba(149, 184, 207, .16); border-radius: 8px; padding: 7px; color: var(--text); background: linear-gradient(145deg, rgba(29, 55, 75, .8), rgba(13, 29, 45, .82)); text-align: left; }.atlas-repository::before { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(255,255,255,.07), transparent 55%); content: ''; pointer-events: none; }.atlas-repository:hover:not(:disabled), .atlas-repository:focus-visible { z-index: 2; border-color: var(--accent); background: linear-gradient(145deg, rgba(46, 96, 110, .94), rgba(13, 40, 53, .98)); box-shadow: 0 0 0 3px rgba(117, 209, 204, .12), 0 10px 20px rgba(0, 0, 0, .24); transform: translateY(-2px); }.atlas-repository-name, .atlas-repository-health { position: relative; z-index: 1; overflow: hidden; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; }.atlas-repository-name { color: rgba(244, 250, 255, .84); font-size: 9px; font-weight: 770; letter-spacing: .04em; }.atlas-repository-health { align-self: end; color: var(--text-muted); font-size: 11px; font-weight: 700; }.atlas-repository > i { position: absolute; z-index: 1; bottom: 5px; left: 7px; width: var(--signal-strength); max-width: calc(100% - 14px); height: 2px; border-radius: 2px; background: var(--accent); box-shadow: 0 0 8px rgba(117, 209, 204, .45); }.atlas-band.blocked .atlas-repository > i { background: var(--blocked); box-shadow: 0 0 8px rgba(237, 113, 136, .42); }.atlas-band.warnings .atlas-repository > i { background: var(--warning); box-shadow: 0 0 8px rgba(233, 187, 113, .38); }.atlas-band.ready .atlas-repository > i { background: var(--ready); box-shadow: 0 0 8px rgba(100, 211, 155, .38); }.atlas-repository.stale { border-style: dashed; }.atlas-repository.scanning::after { position: absolute; inset: 3px; border: 1px solid currentColor; border-radius: 5px; content: ''; animation: atlas-scan 1.35s var(--ease) infinite; }.atlas-legend { display: flex; flex-wrap: wrap; gap: 14px; margin: 11px 1px 0; color: var(--text-subtle); font-size: 9px; }.atlas-legend span { display: inline-flex; align-items: center; gap: 5px; }.atlas-legend i { width: 14px; height: 2px; border-radius: 2px; background: var(--accent); }.atlas-legend .blocked { background: var(--blocked); }.atlas-legend .stale { height: 6px; border: 1px dashed var(--text-subtle); background: transparent; }.atlas-legend .scanning { width: 7px; height: 7px; border: 1px solid var(--accent); border-radius: 50%; background: transparent; }.portfolio-atlas[data-density='dense'] .atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); gap: 4px; }.portfolio-atlas[data-density='dense'] .atlas-repository { height: 45px; padding: 5px; }.portfolio-atlas[data-density='dense'] .atlas-repository-name { font-size: 8px; }.portfolio-atlas[data-density='compact'] .atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(28px, 1fr)); gap: 3px; }.portfolio-atlas[data-density='compact'] .atlas-repository { height: 31px; padding: 4px; border-radius: 5px; }.portfolio-atlas[data-density='compact'] .atlas-reposiLine truncated
.dashboard-bottom-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(330px, .85fr); gap: 15px; }.dashboard-bottom-grid .dashboard-section { padding: 18px; }.activity-rail-item { border-radius: 8px; }.pinned-tile { grid-template-columns: 46px minmax(0, 1fr) auto; }.pinned-tile b { color: var(--accent); }.repository-visual { border-color: rgba(140, 187, 207, .15); color: #8aaac7; background: radial-gradient(ellipse at 70% 20%, rgba(93, 159, 181, .18), transparent 58%), #091827; }.repository-visual.state-ready { color: var(--ready); }.repository-visual.state-warnings { color: var(--warning); }.repository-visual.state-blocked { color: var(--blocked); }.repository-visual svg { overflow: hidden; }.visual-wash { opacity: .75; }.visual-link { transition: stroke-opacity var(--motion) var(--ease); }.visual-contour { transform-origin: center; }.repository-visual:not(.compact):hover .contour-one { animation: visual-drift 2.8s var(--ease) infinite alternate; }.repository-visual:not(.compact):hover .visual-link { stroke-opacity: .95; }.visual-node { transform-origin: center; transition: opacity var(--motion) var(--ease); }.visual-node.hot { animation: visual-node-breathe 2.7s var(--ease) infinite; }.visual-caption { position: absolute; top: 9px; left: 10px; display: flex; gap: 5px; align-items: center; }.visual-caption i { border: 1px solid currentColor; border-radius: 99px; padding: 2px 4px; color: currentColor; font-size: 7px; font-style: normal; font-weight: 760; letter-spacing: .09em; }.visual-caption b { color: rgba(238, 247, 255, .76); font-size: 8px; font-weight: 650; }.repository-visual.compact .visual-health-line, .repository-visual.compact .visual-contour { opacity: .65; }.workspace-visual { min-height: 132px; }.workspace-visual .visual-caption { top: 11px; left: 12px; }.workspace-visual .visual-caption b { font-size: 9px; }.repository-tile .repository-visual { min-height: 124px; }.repository-tile-main:hover .repository-visual { box-shadow: 0 14px 24px rgba(0, 0, 0, .18); }
@keyframes atlas-scan { 0% { opacity: .8; transform: scale(.82); } 100% { opacity: 0; transform: scale(1.06); } } @keyframes visual-drift { to { transform: translateX(4px); } } @keyframes visual-node-breathe { 50% { opacity: .7; } }
@media (max-width: 1180px) { .dashboard-command { grid-template-columns: minmax(0, 1fr) 205px; }.command-context { grid-column: 1 / -1; max-width: 420px; }.cockpit-focus-grid, .dashboard-bottom-grid { grid-template-columns: 1fr; }.next-move { min-height: 180px; }.atlas-band { grid-template-columns: 158px minmax(0, 1fr); } }
@media (max-width: 760px) { .dashboard-command { grid-template-columns: 1fr; min-height: 0; padding: 23px 20px; }.command-pulse { position: absolute; right: -16px; bottom: -23px; opacity: .32; }.command-context { max-width: none; margin-top: 18px; }.command-copy { padding-right: 40px; }.command-copy h1 { font-size: 39px; }.dashboard-readiness-rail { grid-template-columns: repeat(2, 1fr); }.readiness-rail-segment { min-height: 78px; padding: 12px; }.readiness-rail-segment strong { font-size: 23px; }.cockpit-focus-grid { gap: 12px; }.attention-section, .next-move, .portfolio-atlas, .dashboard-bottom-grid .dashboard-section { padding: 15px; }.next-move-action { grid-template-columns: 105px minmax(0, 1fr); }.atlas-band { grid-template-columns: 1fr; }.atlas-band > header { grid-template-columns: minmax(0, 1fr) auto; gap: 2px 9px; border-right: 0; border-bottom: 1px solid rgba(135, 174, 200, .1); padding: 11px 13px; }.atlas-band > header strong { grid-row: 1 / 3; grid-column: 2; align-self: center; }.atlas-band > header small { grid-column: 1; }.atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(49px, 1fr)); padding: 8px; }.atlas-legend { gap: 8px; }.dashboard-bottom-grid { gap: 12px; }.workspace-visual { max-width: none; } }
@media (max-width: 470px) { .command-context div { padding: 11px 9px; }.command-context dd { font-size: 20px; }.attention-row { grid-template-columns: 20px 42px minmax(0, 1fr) auto; gap: 7px; padding: 7px 3px; }.attention-row .repository-visual.compact { width: 40px; height: 40px; }.attention-copy small { white-space: normal; }.attention-copy em { display: none; }.next-move-action { grid-template-columns: 88px minmax(0, 1fr); }.next-move-action .repository-visual { min-height: 76px; }.atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); gap: 4px; }.atlas-repository { height: 48px; padding: 5px; }.atlas-repository-name { font-size: 8px; }.atlas-legend { display: grid; }.portfolio-atlas[data-density='compact'] .atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(23px, 1fr)); } }
@media (prefers-reduced-motion: reduce) { .atlas-repository, .visual-link, .visual-node, .repository-visual, .next-move-action { transition: none !important; }.atlas-repository.scanning::after, .repository-visual:not(.compact):hover .contour-one, .visual-node.hot { animation: none !important; }.atlas-repository:hover:not(:disabled), .atlas-repository:focus-visible { transform: none; } }
@media (max-width: 760px) { .command-pulse strong, .command-pulse span { display: none; } }
/* v1.4 remediation cockpit: decision provenance, layered surfaces and quiet technical motion. */
.cockpit-page { gap: 18px; }.dashboard-command { isolation: isolate; min-height: 212px; border-color: rgba(139, 222, 215, .24); border-radius: 26px; background: radial-gradient(ellipse at 18% 4%, rgba(50, 131, 145, .25), transparent 43%), radial-gradient(ellipse at 79% 82%, rgba(105, 95, 165, .15), transparent 48%), linear-gradient(118deg, #102d40, #0b1d31 49%, #111d35); box-shadow: 0 30px 88px rgba(0, 0, 0, .31), inset 0 1px rgba(231, 255, 252, .07); }.dashboard-command::after { position: absolute; z-index: 0; top: -48px; left: 42%; width: 300px; height: 300px; border: 1px solid rgba(116, 218, 211, .11); border-radius: 50%; box-shadow: 0 0 0 34px rgba(116, 218, 211, .025), 0 0 0 78px rgba(116, 218, 211, .015); content: ''; pointer-events: none; }.command-copy { max-width: 565px; }.command-copy h1 { text-wrap: balance; }.command-trend { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }.command-trend span { display: inline-flex; align-items: center; gap: 6px; border: 1px solid rgba(150, 189, 211, .16); border-radius: 999px; padding: 5px 8px; color: var(--text-muted); background: rgba(5, 17, 29, .28); font-size: 9px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }.command-trend i { width: 6px; height: 6px; border-radius: 50%; background: var(--ready); box-shadow: 0 0 0 3px rgba(100, 211, 155, .11); }.command-trend .trend-watch { background: var(--warning); box-shadow: 0 0 0 3px rgba(233, 187, 113, .11); }.command-context { backdrop-filter: blur(12px); box-shadow: inset 0 1px rgba(255, 255, 255, .035); }.command-context div { background: linear-gradient(160deg, rgba(13, 35, 52, .68), rgba(6, 17, 30, .47)); }
.dashboard-readiness-rail { gap: 6px; overflow: visible; border: 0; border-radius: 0; background: transparent; }.readiness-rail-segment { position: relative; overflow: hidden; min-height: 99px; border: 1px solid rgba(132, 160, 193, .16); border-radius: 15px; background: linear-gradient(145deg, rgba(16, 35, 52, .88), rgba(8, 20, 34, .88)); box-shadow: inset 0 1px rgba(255,255,255,.025); }.readiness-rail-segment::after { position: absolute; right: 12px; bottom: 11px; width: 30px; height: 1px; background: currentColor; content: ''; opacity: .34; transform: rotate(-38deg); transform-origin: right; }.readiness-rail-segment:hover:not(:disabled) { border-color: rgba(117, 209, 204, .42); border-radius: 15px; box-shadow: 0 13px 24px rgba(0, 0, 0, .18); transform: translateY(-2px); }.readiness-rail-segment.blocked { color: var(--blocked); }.readiness-rail-segment.warnings { color: var(--warning); }.readiness-rail-segment.ready { color: var(--ready); }.readiness-rail-segment.all { color: var(--accent); }.readiness-rail-segment > span, .readiness-rail-segment small { color: var(--text-subtle); }.readiness-rail-segment strong { color: currentColor !important; }
.cockpit-focus-grid { align-items: stretch; gap: 18px; }.dashboard-section { border-radius: 18px; box-shadow: inset 0 1px rgba(255, 255, 255, .025), 0 15px 35px rgba(0, 0, 0, .1); }.attention-section { position: relative; overflow: hidden; padding: 22px; background: linear-gradient(145deg, rgba(15, 34, 52, .94), rgba(8, 20, 33, .91)); }.attention-section::before { position: absolute; top: 0; bottom: 0; left: 0; width: 3px; background: linear-gradient(var(--blocked), rgba(237, 113, 136, 0)); content: ''; opacity: .78; }.attention-list { gap: 5px; margin-top: 7px; }.attention-row { position: relative; grid-template-columns: 26px 52px minmax(0, 1fr) auto; min-height: 66px; overflow: hidden; border-color: rgba(132, 160, 193, .07); padding: 8px 10px; background: linear-gradient(90deg, rgba(7, 18, 30, .44), transparent); }.attention-row::before { position: absolute; top: 12px; bottom: 12px; left: 0; width: 2px; border-radius: 99px; background: var(--blocked); content: ''; opacity: .55; }.attention-row:hover:not(:disabled), .attention-row:focus-visible { border-color: rgba(117, 209, 204, .34); background: linear-gradient(90deg, rgba(117, 209, 204, .1), rgba(24, 52, 65, .12)); }.attention-copy { display: grid; align-content: center; gap: 3px; }.attention-copy small { line-height: 1.35; }.attention-context { display: flex; align-items: center; gap: 7px; min-width: 0; }.attention-context i, .next-move-context i { max-width: 116px; overflow: hidden; border: 1px solid rgba(117, 209, 204, .2); border-radius: 999px; padding: 2px 5px; color: var(--accent-bright); background: rgba(117, 209, 204, .07); font-size: 8px; font-style: normal; font-weight: 720; letter-spacing: .04em; text-overflow: ellipsis; white-space: nowrap; }.attention-context em { overflow: hidden; margin: 0; color: var(--text-subtle); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }.attention-score { display: grid; justify-items: end; align-content: center; gap: 3px; }.attention-score b { color: var(--blocked); font-size: 23px; }.attention-score small { color: var(--text-subtle); font-size: 8px; white-space: nowrap; }
.next-move { border-color: rgba(117, 209, 204, .25); background: radial-gradient(ellipse at 84% 8%, rgba(77, 185, 178, .22), transparent 44%), linear-gradient(150deg, rgba(18, 52, 64, .96), rgba(8, 19, 33, .98)); }.next-move::before { position: absolute; top: 0; right: 0; width: 52%; height: 1px; background: linear-gradient(90deg, transparent, rgba(117, 209, 204, .72)); content: ''; }.next-move-action { border-radius: 13px; background: linear-gradient(125deg, rgba(5, 19, 30, .6), rgba(23, 50, 62, .35)); box-shadow: inset 0 1px rgba(255,255,255,.035); }.next-move-action:hover:not(:disabled) { box-shadow: 0 17px 28px rgba(0, 0, 0, .2), inset 0 1px rgba(255,255,255,.05); }.next-move-context { display: grid; gap: 5px; margin-top: 10px; color: var(--text-muted); font-size: 9px; font-style: normal; line-height: 1.4; }.next-move-context i { width: fit-content; }
.portfolio-atlas { padding: 22px; background: linear-gradient(145deg, rgba(13, 31, 48, .92), rgba(7, 18, 30, .9)); }.atlas-field { border-radius: 19px; background: radial-gradient(ellipse at 78% 0, rgba(77, 166, 175, .17), transparent 47%), radial-gradient(ellipse at 12% 100%, rgba(105, 80, 140, .09), transparent 49%), rgba(4, 15, 26, .74); }.atlas-bands { gap: 10px; padding: 10px; background: transparent; }.atlas-band { grid-template-columns: 196px minmax(0, 1fr); min-height: 104px; overflow: hidden; border: 1px solid rgba(144, 184, 207, .12); border-radius: 14px; background: linear-gradient(90deg, rgba(13, 34, 50, .92), rgba(5, 17, 29, .62)); }.atlas-band > header { position: relative; gap: 4px; overflow: hidden; border-right: 1px solid rgba(135, 174, 200, .1); padding: 16px 17px 15px 21px; background: linear-gradient(100deg, rgba(20, 49, 66, .92), rgba(12, 29, 43, .22)); }.atlas-lane-beacon { position: absolute; top: 16px; left: 10px; width: 3px; height: 32px; border-radius: 99px; background: var(--accent); box-shadow: 0 0 17px currentColor; }.atlas-band.blocked .atlas-lane-beacon { color: var(--blocked); background: var(--blocked); }.atlas-band.warnings .atlas-lane-beacon { color: var(--warning); background: var(--warning); }.atlas-band.ready .atlas-lane-beacon { color: var(--ready); background: var(--ready); }.atlas-band > header strong { font-size: 31px; }.atlas-river { position: relative; min-width: 0; overflow: hidden; }.atlas-river::before { position: absolute; top: 50%; right: 0; left: 0; height: 1px; background: linear-gradient(90deg, rgba(117, 209, 204, .04), rgba(117, 209, 204, .25), rgba(117, 209, 204, .03)); content: ''; }.atlas-tiles { position: relative; grid-template-columns: repeat(auto-fill, minmax(61px, 1fr)); gap: 8px; padding: 13px 14px; }.atlas-repository { height: 62px; border-radius: 12px; background: linear-gradient(150deg, rgba(35, 69, 87, .86), rgba(10, 26, 42, .92)); box-shadow: inset 0 1px rgba(255,255,255,.045), 0 7px 12px rgba(0,0,0,.12); transform: translateY(calc((var(--signal-phase) - 3) * 1px)); transition: transform var(--motion) var(--ease), border-color var(--motion) var(--ease), background var(--motion) var(--ease), box-shadow var(--motion) var(--ease); }.atlas-repository::before { background: linear-gradient(135deg, rgba(255,255,255,.11), transparent 46%); }.atlas-repository-orbit { position: absolute; top: 8px; right: 7px; width: 15px; height: 15px; border: 1px solid currentColor; border-radius: 50%; opacity: .18; }.atlas-repository-orbit::after { position: absolute; top: 2px; right: -2px; width: 3px; height: 3px; border-radius: 50%; background: currentColor; content: ''; }.atlas-repository:hover:not(:disabled), .atlas-repository:focus-visible { border-color: currentColor; box-shadow: 0 0 0 3px rgba(117, 209, 204, .11), 0 16px 24px rgba(0, 0, 0, .28); transform: translateY(-4px) scale(1.035); }.atlas-repository-name { padding-right: 12px; font-size: 9px; }.atlas-repository > i { height: 3px; }.portfolio-atlas[data-density='dense'] .atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(47px, 1fr)); gap: 5px; padding: 10px; }.portfolio-atlas[data-density='dense'] .atlas-repository { height: 49px; border-radius: 9px; }.portfolio-atlas[data-density='compact'] .atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(30px, 1fr)); gap: 3px; padding: 8px; }.portfolio-atlas[data-density='compact'] .atlas-repository { height: 33px; border-radius: 6px; transform: none; }.portfolio-atlas[data-density='compact'] .atlas-repository-orbit { display: none; }
.repository-visual { border-radius: 14px; background: radial-gradient(ellipse at 78% 18%, rgba(105, 193, 187, .18), transparent 54%), linear-gradient(140deg, #0b2031, #081624); box-shadow: inset 0 1px rgba(255, 255, 255, .045); }.visual-gridline { stroke-dasharray: 2 5; }.visual-orbit { transform-origin: center; }.visual-core-beacon { filter: drop-shadow(0 0 5px currentColor); }.repository-visual:not(.compact):hover .orbit-one { animation: visual-orbit-spin 8s linear infinite; }.repository-visual:not(.compact):hover .orbit-two { animation: visual-orbit-spin 12s linear infinite reverse; }.workspace-hero { position: relative; isolation: isolate; border-color: rgba(132, 184, 207, .22); border-radius: 22px; background: radial-gradient(ellipse at 72% 15%, rgba(84, 177, 174, .17), transparent 39%), linear-gradient(120deg, rgba(16, 40, 56, .97), rgba(10, 23, 38, .97)); box-shadow: 0 24px 54px rgba(0,0,0,.18); }.workspace-hero::before { position: absolute; z-index: 0; top: -65px; right: 130px; width: 230px; height: 230px; border: 1px solid rgba(117, 209, 204, .1); border-radius: 50%; box-shadow: 0 0 0 28px rgba(117, 209, 204, .02); content: ''; }.workspace-hero > * { position: relative; z-index: 1; }.workspace-score { min-width: 100px; padding: 12px 0; border-right: 1px solid rgba(132, 184, 207, .13); }.workspace-score strong { text-shadow: 0 0 20px rgba(117, 209, 204, .25); }.workspace-visual { border-color: rgba(117, 209, 204, .26); box-shadow: 0 16px 30px rgba(0,0,0,.2); }.repository-tile { border-radius: 16px; background: linear-gradient(145deg, rgba(17, 37, 55, .92), rgba(8, 19, 32, .9)); }.repository-tile-main:hover:not(:disabled) { background: radial-gradient(ellipse at 80% 10%, rgba(117,209,204,.12), transparent 50%); }
@keyframes visual-orbit-spin { to { transform: rotate(360deg); } }
@media (max-width: 1180px) { .atlas-band { grid-template-columns: 168px minmax(0, 1fr); }.command-trend { margin-bottom: 2px; } }
@media (max-width: 760px) { .dashboard-command { min-height: 0; }.command-trend { margin-top: 12px; }.dashboard-readiness-rail { gap: 7px; }.readiness-rail-segment:hover:not(:disabled) { transform: none; }.attention-section { padding: 17px; }.attention-row { grid-template-columns: 20px 44px minmax(0, 1fr) auto; min-height: 62px; gap: 8px; }.attention-score small { display: none; }.attention-context em { font-size: 8px; }.atlas-bands { gap: 7px; padding: 7px; }.atlas-band { grid-template-columns: 1fr; }.atlas-band > header { padding: 12px 14px 12px 19px; }.atlas-lane-beacon { top: 14px; left: 9px; height: 25px; }.atlas-tiles { grid-template-columns: repeat(auto-fill, minmax(50px, 1fr)); gap: 5px; padding: 10px; }.atlas-repository { height: 50px; border-radius: 9px; transform: none; }.workspace-score { border-right: 0; border-bottom: 1px solid rgba(132, 184, 207, .13); }.workspace-hero::before { right: -45px; }.next-move-context { font-size: 8px; } }
@media (prefers-reduced-motion: reduce) { .readiness-rail-segment, .atlas-repository { transition: none !important; }.repository-visual:not(.compact):hover .orbit-one, .repository-visual:not(.compact):hover .orbit-two { animation: none !important; }.atlas-repository:hover:not(:disabled), .atlas-repository:focus-visible { transform: none; } }
.overview-intelligence { display: grid; grid-template-columns: minmax(0, .83fr) minmax(0, 1.17fr); gap: 14px; }.change-brief > .muted { margin: 14px 0 2px; font-size: 11px; }.change-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; margin: 14px -18px -18px; border-top: 1px solid var(--border); background: var(--border); }.change-grid span { display: grid; gap: 4px; padding: 12px; color: var(--text-subtle); background: rgba(7, 17, 28, .28); font-size: 9px; text-transform: uppercase; }.change-grid b { color: var(--accent-bright); font-size: 17px; letter-spacing: -.04em; text-transform: none; }.top-findings-list { display: grid; gap: 2px; }.top-findings-list > div { display: grid; grid-template-columns: 64px minmax(0, 1fr) auto; align-items: center; gap: 9px; border-radius: 7px; padding: 7px 4px; }.top-findings-list > div:hover { background: rgba(117, 209, 204, .055); }.top-findings-list strong, .top-findings-list small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.top-findings-list strong { color: var(--text); font-size: 11px; }.top-findings-list small { margin-top: 2px; color: var(--text-subtle); font-size: 9px; }.top-findings-list b { color: var(--blocked); font-size: 11px; }
.architecture-workspace { padding: 0; overflow: hidden; }.architecture-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; border-bottom: 1px solid var(--border); padding: 12px 15px; color: var(--text-subtle); font-size: 10px; }.architecture-toolbar label { width: min(360px, 100%); }.architecture-toolbar input { min-height: 35px; }.architecture-main { display: grid; grid-template-columns: minmax(0, 1fr) 230px; min-height: 590px; }.architecture-workspace .architecture-canvas { height: auto; min-height: 590px; border: 0; border-radius: 0; }.architecture-inspector { border-left: 1px solid var(--border); padding: 17px; background: rgba(5, 14, 24, .35); }.architecture-inspector h3 { margin: 6px 0; color: var(--text); font-size: 15px; }.architecture-inspector > code { display: block; margin-bottom: 16px; overflow-wrap: anywhere; color: var(--accent-bright); font-size: 10px; }.architecture-inspector p { color: var(--text-muted); font-size: 11px; line-height: 1.5; }.architecture-inspector dl { display: grid; gap: 8px; margin: 0; }.architecture-inspector dl > div { display: flex; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--border); padding-bottom: 7px; }.architecture-inspector dt { color: var(--text-subtle); font-size: 10px; }.architecture-inspector dd { margin: 0; color: var(--text); font-size: 11px; }.react-flow__node.arch-selected { border-color: var(--accent) !important; box-shadow: 0 0 0 2px rgba(117, 209, 204, .14), 0 10px 28px rgba(0, 0, 0, .3) !important; }.react-flow__edge.animated .react-flow__edge-path { stroke: var(--accent) !important; }
@media (max-width: 760px) { .overview-intelligence { grid-template-columns: 1fr; }.change-grid { grid-template-columns: 1fr 1fr; }.architecture-main { grid-template-columns: 1fr; }.architecture-workspace .architecture-canvas { min-height: 460px; }.architecture-inspector { min-height: 160px; border-top: 1px solid var(--border); border-left: 0; }.architecture-toolbar { align-items: stretch; flex-direction: column; }.architecture-toolbar label { width: 100%; } }
/* v1.5: compact remediation queue driven only by retained backend evidence. */
.remediation-center { padding: 19px; background: linear-gradient(135deg, rgba(15, 38, 53, .94), rgba(8, 20, 34, .9)); }.remediation-live { color: var(--accent-bright); font-size: 9px; font-weight: 720; letter-spacing: .08em; text-transform: uppercase; }.remediation-list { display: grid; gap: 5px; margin: 10px 0 0; padding: 0; list-style: none; }.remediation-row { display: grid; grid-template-columns: 42px minmax(0, 1fr) auto; align-items: center; gap: 12px; width: 100%; border: 1px solid rgba(132, 160, 193, .12); border-radius: 11px; padding: 11px; color: var(--text); background: rgba(5, 17, 29, .34); text-align: left; transition: border-color var(--motion) var(--ease), background var(--motion) var(--ease); }.remediation-row:hover:not(:disabled), .remediation-row:focus-visible { border-color: rgba(117, 209, 204, .42); background: rgba(117, 209, 204, .075); }.remediation-priority { display: grid; place-items: center; width: 38px; height: 38px; border: 1px solid rgba(237, 113, 136, .34); border-radius: 50%; color: var(--blocked); background: rgba(237, 113, 136, .09); font-size: 11px; font-weight: 800; }.remediation-copy { display: grid; min-width: 0; gap: 3px; }.remediation-copy strong, .remediation-copy small, .remediation-copy em, .remediation-copy b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.remediation-copy strong { font-size: 12px; }.remediation-copy small { color: var(--accent-bright); font-size: 9px; text-transform: capitalize; }.remediation-copy em { color: var(--text-muted); font-size: 10px; font-style: normal; }.remediation-copy b { color: var(--text-subtle); font-size: 9px; font-weight: 500; }.remediation-arrow { color: var(--accent); font-size: 18px; }.remediation-row:hover .remediation-arrow { transform: translateX(2px); }
@media (max-width: 760px) { .remediation-center { padding: 15px; }.remediation-row { grid-template-columns: 34px minmax(0, 1fr) auto; gap: 9px; padding: 9px; }.remediation-priority { width: 31px; height: 31px; }.remediation-copy b { display: none; } }
.repository-table th { position: static; }
.repository-table tbody tr { scroll-margin-top: 150px; }
+155
View File
@@ -0,0 +1,155 @@
import type { AppMeta, RepositoryDetail, RepositoryPolicy, RepositorySummary, ScanComparison, ScanReport, ScanSummary, TrendPoint } from './api'
export const testPolicy: RepositoryPolicy = {
auto_scan: true,
min_health_score: 70,
max_blockers: 0,
require_tests: true,
require_ci: true,
suppressions: [],
}
export function testRepository(id = 'gitea:64', fullName = 'Jens/RepoPulse', overrides: Partial<RepositorySummary> = {}): RepositorySummary {
const [owner, name] = fullName.split('/')
return {
id,
provider: id.startsWith('local:') ? 'local' : 'gitea',
provider_repository_id: id.split(':')[1] ?? id,
owner: owner ?? 'Jens',
name: name ?? fullName,
full_name: fullName,
description: 'Test repository',
html_url: null,
clone_url: null,
default_branch: 'main',
private: true,
archived: false,
available: true,
latest_revision: 'revision-1',
latest_revision_at: '2026-09-12T20:00:00Z',
last_synced_at: '2026-09-12T20:00:00Z',
sync_error: null,
state: 'current',
stale: false,
active_job_id: null,
last_scan_id: 'scan-1',
last_scan_at: '2026-09-12T20:00:00Z',
last_scan_health: 82,
last_scan_readiness: 'BLOCKED',
last_scan_revision: 'revision-1',
last_scan_blockers: 1,
last_scan_warnings: 0,
last_scan_primary_signal: { code: 'security.secret-assignment', category: 'Security basics', severity: 'blocker', message: 'Potential credential assignment detected; value intentionally redacted', count: 1 },
last_scan_health_delta: null,
last_scan_added_findings: 0,
last_scan_resolved_findings: 0,
policy: testPolicy,
...overrides,
}
}
export function testReport(overrides: Partial<ScanReport> = {}): ScanReport {
return {
id: 'scan-1',
repository_path: '/scan/repopulse',
repository_name: 'RepoPulse',
source: {
origin: 'gitea',
provider: 'gitea',
repository_id: '64',
repository_full_name: 'Jens/RepoPulse',
branch: 'main',
requested_revision: 'revision-1',
actual_revision: 'revision-1',
trigger: 'scheduled',
},
policy: testPolicy,
scanned_at: '2026-09-12T20:00:00Z',
duration_ms: 1200,
metrics: {
files: 2,
source_files: 2,
lines_of_code: 20,
test_files: 1,
todo: 0,
fixme: 0,
hack: 0,
xxx: 0,
dependency_manifests: 1,
declared_dependencies: 1,
large_files: 0,
large_source_files: 0,
},
languages: { TypeScript: 20 },
category_scores: { 'Security basics': 20, Maintainability: 80 },
health_score: 70,
release_readiness: 'BLOCKED',
findings: [{
code: 'security.secret-assignment',
category: 'Security basics',
severity: 'blocker',
message: 'Potential credential assignment detected; value intentionally redacted',
path: 'backend/app/main.py',
line: 245,
deduction: 20,
fingerprint: 'fingerprint-1',
suppressed: false,
}],
tests: { frameworks: ['pytest'], coverage_artifacts: [], ci_workflows: [], test_source_ratio: null },
git: { available: true, commit_count: 1, contributor_count: 1, latest_commit_at: null, tracked_files: 2, renamed_files: [] },
dependencies: { manifests: ['package.json'], lockfiles: ['package-lock.json'], declared_dependencies: 1, unbounded_dependencies: 0, unpinned_dependencies: 0, duplicate_dependencies: [] },
architecture: { nodes: [], edges: [], clusters: {}, truncated: false, total_nodes: 0, total_edges: 0 },
hotspots: [],
release_gate: { status: 'BLOCKED', checks: [{ key: 'security', label: 'Security basics', state: 'block', detail: '1 blocker finding' }] },
...overrides,
}
}
export const testComparison: ScanComparison = {
current_scan_id: 'scan-1',
previous_scan_id: null,
health_score_delta: null,
category_score_deltas: {},
metric_deltas: {},
added_findings: [],
resolved_findings: [],
}
export const testScans: ScanSummary[] = [{
id: 'scan-1',
repository_id: 'gitea:64',
scanned_at: '2026-09-12T20:00:00Z',
branch: 'main',
requested_revision: 'revision-1',
actual_revision: 'revision-1',
trigger: 'scheduled',
health_score: 70,
release_readiness: 'BLOCKED',
findings: 1,
duration_ms: 1200,
}]
export const testTrend: TrendPoint[] = []
export const testMeta: AppMeta = {
name: 'RepoPulse',
version: '1.5.0',
stage: 'v1.5.0',
scan_root_restricted: true,
scan_root: '/scan',
max_concurrent_scans: 1,
repository_count: 2,
repository_states: { current: 2 },
current_repositories: 2,
stale_repositories: 0,
queue_depth: 0,
active_jobs: 0,
completed_jobs: 3,
failed_jobs: 0,
worker_heartbeat: '2026-09-12T20:00:00Z',
gitea: { configured: true, url: 'http://gitea.test', last_sync: { synced_at: '2026-09-12T20:00:00Z' } },
}
export function testDetail(repository = testRepository()): RepositoryDetail {
return { ...repository, latest_scan: testReport() }
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "vite.config.ts"]
}
+22
View File
@@ -0,0 +1,22 @@
import { loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import { configDefaults, defineConfig } from 'vitest/config'
export default defineConfig(({ mode }) => {
const environment = loadEnv(mode, '.', '')
const apiTarget = environment.REPOPULSE_API_PROXY || 'http://localhost:8000'
return {
plugins: [react()],
server: {
port: 5174,
proxy: {
'/api': apiTarget,
'/health': apiTarget,
},
},
test: {
exclude: [...configDefaults.exclude, 'e2e/**'],
},
}
})
+12
View File
@@ -0,0 +1,12 @@
{
"name": "repopulse-workspace",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "repopulse-workspace",
"version": "1.5.0"
}
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "repopulse-workspace",
"private": true,
"version": "1.5.0",
"scripts": {
"test": "npm --prefix frontend run test",
"typecheck": "npm --prefix frontend run typecheck",
"build": "npm --prefix frontend run build"
}
}
+1
View File
@@ -0,0 +1 @@
-e ./backend[dev]