From 3871c328421232e7446454ba937545ef99d82a31 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 14:54:24 +0000 Subject: [PATCH 1/2] chore(deps): bump the frontend-patches group across 1 directory with 12 updates Bumps the frontend-patches group with 12 updates in the /frontend directory: | Package | From | To | | --- | --- | --- | | [axios](https://github.com/axios/axios) | `1.16.1` | `1.18.1` | | [react-force-graph-2d](https://github.com/vasturiano/react-force-graph) | `1.25.7` | `1.29.1` | | [swr](https://github.com/vercel/swr) | `2.2.5` | `2.4.2` | | [@playwright/test](https://github.com/microsoft/playwright) | `1.60.0` | `1.61.1` | | [@tailwindcss/typography](https://github.com/tailwindlabs/tailwindcss-typography) | `0.5.15` | `0.5.20` | | [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.2` | `6.0.3` | | [autoprefixer](https://github.com/postcss/autoprefixer) | `10.4.20` | `10.5.2` | | [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.4.16` | `0.5.3` | | [postcss](https://github.com/postcss/postcss) | `8.5.15` | `8.5.16` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.18.2` | `8.62.1` | | [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.14` | `8.1.3` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.7` | `4.1.9` | Updates `axios` from 1.16.1 to 1.18.1 - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.16.1...v1.18.1) Updates `react-force-graph-2d` from 1.25.7 to 1.29.1 - [Commits](https://github.com/vasturiano/react-force-graph/commits/v1.29.1) Updates `swr` from 2.2.5 to 2.4.2 - [Release notes](https://github.com/vercel/swr/releases) - [Commits](https://github.com/vercel/swr/compare/v2.2.5...v2.4.2) Updates `@playwright/test` from 1.60.0 to 1.61.1 - [Release notes](https://github.com/microsoft/playwright/releases) - [Commits](https://github.com/microsoft/playwright/compare/v1.60.0...v1.61.1) Updates `@tailwindcss/typography` from 0.5.15 to 0.5.20 - [Release notes](https://github.com/tailwindlabs/tailwindcss-typography/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss-typography/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss-typography/compare/v0.5.15...v0.5.20) Updates `@vitejs/plugin-react` from 6.0.2 to 6.0.3 - [Release notes](https://github.com/vitejs/vite-plugin-react/releases) - [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.3/packages/plugin-react) Updates `autoprefixer` from 10.4.20 to 10.5.2 - [Release notes](https://github.com/postcss/autoprefixer/releases) - [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/autoprefixer/compare/10.4.20...10.5.2) Updates `eslint-plugin-react-refresh` from 0.4.16 to 0.5.3 - [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases) - [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md) - [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.4.16...v0.5.3) Updates `postcss` from 8.5.15 to 8.5.16 - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.15...8.5.16) Updates `typescript-eslint` from 8.18.2 to 8.62.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.62.1/packages/typescript-eslint) Updates `vite` from 8.0.14 to 8.1.3 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v8.1.3/packages/vite) Updates `vitest` from 4.1.7 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest) --- updated-dependencies: - dependency-name: "@playwright/test" dependency-version: 1.61.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: frontend-patches - dependency-name: "@tailwindcss/typography" dependency-version: 0.5.20 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-patches - dependency-name: "@vitejs/plugin-react" dependency-version: 6.0.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-patches - dependency-name: autoprefixer dependency-version: 10.5.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: frontend-patches - dependency-name: axios dependency-version: 1.18.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: frontend-patches - dependency-name: eslint-plugin-react-refresh dependency-version: 0.5.3 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: frontend-patches - dependency-name: postcss dependency-version: 8.5.16 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-patches - dependency-name: react-force-graph-2d dependency-version: 1.29.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: frontend-patches - dependency-name: swr dependency-version: 2.4.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: frontend-patches - dependency-name: typescript-eslint dependency-version: 8.62.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: frontend-patches - dependency-name: vite dependency-version: 8.1.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: frontend-patches - dependency-name: vitest dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: frontend-patches ... Signed-off-by: dependabot[bot] --- frontend/package-lock.json | 909 +++++++++++++++++++++---------------- frontend/package.json | 24 +- 2 files changed, 532 insertions(+), 401 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a89b863..1fdecee 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,24 +7,25 @@ "": { "name": "commitiq-frontend", "version": "1.0.0", + "license": "MIT", "dependencies": { - "axios": "1.16.1", + "axios": "1.18.1", "clsx": "^2.1.1", "d3-force": "^3.0.0", "date-fns": "3.6.0", "lucide-react": "0.383.0", "react": "18.3.1", "react-dom": "18.3.1", - "react-force-graph-2d": "1.25.7", + "react-force-graph-2d": "1.29.1", "react-markdown": "9.0.3", "react-router-dom": "6.30.4", "recharts": "2.12.7", - "swr": "2.2.5" + "swr": "2.4.2" }, "devDependencies": { "@eslint/js": "9.39.4", - "@playwright/test": "1.60.0", - "@tailwindcss/typography": "0.5.15", + "@playwright/test": "1.61.1", + "@tailwindcss/typography": "0.5.20", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -32,19 +33,19 @@ "@types/node": "24.10.1", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", - "@vitejs/plugin-react": "6.0.2", - "autoprefixer": "10.4.20", + "@vitejs/plugin-react": "6.0.3", + "autoprefixer": "10.5.2", "eslint": "9.39.4", "eslint-plugin-react-hooks": "5.1.0", - "eslint-plugin-react-refresh": "0.4.16", + "eslint-plugin-react-refresh": "0.5.3", "globals": "15.14.0", "jsdom": "^29.1.1", - "postcss": "8.5.15", + "postcss": "8.5.16", "tailwindcss": "3.4.17", "typescript": "5.7.2", - "typescript-eslint": "8.18.2", - "vite": "8.0.14", - "vitest": "^4.1.7" + "typescript-eslint": "8.62.1", + "vite": "8.1.3", + "vitest": "^4.1.9" } }, "node_modules/@adobe/css-tools": { @@ -292,31 +293,34 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, + "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, + "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, + "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" @@ -582,13 +586,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, + "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -635,22 +640,23 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.132.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", - "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" } }, "node_modules/@playwright/test": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", - "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.60.0" + "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -668,13 +674,14 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", - "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -684,13 +691,14 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -700,13 +708,14 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", - "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -716,13 +725,14 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", - "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -732,13 +742,14 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", - "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -748,13 +759,17 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", - "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -764,13 +779,17 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", - "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -780,13 +799,17 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", - "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -796,13 +819,17 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", - "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -812,13 +839,17 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", - "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -828,13 +859,17 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", - "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -844,13 +879,14 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", - "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openharmony" @@ -860,31 +896,33 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", - "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", "cpu": [ "wasm32" ], "dev": true, + "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", - "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -894,13 +932,14 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", - "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -919,21 +958,20 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@tailwindcss/typography": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.15.tgz", - "integrity": "sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==", + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz", + "integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==", "dev": true, + "license": "MIT", "dependencies": { - "lodash.castarray": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.merge": "^4.6.2", "postcss-selector-parser": "6.0.10" }, "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20" + "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" } }, "node_modules/@testing-library/dom": { @@ -1027,10 +1065,11 @@ "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, + "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" @@ -1048,6 +1087,7 @@ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, + "license": "MIT", "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" @@ -1125,7 +1165,8 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.9", @@ -1205,20 +1246,20 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.18.2.tgz", - "integrity": "sha512-adig4SzPLjeQ0Tm+jvsozSGiCliI2ajeURDGHjZ2llnA+A67HihCQ+a3amtPhUakd1GlwHxSRvzOZktbEvhPPg==", - "dev": true, - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.18.2", - "@typescript-eslint/type-utils": "8.18.2", - "@typescript-eslint/utils": "8.18.2", - "@typescript-eslint/visitor-keys": "8.18.2", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", + "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/type-utils": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1228,22 +1269,56 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "@typescript-eslint/parser": "^8.62.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.18.2.tgz", - "integrity": "sha512-y7tcq4StgxQD4mDr9+Jb26dZ+HTZ/SkfqpXSiqeUXZHxOUyjWDKsmwKhJ0/tApR08DgOhrFAoAhyB80/p3ViuA==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", + "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", + "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.18.2", - "@typescript-eslint/types": "8.18.2", - "@typescript-eslint/typescript-estree": "8.18.2", - "@typescript-eslint/visitor-keys": "8.18.2", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.62.1", + "@typescript-eslint/types": "^8.62.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1253,18 +1328,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.18.2.tgz", - "integrity": "sha512-YJFSfbd0CJjy14r/EvWapYgV4R5CHzptssoag2M7y3Ra7XNta6GPAJPPP5KGB9j14viYXyrzRO5GkX7CRfo8/g==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", + "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.18.2", - "@typescript-eslint/visitor-keys": "8.18.2" + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1274,16 +1349,35 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", + "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.18.2.tgz", - "integrity": "sha512-AB/Wr1Lz31bzHfGm/jgbFR0VB0SML/hd2P1yxzKDM48YmP7vbyJNHRExUE/wZsQj2wUCvbWH8poNHFuxLqCTnA==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", + "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.18.2", - "@typescript-eslint/utils": "8.18.2", - "debug": "^4.3.4", - "ts-api-utils": "^1.3.0" + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1293,15 +1387,16 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.18.2.tgz", - "integrity": "sha512-Z/zblEPp8cIvmEn6+tPDIHUbRu/0z5lqZ+NvolL5SvXWT5rQy7+Nch83M0++XzO0XrWRFWECgOAyE8bsJTl1GQ==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", + "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1311,19 +1406,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.18.2.tgz", - "integrity": "sha512-WXAVt595HjpmlfH4crSdM/1bcsqh+1weFRWIa9XMTx/XHZ9TCKMcr725tLYqWOgzKdeDrqVHxFotrvWcEsk2Tg==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", + "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.18.2", - "@typescript-eslint/visitor-keys": "8.18.2", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" + "@typescript-eslint/project-service": "8.62.1", + "@typescript-eslint/tsconfig-utils": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1333,55 +1430,59 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, + "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.2" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.18.2.tgz", - "integrity": "sha512-Cr4A0H7DtVIPkauj4sTSXVl+VBWewE9/o40KcF3TV9aqDEOWoXF3/+oRXNby3DYzZeCATvbdksYsGZzplwnK/Q==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", + "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.18.2", - "@typescript-eslint/types": "8.18.2", - "@typescript-eslint/typescript-estree": "8.18.2" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1391,18 +1492,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.18.2.tgz", - "integrity": "sha512-zORcwn4C3trOWiCqFQP1x6G3xTRyZ1LYydnj51cRnJ6hxBlr/cKPckk+PKPUw/fXmvfKTcw7bwY3w9izgx5jZw==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", + "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.18.2", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.62.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1412,18 +1514,32 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==" }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", - "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", "dev": true, + "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "^1.0.0" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -1443,15 +1559,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", - "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, + "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1459,11 +1576,39 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/@vitest/pretty-format": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", - "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, + "license": "MIT", "dependencies": { "tinyrainbow": "^3.1.0" }, @@ -1472,12 +1617,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", - "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.9", "pathe": "^2.0.3" }, "funding": { @@ -1485,13 +1631,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", - "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1500,21 +1647,23 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", - "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, + "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", - "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", + "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1648,6 +1797,7 @@ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" } @@ -1658,9 +1808,9 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/autoprefixer": { - "version": "10.4.20", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", - "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "version": "10.5.2", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz", + "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==", "dev": true, "funding": [ { @@ -1676,12 +1826,12 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "browserslist": "^4.23.3", - "caniuse-lite": "^1.0.30001646", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.1", + "browserslist": "^4.28.4", + "caniuse-lite": "^1.0.30001799", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "bin": { @@ -1695,9 +1845,10 @@ } }, "node_modules/axios": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", - "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", @@ -1721,10 +1872,11 @@ "dev": true }, "node_modules/baseline-browser-mapping": { - "version": "2.10.33", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", - "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", "dev": true, + "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" }, @@ -1785,9 +1937,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", "dev": true, "funding": [ { @@ -1803,11 +1955,12 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -1848,9 +2001,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", "dev": true, "funding": [ { @@ -1865,7 +2018,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/canvas-color-tracker": { "version": "1.3.2", @@ -1892,6 +2046,7 @@ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" } @@ -1984,11 +2139,6 @@ "node": ">= 6" } }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2054,7 +2204,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -2481,10 +2632,11 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.364", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", - "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", - "dev": true + "version": "1.5.387", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", + "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==", + "dev": true, + "license": "ISC" }, "node_modules/entities": { "version": "8.0.0", @@ -2550,6 +2702,7 @@ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -2638,12 +2791,13 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.16", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.16.tgz", - "integrity": "sha512-slterMlxAhov/DZO8NScf6mEeMBBXodFUolijDvrtTxyezyLoTQaa73FyYus/VbTdftd8wBgBxPMRk3poleXNQ==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", + "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", "dev": true, + "license": "MIT", "peerDependencies": { - "eslint": ">=8.40" + "eslint": "^9 || ^10" } }, "node_modules/eslint-scope": { @@ -2738,6 +2892,7 @@ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, + "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" } @@ -2965,15 +3120,16 @@ } }, "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "dev": true, + "license": "MIT", "engines": { "node": "*" }, "funding": { - "type": "patreon", + "type": "github", "url": "https://github.com/sponsors/rawify" } }, @@ -3069,12 +3225,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3801,18 +3951,6 @@ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==" }, - "node_modules/lodash.castarray": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", - "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", - "dev": true - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3862,6 +4000,7 @@ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } @@ -4549,10 +4688,11 @@ "dev": true }, "node_modules/node-releases": { - "version": "2.0.46", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", - "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" } @@ -4566,15 +4706,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4724,7 +4855,8 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", @@ -4763,13 +4895,13 @@ } }, "node_modules/playwright": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", - "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0" + "playwright-core": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -4782,9 +4914,9 @@ } }, "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4810,9 +4942,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -4828,6 +4960,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -5104,13 +5237,14 @@ } }, "node_modules/react-force-graph-2d": { - "version": "1.25.7", - "resolved": "https://registry.npmjs.org/react-force-graph-2d/-/react-force-graph-2d-1.25.7.tgz", - "integrity": "sha512-WVNLK/z8RDq8kDhfEH/cZBGl64R3M761M3Xa3nXjXFW4mO3xUbQ7GG7dKV0brd4LSfjamGs2f1+UmXF4Tp8sGQ==", + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz", + "integrity": "sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==", + "license": "MIT", "dependencies": { - "force-graph": "1", + "force-graph": "^1.51", "prop-types": "15", - "react-kapsule": "2" + "react-kapsule": "^2.5" }, "engines": { "node": ">=12" @@ -5368,12 +5502,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", - "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", "dev": true, + "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.132.0", + "@oxc-project/types": "=0.138.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5383,21 +5518,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.2", - "@rolldown/binding-darwin-arm64": "1.0.2", - "@rolldown/binding-darwin-x64": "1.0.2", - "@rolldown/binding-freebsd-x64": "1.0.2", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", - "@rolldown/binding-linux-arm64-gnu": "1.0.2", - "@rolldown/binding-linux-arm64-musl": "1.0.2", - "@rolldown/binding-linux-ppc64-gnu": "1.0.2", - "@rolldown/binding-linux-s390x-gnu": "1.0.2", - "@rolldown/binding-linux-x64-gnu": "1.0.2", - "@rolldown/binding-linux-x64-musl": "1.0.2", - "@rolldown/binding-openharmony-arm64": "1.0.2", - "@rolldown/binding-wasm32-wasi": "1.0.2", - "@rolldown/binding-win32-arm64-msvc": "1.0.2", - "@rolldown/binding-win32-x64-msvc": "1.0.2" + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" } }, "node_modules/run-parallel": { @@ -5443,6 +5578,19 @@ "loose-envify": "^1.1.0" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5600,15 +5748,16 @@ } }, "node_modules/swr": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz", - "integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.2.tgz", + "integrity": "sha512-ej644Y2bvkIajfR32KGeSSdBXQW+ScjGjkybZgSE7kFpk9eGnV44XY9FJylXi+W75pavSX1PVNB57W5EbhGIYw==", + "license": "MIT", "dependencies": { - "client-only": "^0.0.1", - "use-sync-external-store": "^1.2.0" + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" }, "peerDependencies": { - "react": "^16.11.0 || ^17.0.0 || ^18.0.0" + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/symbol-tree": { @@ -5763,6 +5912,7 @@ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -5840,15 +5990,16 @@ } }, "node_modules/ts-api-utils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", - "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18.12" }, "peerDependencies": { - "typescript": ">=4.2.0" + "typescript": ">=4.8.4" } }, "node_modules/ts-interface-checker": { @@ -5862,6 +6013,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, + "license": "0BSD", "optional": true }, "node_modules/type-check": { @@ -5890,14 +6042,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.18.2.tgz", - "integrity": "sha512-KuXezG6jHkvC3MvizeXgupZzaG5wjhU3yE8E7e6viOvAvD9xAWYp8/vy0WULTGe9DYDWcQu7aW03YIV3mSitrQ==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz", + "integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.18.2", - "@typescript-eslint/parser": "8.18.2", - "@typescript-eslint/utils": "8.18.2" + "@typescript-eslint/eslint-plugin": "8.62.1", + "@typescript-eslint/parser": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5907,8 +6061,8 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/undici": { @@ -6026,6 +6180,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" @@ -6108,16 +6263,17 @@ } }, "node_modules/vite": { - "version": "8.0.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", - "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "dev": true, + "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.2", - "tinyglobby": "^0.2.16" + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -6133,7 +6289,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -6197,18 +6353,19 @@ } }, "node_modules/vitest": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", - "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", - "dev": true, - "dependencies": { - "@vitest/expect": "4.1.7", - "@vitest/mocker": "4.1.7", - "@vitest/pretty-format": "4.1.7", - "@vitest/runner": "4.1.7", - "@vitest/snapshot": "4.1.7", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -6236,12 +6393,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.7", - "@vitest/browser-preview": "4.1.7", - "@vitest/browser-webdriverio": "4.1.7", - "@vitest/coverage-istanbul": "4.1.7", - "@vitest/coverage-v8": "4.1.7", - "@vitest/ui": "4.1.7", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -6285,32 +6442,6 @@ } } }, - "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", - "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", - "dev": true, - "dependencies": { - "@vitest/spy": "4.1.7", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, "node_modules/vitest/node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 83f460f..a6cb262 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,23 +14,23 @@ "test:e2e": "playwright test" }, "dependencies": { - "axios": "1.16.1", + "axios": "1.18.1", "clsx": "^2.1.1", "d3-force": "^3.0.0", "date-fns": "3.6.0", "lucide-react": "0.383.0", "react": "18.3.1", "react-dom": "18.3.1", - "react-force-graph-2d": "1.25.7", + "react-force-graph-2d": "1.29.1", "react-markdown": "9.0.3", "react-router-dom": "6.30.4", "recharts": "2.12.7", - "swr": "2.2.5" + "swr": "2.4.2" }, "devDependencies": { "@eslint/js": "9.39.4", - "@playwright/test": "1.60.0", - "@tailwindcss/typography": "0.5.15", + "@playwright/test": "1.61.1", + "@tailwindcss/typography": "0.5.20", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -38,18 +38,18 @@ "@types/node": "24.10.1", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", - "@vitejs/plugin-react": "6.0.2", - "autoprefixer": "10.4.20", + "@vitejs/plugin-react": "6.0.3", + "autoprefixer": "10.5.2", "eslint": "9.39.4", "eslint-plugin-react-hooks": "5.1.0", - "eslint-plugin-react-refresh": "0.4.16", + "eslint-plugin-react-refresh": "0.5.3", "globals": "15.14.0", "jsdom": "^29.1.1", - "postcss": "8.5.15", + "postcss": "8.5.16", "tailwindcss": "3.4.17", "typescript": "5.7.2", - "typescript-eslint": "8.18.2", - "vite": "8.0.14", - "vitest": "^4.1.7" + "typescript-eslint": "8.62.1", + "vite": "8.1.3", + "vitest": "^4.1.9" } } From e5373715e584a0d74cb821b10d20a1e958382e7a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 5 Jul 2026 14:54:48 +0000 Subject: [PATCH 2/2] chore: auto-format code on PR branch --- backend/backend/__init__.py | 1 + backend/config.py | 28 +- backend/database.py | 9 +- .../features/llm_analysis/claude_client.py | 67 +- backend/features/llm_analysis/cost_guard.py | 18 +- backend/features/llm_analysis/llm_router.py | 9 +- .../features/llm_analysis/prompt_builder.py | 34 +- backend/features/llm_analysis/router.py | 33 +- backend/features/repo_ingestion/bus_factor.py | 27 +- .../features/repo_ingestion/clone_service.py | 70 +- .../features/repo_ingestion/commit_walker.py | 45 +- .../features/repo_ingestion/graph_builder.py | 91 +- .../features/repo_ingestion/health_scorer.py | 249 ++--- .../repo_ingestion/metrics_extractor.py | 21 +- backend/features/repo_ingestion/router.py | 153 +-- .../repo_ingestion/semantic_analyzer.py | 7 +- backend/main.py | 5 +- backend/shared/models.py | 249 ++--- backend/shared/schemas.py | 13 +- backend/sitecustomize.py | 1 + backend/tests/test_config.py | 15 +- backend/tests/test_database_migrations.py | 2 + backend/tests/test_llm_logic.py | 6 +- backend/tests/test_repo_api.py | 313 +++--- backend/tests/test_repo_ingestion_logic.py | 12 +- frontend/src/App.test.tsx | 10 +- frontend/src/App.tsx | 4 +- frontend/src/components/AmbientBackground.tsx | 18 +- .../src/components/BusFactorTable.test.tsx | 4 +- frontend/src/components/BusFactorTable.tsx | 49 +- frontend/src/components/CommitList.tsx | 32 +- frontend/src/components/CostMeter.tsx | 20 +- frontend/src/components/GraphDiffPanel.tsx | 34 +- frontend/src/components/GraphExplorer.tsx | 920 ++++++++++-------- frontend/src/components/HealthTimeline.tsx | 185 ++-- frontend/src/components/HotspotMap.tsx | 75 +- frontend/src/components/NarrativeCard.tsx | 54 +- frontend/src/components/ui/HealthBadge.tsx | 29 +- frontend/src/components/ui/ThemeToggle.tsx | 38 +- frontend/src/index.css | 228 +++-- frontend/src/lib/api.test.ts | 18 +- frontend/src/lib/api.ts | 6 +- frontend/src/lib/utils.ts | 7 +- frontend/src/main.tsx | 2 +- frontend/src/pages/AnalyzePage.test.tsx | 2 +- frontend/src/pages/AnalyzePage.tsx | 64 +- frontend/src/pages/CommitDetailPage.tsx | 81 +- frontend/src/pages/DashboardPage.test.tsx | 25 +- frontend/src/pages/DashboardPage.tsx | 200 ++-- frontend/src/pages/DemoPage.test.tsx | 2 +- frontend/src/pages/DemoPage.tsx | 15 +- frontend/src/pages/LandingPage.test.tsx | 7 +- frontend/src/pages/LandingPage.tsx | 93 +- frontend/src/pages/NotFoundPage.tsx | 15 +- frontend/src/types/index.test.ts | 8 +- frontend/src/types/index.ts | 10 +- 56 files changed, 2200 insertions(+), 1533 deletions(-) diff --git a/backend/backend/__init__.py b/backend/backend/__init__.py index bad37b4..d308e64 100644 --- a/backend/backend/__init__.py +++ b/backend/backend/__init__.py @@ -1,4 +1,5 @@ """Compatibility package for running commands from the backend directory.""" + from __future__ import annotations from pathlib import Path diff --git a/backend/config.py b/backend/config.py index 6ba52e7..ddd285b 100644 --- a/backend/config.py +++ b/backend/config.py @@ -1,9 +1,11 @@ import os -from dotenv import load_dotenv from pathlib import Path +from dotenv import load_dotenv + load_dotenv() + def _normalize_database_url(url: str) -> str: if url.startswith("postgres://"): return url.replace("postgres://", "postgresql+asyncpg://", 1) @@ -36,17 +38,23 @@ def _cors_origins(environment: str, raw_origins: str | None) -> list[str]: return _parse_csv(DEFAULT_LOCAL_CORS_ORIGINS) -ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "") -GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "") -GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", "") -DATABASE_URL = _normalize_database_url(os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./commitiq.db")) -REPO_STORAGE_PATH = Path(os.getenv("REPO_STORAGE_PATH", "/tmp/commitiq_repos")) -MAX_COMMITS = int(os.getenv("MAX_COMMITS_PER_INGESTION", os.getenv("MAX_COMMITS", "500"))) -LLM_MAX_CALLS = int(os.getenv("LLM_BUDGET_PER_REPO", os.getenv("LLM_MAX_CALLS_PER_REPO", os.getenv("LLM_MAX_CALLS", "25")))) +ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "") +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "") +GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", "") +DATABASE_URL = _normalize_database_url( + os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./commitiq.db") +) +REPO_STORAGE_PATH = Path(os.getenv("REPO_STORAGE_PATH", "/tmp/commitiq_repos")) +MAX_COMMITS = int(os.getenv("MAX_COMMITS_PER_INGESTION", os.getenv("MAX_COMMITS", "500"))) +LLM_MAX_CALLS = int( + os.getenv( + "LLM_BUDGET_PER_REPO", os.getenv("LLM_MAX_CALLS_PER_REPO", os.getenv("LLM_MAX_CALLS", "25")) + ) +) LLM_BUDGET_PER_REPO_USD = float(os.getenv("LLM_BUDGET_PER_REPO_USD", "0.50")) ENABLE_SEMANTIC_ANALYSIS = _parse_bool(os.getenv("ENABLE_SEMANTIC_ANALYSIS"), default=True) ENABLE_GRAPHCODEBERT = _parse_bool(os.getenv("ENABLE_GRAPHCODEBERT"), default=False) -ENVIRONMENT = os.getenv("ENVIRONMENT", "development") -CORS_ORIGINS = _cors_origins(ENVIRONMENT, os.getenv("CORS_ORIGINS")) +ENVIRONMENT = os.getenv("ENVIRONMENT", "development") +CORS_ORIGINS = _cors_origins(ENVIRONMENT, os.getenv("CORS_ORIGINS")) REPO_STORAGE_PATH.mkdir(parents=True, exist_ok=True) diff --git a/backend/database.py b/backend/database.py index 7d9202d..6c24c4f 100644 --- a/backend/database.py +++ b/backend/database.py @@ -60,14 +60,9 @@ async def _execute_statement(conn, statement: str, *, is_sqlite: bool = _IS_SQLI def _migration_statements(migration_sql: str) -> list[str]: migration_lines = migration_sql.splitlines() uncommented_sql = "\n".join( - line for line in migration_lines - if not line.lstrip().startswith("--") + line for line in migration_lines if not line.lstrip().startswith("--") ) - return [ - statement.strip() - for statement in uncommented_sql.split(";") - if statement.strip() - ] + return [statement.strip() for statement in uncommented_sql.split(";") if statement.strip()] async def _ensure_migration_table(conn) -> None: diff --git a/backend/features/llm_analysis/claude_client.py b/backend/features/llm_analysis/claude_client.py index 2dda109..472349f 100644 --- a/backend/features/llm_analysis/claude_client.py +++ b/backend/features/llm_analysis/claude_client.py @@ -1,24 +1,26 @@ """LLM call wrapper for on-demand CommitIQ narratives.""" + from typing import AsyncGenerator -from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession -from backend.shared.models import LLMNarrative, Commit, HealthSnapshot +from backend.config import ANTHROPIC_API_KEY from backend.features.llm_analysis.cache import make_cache_key -from backend.features.llm_analysis.cost_guard import check_budget -from backend.features.llm_analysis.cost_guard import estimate_cost_usd +from backend.features.llm_analysis.cost_guard import check_budget, estimate_cost_usd from backend.features.llm_analysis.llm_router import get_narrative_non_streaming, model_for_provider from backend.features.llm_analysis.prompt_builder import ( - EXPLAIN_DROP_SYSTEM, PREDICT_MERGE_SYSTEM, - build_explain_prompt, build_predict_prompt + EXPLAIN_DROP_SYSTEM, + PREDICT_MERGE_SYSTEM, + build_explain_prompt, + build_predict_prompt, ) -from backend.config import ANTHROPIC_API_KEY, GEMINI_API_KEY +from backend.shared.models import Commit, HealthSnapshot, LLMNarrative # Sonnet pricing (as of 2025): # Input: $3.00 per 1M tokens = $0.000003 per token # Output: $15.00 per 1M tokens = $0.000015 per token -INPUT_COST_PER_TOKEN = 0.000003 +INPUT_COST_PER_TOKEN = 0.000003 OUTPUT_COST_PER_TOKEN = 0.000015 CLAUDE_MODEL = "claude-3-5-sonnet-20241022" @@ -26,6 +28,7 @@ def _get_anthropic(): try: import anthropic + return anthropic except ImportError: return None @@ -34,6 +37,7 @@ def _get_anthropic(): def _get_genai(): try: import google.generativeai as genai + return genai except ImportError: return None @@ -67,7 +71,7 @@ async def stream_narrative(prompt: str) -> AsyncGenerator[str, None]: "You are a code health analyst. Given metric deltas for a Git commit, " "explain in plain English why the health score changed. Be specific about " "which files and metrics drove the change. Use bullet points. Max 4 sentences. " - "Always end with: \"Estimated impact: [Low/Medium/High] risk to next sprint.\" " + 'Always end with: "Estimated impact: [Low/Medium/High] risk to next sprint." ' "Never mention token costs or that you're an AI." ), ) as stream: @@ -93,10 +97,12 @@ async def get_or_create_narrative( Checks cache → checks budget → fires API → stores result. """ commit_result = await db.execute( - select(Commit).where( + select(Commit) + .where( Commit.repo_id == repo_id, Commit.sha == commit_sha[:12], - ).limit(1) + ) + .limit(1) ) commit = commit_result.scalar_one_or_none() if not commit: @@ -116,11 +122,11 @@ async def get_or_create_narrative( "prompt_type": prompt_type, "explanation": cached.response_text, "tokens_used": cached.tokens_input + cached.tokens_output, - "cost_usd": cached.cost_usd, - "cached": True, - "model": cached.model_used, - "provider": "cache", - "demo_mode": False, + "cost_usd": cached.cost_usd, + "cached": True, + "model": cached.model_used, + "provider": "cache", + "demo_mode": False, } # 3. Check budget — hard limit @@ -135,12 +141,12 @@ async def get_or_create_narrative( raise ValueError(f"No health snapshot for commit {commit_sha}") after_dict = { - "health_score": snapshot.health_score, - "avg_complexity": snapshot.avg_complexity, - "churn_rate": snapshot.churn_rate, + "health_score": snapshot.health_score, + "avg_complexity": snapshot.avg_complexity, + "churn_rate": snapshot.churn_rate, "num_files_changed": snapshot.num_files_changed, - "bus_factor_min": snapshot.bus_factor_min, - "top_files_json": snapshot.top_files_json, + "bus_factor_min": snapshot.bus_factor_min, + "top_files_json": snapshot.top_files_json, "avg_semantic_drift": snapshot.avg_semantic_drift, "semantic_health_score": snapshot.semantic_health_score, "high_drift_files": snapshot.high_drift_files, @@ -148,10 +154,13 @@ async def get_or_create_narrative( } prev_commit_result = await db.execute( - select(Commit).where( + select(Commit) + .where( Commit.repo_id == repo_id, Commit.committed_at < commit.committed_at, - ).order_by(Commit.committed_at.desc()).limit(1) + ) + .order_by(Commit.committed_at.desc()) + .limit(1) ) prev_commit = prev_commit_result.scalar_one_or_none() @@ -161,7 +170,7 @@ async def get_or_create_narrative( ) prev_snap = prev_snap_result.scalar_one_or_none() before_dict = { - "health_score": prev_snap.health_score if prev_snap else 0, + "health_score": prev_snap.health_score if prev_snap else 0, "avg_complexity": prev_snap.avg_complexity if prev_snap else 0, "bus_factor_min": prev_snap.bus_factor_min if prev_snap else 1, } @@ -227,9 +236,9 @@ async def get_or_create_narrative( "prompt_type": prompt_type, "explanation": response_text, "tokens_used": tokens_in + tokens_out, - "cost_usd": round(cost, 5), - "cached": False, - "model": model_used, - "provider": provider_used, - "demo_mode": demo_mode, + "cost_usd": round(cost, 5), + "cached": False, + "model": model_used, + "provider": provider_used, + "demo_mode": demo_mode, } diff --git a/backend/features/llm_analysis/cost_guard.py b/backend/features/llm_analysis/cost_guard.py index 9106936..2bcbb80 100644 --- a/backend/features/llm_analysis/cost_guard.py +++ b/backend/features/llm_analysis/cost_guard.py @@ -1,9 +1,9 @@ -from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select -from backend.shared.models import LLMNarrative +from sqlalchemy.ext.asyncio import AsyncSession + from backend.config import LLM_BUDGET_PER_REPO_USD, LLM_MAX_CALLS from backend.features.llm_analysis.llm_router import provider_from_model - +from backend.shared.models import LLMNarrative PROVIDER_COSTS_PER_1K_OUTPUT = { "anthropic": 0.015, @@ -44,8 +44,12 @@ async def get_usage_summary(repo_id: int, db: AsyncSession) -> dict: cached_rows = [row for row in rows if row.is_pre_cached] total_tokens = sum(row.tokens_input + row.tokens_output for row in provider_rows) total_cost = sum(float(row.cost_usd or 0.0) for row in provider_rows) - anthropic_calls = sum(1 for row in provider_rows if provider_from_model(row.model_used) == "anthropic") - gemini_calls = sum(1 for row in provider_rows if provider_from_model(row.model_used) == "gemini") + anthropic_calls = sum( + 1 for row in provider_rows if provider_from_model(row.model_used) == "anthropic" + ) + gemini_calls = sum( + 1 for row in provider_rows if provider_from_model(row.model_used) == "gemini" + ) cached_tokens = sum(row.tokens_input + row.tokens_output for row in cached_rows) cache_hits = len(cached_rows) return { @@ -56,7 +60,9 @@ async def get_usage_summary(repo_id: int, db: AsyncSession) -> dict: "gemini_calls": gemini_calls, "total_tokens": total_tokens, "total_cost_usd": round(float(total_cost or 0.0), 6), - "cache_savings_usd": round((cached_tokens / 1000.0) * PROVIDER_COSTS_PER_1K_OUTPUT["anthropic"], 6), + "cache_savings_usd": round( + (cached_tokens / 1000.0) * PROVIDER_COSTS_PER_1K_OUTPUT["anthropic"], 6 + ), "budget_remaining": max(0, LLM_MAX_CALLS - len(provider_rows)), "max_calls": LLM_MAX_CALLS, } diff --git a/backend/features/llm_analysis/llm_router.py b/backend/features/llm_analysis/llm_router.py index 203dd8d..fd44865 100644 --- a/backend/features/llm_analysis/llm_router.py +++ b/backend/features/llm_analysis/llm_router.py @@ -1,4 +1,5 @@ """Multi-provider LLM routing for CommitIQ narratives.""" + from __future__ import annotations import asyncio @@ -76,7 +77,9 @@ async def stream_narrative( except Exception as exc: logger.error("Gemini fallback failed: %s", exc) - raise RuntimeError("All LLM providers unavailable. Configure ANTHROPIC_API_KEY or GEMINI_API_KEY.") + raise RuntimeError( + "All LLM providers unavailable. Configure ANTHROPIC_API_KEY or GEMINI_API_KEY." + ) async def _stream_anthropic(prompt: str, max_tokens: int) -> AsyncGenerator[str, None]: @@ -127,7 +130,9 @@ def _sync_stream(): yield text -async def get_narrative_non_streaming(prompt: str, max_tokens: int = 600) -> tuple[str, LLMProvider]: +async def get_narrative_non_streaming( + prompt: str, max_tokens: int = 600 +) -> tuple[str, LLMProvider]: full_text: list[str] = [] provider_used = LLMProvider.NONE async for token, provider in stream_narrative(prompt, max_tokens): diff --git a/backend/features/llm_analysis/prompt_builder.py b/backend/features/llm_analysis/prompt_builder.py index 8180686..30c30e8 100644 --- a/backend/features/llm_analysis/prompt_builder.py +++ b/backend/features/llm_analysis/prompt_builder.py @@ -4,8 +4,8 @@ Input is always a JSON diff of metric values. Target: under 700 input tokens. """ -import json +import json EXPLAIN_DROP_SYSTEM = """You are a senior engineering analyst reviewing codebase health data. Given two commit health snapshots and their metric diff, explain in 3-4 sentences why the @@ -31,21 +31,19 @@ def build_explain_prompt( Total cost: ~$0.003–0.004 per call. """ diff = { - "commit_message": commit_msg[:120], - "health_before": before.get("health_score", 0), - "health_after": after.get("health_score", 0), - "health_delta": round( - after.get("health_score", 0) - before.get("health_score", 0), 2 - ), + "commit_message": commit_msg[:120], + "health_before": before.get("health_score", 0), + "health_after": after.get("health_score", 0), + "health_delta": round(after.get("health_score", 0) - before.get("health_score", 0), 2), "complexity_before": before.get("avg_complexity", 0), - "complexity_after": after.get("avg_complexity", 0), - "complexity_delta": round( + "complexity_after": after.get("avg_complexity", 0), + "complexity_delta": round( after.get("avg_complexity", 0) - before.get("avg_complexity", 0), 2 ), - "churn_rate": after.get("churn_rate", 0), - "files_changed": after.get("num_files_changed", 0), + "churn_rate": after.get("churn_rate", 0), + "files_changed": after.get("num_files_changed", 0), "bus_factor_before": before.get("bus_factor_min", 1), - "bus_factor_after": after.get("bus_factor_min", 1), + "bus_factor_after": after.get("bus_factor_min", 1), "avg_semantic_drift": after.get("avg_semantic_drift", 0), "semantic_health_score": after.get("semantic_health_score", 100), "high_drift_files": after.get("high_drift_files", 0), @@ -62,15 +60,15 @@ def build_predict_prompt(branch_metrics: dict, main_metrics: dict) -> str: Cost: ~$0.002–0.003 per call. """ comparison = { - "main_health": main_metrics.get("health_score", 0), - "branch_health": branch_metrics.get("health_score", 0), - "health_delta": round( + "main_health": main_metrics.get("health_score", 0), + "branch_health": branch_metrics.get("health_score", 0), + "health_delta": round( branch_metrics.get("health_score", 0) - main_metrics.get("health_score", 0), 2 ), - "complexity_main": main_metrics.get("avg_complexity", 0), + "complexity_main": main_metrics.get("avg_complexity", 0), "complexity_branch": branch_metrics.get("avg_complexity", 0), - "churn_branch": branch_metrics.get("churn_rate", 0), + "churn_branch": branch_metrics.get("churn_rate", 0), "bus_factor_branch": branch_metrics.get("bus_factor_min", 1), - "files_to_merge": branch_metrics.get("num_files_changed", 0), + "files_to_merge": branch_metrics.get("num_files_changed", 0), } return f"Predict health impact of merging this branch:\n{json.dumps(comparison, indent=2)}" diff --git a/backend/features/llm_analysis/router.py b/backend/features/llm_analysis/router.py index 8f34560..563aadb 100644 --- a/backend/features/llm_analysis/router.py +++ b/backend/features/llm_analysis/router.py @@ -31,9 +31,17 @@ def _build_demo_narrative( before: dict, after: dict, ) -> str: - health_delta = float(after.get("health_score", 0) or 0) - float(before.get("health_score", 0) or 0) - complexity_delta = float(after.get("avg_complexity", 0) or 0) - float(before.get("avg_complexity", 0) or 0) - risk_level = "High" if health_delta <= -15 or after.get("bus_factor_min", 1) <= 1 else "Medium" if health_delta < 0 else "Low" + health_delta = float(after.get("health_score", 0) or 0) - float( + before.get("health_score", 0) or 0 + ) + complexity_delta = float(after.get("avg_complexity", 0) or 0) - float( + before.get("avg_complexity", 0) or 0 + ) + risk_level = ( + "High" + if health_delta <= -15 or after.get("bus_factor_min", 1) <= 1 + else "Medium" if health_delta < 0 else "Low" + ) top_files = after.get("top_files_json") or "[]" return ( @@ -94,19 +102,24 @@ async def explain_commit_stream( db: AsyncSession = Depends(get_db), ): commit_result = await db.execute( - select(Commit).where( + select(Commit) + .where( Commit.repo_id == request.repo_id, (Commit.sha == request.commit_sha[:12]) | (Commit.full_sha == request.commit_sha), - ).limit(1) + ) + .limit(1) ) commit = commit_result.scalar_one_or_none() if not commit: raise HTTPException(status_code=404, detail=f"Commit {request.commit_sha} not found") cache_key = make_cache_key(request.repo_id, commit.full_sha, request.prompt_type) - cached_result = await db.execute(select(LLMNarrative).where(LLMNarrative.cache_key == cache_key)) + cached_result = await db.execute( + select(LLMNarrative).where(LLMNarrative.cache_key == cache_key) + ) cached = cached_result.scalar_one_or_none() if cached: + async def replay_cache(): for word in cached.response_text.split(" "): yield f"data: {json.dumps({'token': word + ' ', 'done': False})}\n\n" @@ -120,9 +133,13 @@ async def replay_cache(): ) if not await check_budget(request.repo_id, db): - raise HTTPException(status_code=429, detail="LLM budget exceeded for this repo. Cache will be used.") + raise HTTPException( + status_code=429, detail="LLM budget exceeded for this repo. Cache will be used." + ) - snapshot_result = await db.execute(select(HealthSnapshot).where(HealthSnapshot.commit_id == commit.id)) + snapshot_result = await db.execute( + select(HealthSnapshot).where(HealthSnapshot.commit_id == commit.id) + ) snapshot = snapshot_result.scalar_one_or_none() if not snapshot: raise HTTPException(status_code=404, detail="Health snapshot not found") diff --git a/backend/features/repo_ingestion/bus_factor.py b/backend/features/repo_ingestion/bus_factor.py index 8e89b6c..cff1a5d 100644 --- a/backend/features/repo_ingestion/bus_factor.py +++ b/backend/features/repo_ingestion/bus_factor.py @@ -2,7 +2,6 @@ from collections import defaultdict from pathlib import Path - EXCLUDE_PATTERNS = ( ".github/", "ISSUE_TEMPLATE", @@ -92,7 +91,9 @@ def compute_bus_factor_from_history( Falls back to commit authorship counts if a file no longer exists at HEAD. """ touched_files: dict[str, str | None] = {} - fallback_counts: dict[str, dict[tuple[str, str | None], int]] = defaultdict(lambda: defaultdict(int)) + fallback_counts: dict[str, dict[tuple[str, str | None], int]] = defaultdict( + lambda: defaultdict(int) + ) for commit in commit_history: author = commit.get("author_name") or "unknown" @@ -118,15 +119,17 @@ def compute_bus_factor_from_history( top_pct = top_count / total contributor_count = len(ranked) - entries.append({ - "module_path": module_path, - "contributor_count": contributor_count, - "top_contributor": top_author, - "top_contributor_email": top_email, - "top_contributor_pct": round(top_pct, 4), - "total_commits_to_module": sum(fallback_counts[module_path].values()), - "risk_level": _risk_level(contributor_count, top_pct), - "last_commit_sha": touched_files[module_path], - }) + entries.append( + { + "module_path": module_path, + "contributor_count": contributor_count, + "top_contributor": top_author, + "top_contributor_email": top_email, + "top_contributor_pct": round(top_pct, 4), + "total_commits_to_module": sum(fallback_counts[module_path].values()), + "risk_level": _risk_level(contributor_count, top_pct), + "last_commit_sha": touched_files[module_path], + } + ) return entries diff --git a/backend/features/repo_ingestion/clone_service.py b/backend/features/repo_ingestion/clone_service.py index 4bbb5f2..bb2e049 100644 --- a/backend/features/repo_ingestion/clone_service.py +++ b/backend/features/repo_ingestion/clone_service.py @@ -1,10 +1,12 @@ import logging +import re +import shutil import subprocess from pathlib import Path -import shutil -from backend.config import REPO_STORAGE_PATH, GITHUB_TOKEN + import httpx -import re + +from backend.config import GITHUB_TOKEN, REPO_STORAGE_PATH logger = logging.getLogger(__name__) @@ -24,47 +26,49 @@ def _is_valid_github_name(value: str) -> bool: def parse_github_url(url: str) -> tuple[str, str]: """Parse GitHub URL or shorthand to ('owner', 'repo').""" s = url.strip() - - while s.endswith('/') or s.endswith('.git'): - if s.endswith('/'): + + while s.endswith("/") or s.endswith(".git"): + if s.endswith("/"): s = s[:-1] - elif s.endswith('.git'): + elif s.endswith(".git"): s = s[:-4] - + explicit_host = False - if s.startswith('https://'): - s = s[len('https://'):] + if s.startswith("https://"): + s = s[len("https://") :] explicit_host = True - elif s.startswith('http://'): - s = s[len('http://'):] + elif s.startswith("http://"): + s = s[len("http://") :] explicit_host = True - - if s.startswith('www.'): + + if s.startswith("www."): s = s[4:] - - if s.startswith('github.com/'): - s = s[len('github.com/'):] + + if s.startswith("github.com/"): + s = s[len("github.com/") :] elif explicit_host: raise ValueError(f"Cannot parse GitHub URL: {url}. Expected a github.com repository URL.") - - parts = s.split('/') + + parts = s.split("/") if len(parts) < 2 or not parts[0] or not parts[1]: - raise ValueError(f"Cannot parse GitHub URL: {url}. Expected format 'owner/repo' or 'github.com/owner/repo'.") - + raise ValueError( + f"Cannot parse GitHub URL: {url}. Expected format 'owner/repo' or 'github.com/owner/repo'." + ) + owner = parts[0] repo = parts[1] - + # Validate owner/repo format to avoid invalid directory names or bad parameters if not _is_valid_github_name(owner) or not _is_valid_github_name(repo): raise ValueError(f"Invalid owner or repository name in URL: {url}") - + return owner, repo def make_repo_slug(owner: str, repo: str) -> str: slug = f"{owner}-{repo}".lower() - slug = re.sub(r'[^a-z0-9\-]', '-', slug) + slug = re.sub(r"[^a-z0-9\-]", "-", slug) return slug @@ -83,21 +87,12 @@ def clone_repo(repo_url: str, repo_id: int, max_commits: int = 150) -> Path: # Use git clone via HTTPS — never uses GitHub REST API, no rate limits if GITHUB_TOKEN: # This only speeds up clone for large repos — not required for public - auth_url = repo_url.replace( - 'https://github.com/', - f'https://{GITHUB_TOKEN}@github.com/' - ) + auth_url = repo_url.replace("https://github.com/", f"https://{GITHUB_TOKEN}@github.com/") else: auth_url = repo_url result = subprocess.run( - [ - "git", "clone", - "--depth", str(max_commits), - "--single-branch", - auth_url, - str(target) - ], + ["git", "clone", "--depth", str(max_commits), "--single-branch", auth_url, str(target)], capture_output=True, text=True, timeout=300, @@ -150,10 +145,7 @@ async def fetch_github_metadata(owner: str, repo: str) -> dict: try: async with httpx.AsyncClient(timeout=5) as client: - r = await client.get( - f"https://api.github.com/repos/{owner}/{repo}", - headers=headers - ) + r = await client.get(f"https://api.github.com/repos/{owner}/{repo}", headers=headers) if r.status_code == 200: data = r.json() return { diff --git a/backend/features/repo_ingestion/commit_walker.py b/backend/features/repo_ingestion/commit_walker.py index 2885f63..d0cfd89 100644 --- a/backend/features/repo_ingestion/commit_walker.py +++ b/backend/features/repo_ingestion/commit_walker.py @@ -1,9 +1,10 @@ -import git import subprocess from datetime import datetime, timezone from pathlib import Path from typing import Iterator +import git + def walk_commits(repo_path: Path, limit: int = 150) -> Iterator[dict]: """ @@ -13,7 +14,7 @@ def walk_commits(repo_path: Path, limit: int = 150) -> Iterator[dict]: Metrics are computed from git stats, not file inspection. """ repo = git.Repo(repo_path) - commits = list(repo.iter_commits('HEAD', max_count=limit)) + commits = list(repo.iter_commits("HEAD", max_count=limit)) commits.reverse() # oldest → newest for timeline total = len(commits) @@ -23,8 +24,8 @@ def walk_commits(repo_path: Path, limit: int = 150) -> Iterator[dict]: try: stats = commit.stats files_changed = list(stats.files.keys()) - insertions = stats.total.get('insertions', 0) - deletions = stats.total.get('deletions', 0) + insertions = stats.total.get("insertions", 0) + deletions = stats.total.get("deletions", 0) except Exception: # Fallback for shallow clone boundary commits where parent object is missing files_changed = [] @@ -32,9 +33,13 @@ def walk_commits(repo_path: Path, limit: int = 150) -> Iterator[dict]: deletions = 0 try: cmd = ["git", "diff-tree", "--no-commit-id", "--name-only", "-r", commit.hexsha] - res = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, errors="replace") + res = subprocess.run( + cmd, cwd=repo_path, capture_output=True, text=True, errors="replace" + ) if res.returncode == 0: - files_changed = [line.strip() for line in res.stdout.splitlines() if line.strip()] + files_changed = [ + line.strip() for line in res.stdout.splitlines() if line.strip() + ] # Set dummy insertions/deletions as proxy to avoid zero metrics division issues insertions = len(files_changed) * 15 deletions = 5 @@ -42,19 +47,19 @@ def walk_commits(repo_path: Path, limit: int = 150) -> Iterator[dict]: pass yield { - "sha": commit.hexsha[:12], - "full_sha": commit.hexsha, - "message": commit.message.strip()[:500], - "author_name": commit.author.name, - "author_email": commit.author.email, - "committed_at": datetime.fromtimestamp( - commit.committed_date, tz=timezone.utc - ).isoformat(), - "insertions": insertions, - "deletions": deletions, + "sha": commit.hexsha[:12], + "full_sha": commit.hexsha, + "message": commit.message.strip()[:500], + "author_name": commit.author.name, + "author_email": commit.author.email, + "committed_at": datetime.fromtimestamp( + commit.committed_date, tz=timezone.utc + ).isoformat(), + "insertions": insertions, + "deletions": deletions, "files_changed": len(files_changed), - "files_list": files_changed[:100], # cap to 100 for storage - "parent_sha": parent_sha, - "index": idx, - "total": total, + "files_list": files_changed[:100], # cap to 100 for storage + "parent_sha": parent_sha, + "index": idx, + "total": total, } diff --git a/backend/features/repo_ingestion/graph_builder.py b/backend/features/repo_ingestion/graph_builder.py index 8658be4..45e2489 100644 --- a/backend/features/repo_ingestion/graph_builder.py +++ b/backend/features/repo_ingestion/graph_builder.py @@ -2,9 +2,10 @@ Builds import edges (from AST parsing of HEAD state) and co-change edges (from commit history co-occurrence). """ + import ast -import re import os +import re from collections import defaultdict from pathlib import Path @@ -26,87 +27,83 @@ def extract_python_imports(file_content: str) -> list[str]: def extract_js_imports(file_content: str) -> list[str]: imports = [] - + # 1. Matches: import ... from 'path' and export ... from 'path' (including multiline and type imports) es6_from_pattern = r"""(?:import|export)\s+(?:type\s+)?[\s\S]*?\s+from\s+['"]([^'"]+)['"]""" imports.extend(re.findall(es6_from_pattern, file_content)) - + # 2. Matches: import 'path' (side-effect imports) es6_side_effect_pattern = r"""import\s+['"]([^'"]+)['"]""" imports.extend(re.findall(es6_side_effect_pattern, file_content)) - + # 3. Matches require('path') require_pattern = r"""require\s*\(\s*['"]([^'"]+)['"]\s*\)""" imports.extend(re.findall(require_pattern, file_content)) - + # 4. Matches require 'path' (CommonJS without parentheses) require_no_paren = r"""require\s+['"]([^'"]+)['"]""" imports.extend(re.findall(require_no_paren, file_content)) - + # 5. Matches dynamic import('path') dynamic_import_pattern = r"""import\s*\(\s*['"]([^'"]+)['"]\s*\)""" imports.extend(re.findall(dynamic_import_pattern, file_content)) - + # De-duplicate while preserving order seen = set() unique_imports = [] for imp in imports: # Strip query params or webpack loaders if present - imp_clean = imp.split('!')[-1].split('?')[0].strip() + imp_clean = imp.split("!")[-1].split("?")[0].strip() if imp_clean and imp_clean not in seen: seen.add(imp_clean) unique_imports.append(imp_clean) - + return unique_imports -def resolve_import_to_file( - import_path: str, - source_file: str, - all_files: list[str] -) -> str | None: +def resolve_import_to_file(import_path: str, source_file: str, all_files: list[str]) -> str | None: """ Try to resolve an import path to an actual file in the repo. Supports relative imports, direct match, and suffix/sub-path match (e.g. monorepo packages). Returns the resolved path or None if not resolvable. """ - import_path_clean = import_path.replace('\\', '/').strip() + import_path_clean = import_path.replace("\\", "/").strip() if not import_path_clean: return None # Helper set of files for O(1) lookup file_set = set(all_files) - if import_path_clean.startswith('.'): + if import_path_clean.startswith("."): source_dir = os.path.dirname(source_file) candidate = os.path.normpath(os.path.join(source_dir, import_path_clean)) - candidate_clean = candidate.replace('\\', '/') - - for ext in ['', '.js', '.ts', '.jsx', '.tsx', '.py']: + candidate_clean = candidate.replace("\\", "/") + + for ext in ["", ".js", ".ts", ".jsx", ".tsx", ".py"]: with_ext = candidate_clean + ext if with_ext in file_set: return with_ext index = f"{candidate_clean}/index{ext}" if index in file_set: return index - index_norm = os.path.normpath(index).replace('\\', '/') + index_norm = os.path.normpath(index).replace("\\", "/") if index_norm in file_set: return index_norm return None # 2. Handle absolute/package/module-alias imports (e.g. monorepo sub-paths) - for ext in ['', '.js', '.ts', '.jsx', '.tsx', '.py']: + for ext in ["", ".js", ".ts", ".jsx", ".tsx", ".py"]: with_ext = import_path_clean + ext if with_ext in file_set: return with_ext # Suffix match (e.g. "react-reconciler/src/ReactFiberReconciler" matching "packages/react-reconciler/src/ReactFiberReconciler.js") - if '/' in import_path_clean: + if "/" in import_path_clean: for f in all_files: - f_clean = f.replace('\\', '/') - for ext in ['', '.js', '.ts', '.jsx', '.tsx', '.py']: + f_clean = f.replace("\\", "/") + for ext in ["", ".js", ".ts", ".jsx", ".tsx", ".py"]: suffix = import_path_clean + ext - if f_clean.endswith('/' + suffix): + if f_clean.endswith("/" + suffix): return f return None @@ -120,15 +117,15 @@ def build_import_edges(repo_path: Path, all_files: list[str]) -> list[dict]: for rel_path in all_files: full_path = os.path.join(repo_path, rel_path) try: - with open(full_path, 'r', encoding='utf-8', errors='ignore') as f: + with open(full_path, "r", encoding="utf-8", errors="ignore") as f: content = f.read() except Exception: continue ext = os.path.splitext(rel_path)[1].lower() - if ext == '.py': + if ext == ".py": raw_imports = extract_python_imports(content) - elif ext in {'.js', '.ts', '.jsx', '.tsx'}: + elif ext in {".js", ".ts", ".jsx", ".tsx"}: raw_imports = extract_js_imports(content) else: continue @@ -136,13 +133,15 @@ def build_import_edges(repo_path: Path, all_files: list[str]) -> list[dict]: for imp in raw_imports: target = resolve_import_to_file(imp, rel_path, all_files) if target and target != rel_path: - edges.append({ - "source_file": rel_path, - "target_file": target, - "edge_type": "import", - "weight": 1, - "cochange_count": None, - }) + edges.append( + { + "source_file": rel_path, + "target_file": target, + "edge_type": "import", + "weight": 1, + "cochange_count": None, + } + ) return edges @@ -155,21 +154,23 @@ def build_cochange_edges(commit_history: list[dict], min_cooccurrence: int = 3) cochange_counts = defaultdict(int) for commit in commit_history: - files = sorted({file_path for file_path in commit.get('files_list', []) if file_path}) + files = sorted({file_path for file_path in commit.get("files_list", []) if file_path}) for i, f1 in enumerate(files): - for f2 in files[i+1:]: + for f2 in files[i + 1 :]: cochange_counts[(f1, f2)] += 1 edges = [] for (f1, f2), count in sorted(cochange_counts.items()): if count >= min_cooccurrence: - edges.append({ - "source_file": f1, - "target_file": f2, - "edge_type": "co_change", - "weight": count, - "cochange_count": count, - }) + edges.append( + { + "source_file": f1, + "target_file": f2, + "edge_type": "co_change", + "weight": count, + "cochange_count": count, + } + ) return edges @@ -178,6 +179,6 @@ def get_top_files_by_frequency(commit_history: list[dict], top_n: int = 50) -> l """Return the top N most frequently changed files.""" freq = defaultdict(int) for commit in commit_history: - for f in commit.get('files_list', []): + for f in commit.get("files_list", []): freq[f] += 1 return [f for f, _ in sorted(freq.items(), key=lambda x: -x[1])[:top_n]] diff --git a/backend/features/repo_ingestion/health_scorer.py b/backend/features/repo_ingestion/health_scorer.py index b9e7fe6..8dbc079 100644 --- a/backend/features/repo_ingestion/health_scorer.py +++ b/backend/features/repo_ingestion/health_scorer.py @@ -57,135 +57,165 @@ def build_risk_reasons( reasons: list[dict] = [] if avg_complexity >= 10: - reasons.append(_risk_reason( - "high_complexity", - "high", - "High complexity", - f"Changed files average {avg_complexity:.1f} cyclomatic complexity.", - min(avg_complexity * 2.0, 30.0), - )) + reasons.append( + _risk_reason( + "high_complexity", + "high", + "High complexity", + f"Changed files average {avg_complexity:.1f} cyclomatic complexity.", + min(avg_complexity * 2.0, 30.0), + ) + ) elif avg_complexity >= 5: - reasons.append(_risk_reason( - "elevated_complexity", - "medium", - "Elevated complexity", - f"Changed files average {avg_complexity:.1f} cyclomatic complexity.", - min(avg_complexity * 1.4, 18.0), - )) + reasons.append( + _risk_reason( + "elevated_complexity", + "medium", + "Elevated complexity", + f"Changed files average {avg_complexity:.1f} cyclomatic complexity.", + min(avg_complexity * 1.4, 18.0), + ) + ) if prev_avg_complexity > 0: drift_pct = (avg_complexity - prev_avg_complexity) / prev_avg_complexity if drift_pct > 0.20: - reasons.append(_risk_reason( - "complexity_jump", - "medium", - "Complexity jump", - f"Average complexity rose {drift_pct * 100.0:.0f}% from the previous analyzed commit.", - min(drift_pct * 35.0, 20.0), - )) + reasons.append( + _risk_reason( + "complexity_jump", + "medium", + "Complexity jump", + f"Average complexity rose {drift_pct * 100.0:.0f}% from the previous analyzed commit.", + min(drift_pct * 35.0, 20.0), + ) + ) if churn_rate >= 0.50: - reasons.append(_risk_reason( - "high_churn", - "high", - "High churn", - f"This commit rewrote about {churn_rate * 100.0:.0f}% of the analyzed lines.", - 22.0, - )) + reasons.append( + _risk_reason( + "high_churn", + "high", + "High churn", + f"This commit rewrote about {churn_rate * 100.0:.0f}% of the analyzed lines.", + 22.0, + ) + ) elif churn_rate >= 0.25: - reasons.append(_risk_reason( - "elevated_churn", - "medium", - "Elevated churn", - f"This commit touched about {churn_rate * 100.0:.0f}% of the analyzed lines.", - 12.0, - )) + reasons.append( + _risk_reason( + "elevated_churn", + "medium", + "Elevated churn", + f"This commit touched about {churn_rate * 100.0:.0f}% of the analyzed lines.", + 12.0, + ) + ) if hotspot_files: - reasons.append(_risk_reason( - "active_hotspots", - "high" if len(hotspot_files) > 2 else "medium", - "Active hotspots", - f"{len(hotspot_files)} high-complexity file(s) are repeatedly changing.", - min(len(hotspot_files) * 8.0, 24.0), - )) + reasons.append( + _risk_reason( + "active_hotspots", + "high" if len(hotspot_files) > 2 else "medium", + "Active hotspots", + f"{len(hotspot_files)} high-complexity file(s) are repeatedly changing.", + min(len(hotspot_files) * 8.0, 24.0), + ) + ) if hotspot_persistence_score >= 60: - reasons.append(_risk_reason( - "persistent_hotspots", - "high", - "Persistent hotspots", - "Risky files are staying active across several recent commits.", - min(hotspot_persistence_score / 4.0, 25.0), - )) + reasons.append( + _risk_reason( + "persistent_hotspots", + "high", + "Persistent hotspots", + "Risky files are staying active across several recent commits.", + min(hotspot_persistence_score / 4.0, 25.0), + ) + ) elif hotspot_persistence_score >= 30: - reasons.append(_risk_reason( - "persistent_hotspots", - "medium", - "Persistent hotspots", - "Some risky files are recurring in the recent commit window.", - min(hotspot_persistence_score / 5.0, 15.0), - )) + reasons.append( + _risk_reason( + "persistent_hotspots", + "medium", + "Persistent hotspots", + "Some risky files are recurring in the recent commit window.", + min(hotspot_persistence_score / 5.0, 15.0), + ) + ) if bus_factor_min <= 1: - reasons.append(_risk_reason( - "single_owner", - "critical", - "Single-owner risk", - "At least one critical module has only one active contributor.", - 30.0, - )) + reasons.append( + _risk_reason( + "single_owner", + "critical", + "Single-owner risk", + "At least one critical module has only one active contributor.", + 30.0, + ) + ) elif bus_factor_min == 2: - reasons.append(_risk_reason( - "limited_ownership", - "medium", - "Limited ownership", - "Critical ownership is concentrated across two contributors.", - 14.0, - )) + reasons.append( + _risk_reason( + "limited_ownership", + "medium", + "Limited ownership", + "Critical ownership is concentrated across two contributors.", + 14.0, + ) + ) if dependency_density >= 2.0: - reasons.append(_risk_reason( - "dense_dependencies", - "high", - "Dense dependency graph", - f"Dependency density is {dependency_density:.2f} edges per analyzed file.", - 18.0, - )) + reasons.append( + _risk_reason( + "dense_dependencies", + "high", + "Dense dependency graph", + f"Dependency density is {dependency_density:.2f} edges per analyzed file.", + 18.0, + ) + ) elif dependency_density >= 1.0: - reasons.append(_risk_reason( - "dense_dependencies", - "medium", - "Dense dependency graph", - f"Dependency density is {dependency_density:.2f} edges per analyzed file.", - 10.0, - )) + reasons.append( + _risk_reason( + "dense_dependencies", + "medium", + "Dense dependency graph", + f"Dependency density is {dependency_density:.2f} edges per analyzed file.", + 10.0, + ) + ) if has_cycles: - reasons.append(_risk_reason( - "dependency_cycle", - "high", - "Dependency cycle", - "Import cycles were detected in the analyzed dependency graph.", - 20.0, - )) + reasons.append( + _risk_reason( + "dependency_cycle", + "high", + "Dependency cycle", + "Import cycles were detected in the analyzed dependency graph.", + 20.0, + ) + ) if semantic_health_score < 70: - reasons.append(_risk_reason( - "semantic_drift", - "high", - "High semantic drift", - f"Semantic health is {semantic_health_score:.0f}/100 for this snapshot.", - 20.0, - )) + reasons.append( + _risk_reason( + "semantic_drift", + "high", + "High semantic drift", + f"Semantic health is {semantic_health_score:.0f}/100 for this snapshot.", + 20.0, + ) + ) elif semantic_health_score < 85: - reasons.append(_risk_reason( - "semantic_drift", - "medium", - "Semantic drift", - f"Semantic health is {semantic_health_score:.0f}/100 for this snapshot.", - 10.0, - )) + reasons.append( + _risk_reason( + "semantic_drift", + "medium", + "Semantic drift", + f"Semantic health is {semantic_health_score:.0f}/100 for this snapshot.", + 10.0, + ) + ) return sorted(reasons, key=lambda item: item["impact"], reverse=True)[:6] @@ -281,9 +311,7 @@ def compute_full_snapshot( total_loc = sum(item.get("loc", 0) for item in metrics) complexities = [ - item.get("avg_complexity", 0.0) - for item in metrics - if item.get("avg_complexity", 0.0) > 0 + item.get("avg_complexity", 0.0) for item in metrics if item.get("avg_complexity", 0.0) > 0 ] max_complexities = [item.get("max_complexity", 0.0) for item in metrics] @@ -292,7 +320,10 @@ def compute_full_snapshot( lines_changed = commit_data["insertions"] + commit_data["deletions"] churn_rate = round(min(1.0, lines_changed / max(total_loc, lines_changed, 1)), 4) hotspot_persistence_score = round( - min(100.0, sum(float(item.get("recent_commit_count", 0)) * 12.5 for item in persistent_hotspots)), + min( + 100.0, + sum(float(item.get("recent_commit_count", 0)) * 12.5 for item in persistent_hotspots), + ), 1, ) diff --git a/backend/features/repo_ingestion/metrics_extractor.py b/backend/features/repo_ingestion/metrics_extractor.py index dd535fc..2074efc 100644 --- a/backend/features/repo_ingestion/metrics_extractor.py +++ b/backend/features/repo_ingestion/metrics_extractor.py @@ -115,10 +115,9 @@ def extract_commit_metrics( checkout_commit(repo_path, commit_data["full_sha"]) metrics: dict[str, dict] = {} - files = [ - fpath for fpath in commit_data.get("files_list", []) - if _is_supported(fpath) - ][:max_files] + files = [fpath for fpath in commit_data.get("files_list", []) if _is_supported(fpath)][ + :max_files + ] for rel_path in files: full_path = repo_path / rel_path @@ -149,17 +148,14 @@ def scan_repo_head(repo_path: Path, max_files: int = 200) -> dict[str, dict]: for root, dirs, files in os.walk(repo_path): dirs[:] = [ - d for d in dirs - if d not in {".git", "node_modules", "__pycache__", "dist", "build"} + d for d in dirs if d not in {".git", "node_modules", "__pycache__", "dist", "build"} ] for fname in files: if count >= max_files: return file_metrics rel_path = os.path.relpath(os.path.join(root, fname), repo_path) if _is_supported(rel_path): - file_metrics[rel_path] = extract_file_metrics_from_path( - str(repo_path / rel_path) - ) + file_metrics[rel_path] = extract_file_metrics_from_path(str(repo_path / rel_path)) count += 1 return file_metrics @@ -182,12 +178,7 @@ def compute_health_score( bus_score = min(100.0, float(bus_factor_min) * 20.0) loc_score = max(0.0, 100.0 - (loc_touched / 20.0)) - health = ( - cc_score * 0.30 + - churn_score * 0.25 + - bus_score * 0.25 + - loc_score * 0.20 - ) + health = cc_score * 0.30 + churn_score * 0.25 + bus_score * 0.25 + loc_score * 0.20 health = round(max(0.0, min(100.0, health)), 2) return health, { diff --git a/backend/features/repo_ingestion/router.py b/backend/features/repo_ingestion/router.py index fafa97b..9e6a7cf 100644 --- a/backend/features/repo_ingestion/router.py +++ b/backend/features/repo_ingestion/router.py @@ -136,7 +136,7 @@ def _hotspot_files( current_index: int, file_metrics_map: dict, ) -> list[str]: - recent_commits = commit_history[max(0, current_index - 4):current_index + 1] + recent_commits = commit_history[max(0, current_index - 4) : current_index + 1] churn_counts: dict[str, int] = {} for commit_data in recent_commits: for fpath in set(commit_data.get("files_list", [])): @@ -154,7 +154,7 @@ def _persistent_hotspots( file_metrics_map: dict, min_recent_commits: int = 3, ) -> list[dict]: - recent_commits = commit_history[max(0, current_index - 5):current_index + 1] + recent_commits = commit_history[max(0, current_index - 5) : current_index + 1] churn_counts: dict[str, int] = {} for commit_data in recent_commits: for fpath in set(commit_data.get("files_list", [])): @@ -166,12 +166,14 @@ def _persistent_hotspots( avg_complexity = float(metrics.get("avg_complexity", 0.0)) if recent_count < min_recent_commits or avg_complexity <= 5.0: continue - hotspots.append({ - "path": fpath, - "recent_commit_count": recent_count, - "complexity": round(avg_complexity, 2), - "loc": int(metrics.get("loc", 0)), - }) + hotspots.append( + { + "path": fpath, + "recent_commit_count": recent_count, + "complexity": round(avg_complexity, 2), + "loc": int(metrics.get("loc", 0)), + } + ) return sorted( hotspots, key=lambda item: (item["recent_commit_count"], item["complexity"], item["loc"]), @@ -259,10 +261,14 @@ async def _graph_payload(db: AsyncSession, repo_id: int, commit: Commit) -> dict if fallback_commit: commit = fallback_commit nodes_result = await db.execute( - select(GraphNode).where(GraphNode.repo_id == repo_id, GraphNode.commit_id == commit.id) + select(GraphNode).where( + GraphNode.repo_id == repo_id, GraphNode.commit_id == commit.id + ) ) edges_result = await db.execute( - select(GraphEdge).where(GraphEdge.repo_id == repo_id, GraphEdge.commit_id == commit.id) + select(GraphEdge).where( + GraphEdge.repo_id == repo_id, GraphEdge.commit_id == commit.id + ) ) nodes = nodes_result.scalars().all() edges = edges_result.scalars().all() @@ -323,7 +329,9 @@ async def _bus_factor_payload(db: AsyncSession, repo_id: int) -> dict: } -async def _commit_graph_rows(db: AsyncSession, repo_id: int, sha: str) -> tuple[list[GraphNode], list[GraphEdge], Commit]: +async def _commit_graph_rows( + db: AsyncSession, repo_id: int, sha: str +) -> tuple[list[GraphNode], list[GraphEdge], Commit]: commit = await _find_commit(db, repo_id, sha) if not commit: raise _http_error(404, "Commit not found.", "commit_not_found") @@ -354,7 +362,10 @@ async def _latest_active_job(db: AsyncSession, repo_id: int) -> AnalysisJob | No async def run_ingestion(repo_id: int, job_id: int, max_commits: int) -> None: from backend.database import AsyncSessionLocal - from backend.features.repo_ingestion.metrics_extractor import checkout_commit, extract_commit_metrics + from backend.features.repo_ingestion.metrics_extractor import ( + checkout_commit, + extract_commit_metrics, + ) async with AsyncSessionLocal() as db: repo = await db.get(Repo, repo_id) @@ -363,7 +374,9 @@ async def run_ingestion(repo_id: int, job_id: int, max_commits: int) -> None: job = await db.get(AnalysisJob, job_id) if not job or job.repo_id != repo_id: - logger.warning("Skipping ingestion for repo_id=%s because job_id=%s was not found", repo_id, job_id) + logger.warning( + "Skipping ingestion for repo_id=%s because job_id=%s was not found", repo_id, job_id + ) return clone_path = None @@ -397,7 +410,9 @@ async def run_ingestion(repo_id: int, job_id: int, max_commits: int) -> None: await _clear_repo_data(db, repo_id) await _raise_if_cancelled(db, job) - await _update_job(db, job, status="computing_bus_factor", current_stage="Computing bus factor") + await _update_job( + db, job, status="computing_bus_factor", current_stage="Computing bus factor" + ) await _raise_if_cancelled(db, job) checkout_commit(clone_path, commit_history[-1]["full_sha"]) bus_entries = compute_bus_factor_from_history(commit_history, clone_path) @@ -420,7 +435,7 @@ async def run_ingestion(repo_id: int, job_id: int, max_commits: int) -> None: file_metrics_map = extract_commit_metrics(clone_path, commit_data) top_files = list(file_metrics_map.keys())[:50] import_edges = build_import_edges(clone_path, top_files) - cochange_edges = build_cochange_edges(commit_history[:idx + 1]) + cochange_edges = build_cochange_edges(commit_history[: idx + 1]) top_set = set(top_files) filtered_edges = [] seen_edges = set() @@ -470,33 +485,39 @@ async def run_ingestion(repo_id: int, job_id: int, max_commits: int) -> None: for fpath in top_files: metrics = file_metrics_map.get(fpath, {}) - db.add(GraphNode( - repo_id=repo_id, - commit_id=commit_obj.id, - full_sha=commit_obj.full_sha, - file_path=fpath, - module_name=Path(fpath).name, - loc=metrics.get("loc", 0), - avg_complexity=metrics.get("avg_complexity", 0.0), - health_color=assign_health_color(metrics.get("avg_complexity", 0.0)), - is_entry_point=Path(fpath).stem in {"index", "main", "app", "server"}, - semantic_drift_score=metrics.get("semantic_drift_score", 0.0), - drift_method=metrics.get("drift_method", "none"), - )) + db.add( + GraphNode( + repo_id=repo_id, + commit_id=commit_obj.id, + full_sha=commit_obj.full_sha, + file_path=fpath, + module_name=Path(fpath).name, + loc=metrics.get("loc", 0), + avg_complexity=metrics.get("avg_complexity", 0.0), + health_color=assign_health_color(metrics.get("avg_complexity", 0.0)), + is_entry_point=Path(fpath).stem in {"index", "main", "app", "server"}, + semantic_drift_score=metrics.get("semantic_drift_score", 0.0), + drift_method=metrics.get("drift_method", "none"), + ) + ) for edge in filtered_edges: - db.add(GraphEdge( - repo_id=repo_id, - commit_id=commit_obj.id, - full_sha=commit_obj.full_sha, - **edge, - )) + db.add( + GraphEdge( + repo_id=repo_id, + commit_id=commit_obj.id, + full_sha=commit_obj.full_sha, + **edge, + ) + ) prev_health = snapshot_data["health_score"] prev_avg_complexity = snapshot_data["avg_complexity"] await db.commit() - await _update_job(db, job, status="computing_bus_factor", current_stage="Computing bus factor") + await _update_job( + db, job, status="computing_bus_factor", current_stage="Computing bus factor" + ) for entry in bus_entries: db.add(BusFactor(repo_id=repo_id, **entry)) @@ -739,13 +760,17 @@ async def get_commit_detail(repo_id: int, sha: str, db: AsyncSession = Depends(g if not commit: raise _http_error(404, "Commit not found.", "commit_not_found") - snap_result = await db.execute(select(HealthSnapshot).where(HealthSnapshot.commit_id == commit.id)) + snap_result = await db.execute( + select(HealthSnapshot).where(HealthSnapshot.commit_id == commit.id) + ) snapshot = snap_result.scalar_one_or_none() if not snapshot: raise _http_error(404, "Health snapshot not found for commit.", "snapshot_not_found") narrative_key = make_cache_key(repo_id, commit.full_sha, "explain_drop") - narrative_result = await db.execute(select(LLMNarrative).where(LLMNarrative.cache_key == narrative_key)) + narrative_result = await db.execute( + select(LLMNarrative).where(LLMNarrative.cache_key == narrative_key) + ) narrative = narrative_result.scalar_one_or_none() narrative_payload = None @@ -804,14 +829,18 @@ async def get_graph_diff( if before_cx > 0: delta_pct = (after_cx - before_cx) / before_cx if abs(delta_pct) > 0.10: - nodes_changed.append({ - "file": fpath, - "before_complexity": round(before_cx, 2), - "after_complexity": round(after_cx, 2), - "delta_pct": round(delta_pct * 100.0, 1), - }) - - before_edge_set = {(edge.source_file, edge.target_file, edge.edge_type) for edge in before_edges} + nodes_changed.append( + { + "file": fpath, + "before_complexity": round(before_cx, 2), + "after_complexity": round(after_cx, 2), + "delta_pct": round(delta_pct * 100.0, 1), + } + ) + + before_edge_set = { + (edge.source_file, edge.target_file, edge.edge_type) for edge in before_edges + } after_edge_set = {(edge.source_file, edge.target_file, edge.edge_type) for edge in after_edges} added_edges = sorted(after_edge_set - before_edge_set) removed_edges = sorted(before_edge_set - after_edge_set) @@ -828,7 +857,9 @@ async def get_graph_diff( }, "nodes_added": nodes_added, "nodes_removed": nodes_removed, - "nodes_changed": sorted(nodes_changed, key=lambda item: abs(item["delta_pct"]), reverse=True)[:20], + "nodes_changed": sorted( + nodes_changed, key=lambda item: abs(item["delta_pct"]), reverse=True + )[:20], "edges_added": [ {"source": source, "target": target, "type": edge_type} for source, target, edge_type in added_edges[:30] @@ -855,8 +886,11 @@ async def get_hotspots(repo_id: int, sha: str | None = None, db: AsyncSession = raise _http_error(404, "Commit not found.", "commit_not_found") nodes_result = await db.execute( - select(GraphNode) - .where(GraphNode.repo_id == repo_id, GraphNode.commit_id == commit.id, GraphNode.avg_complexity > 5.0) + select(GraphNode).where( + GraphNode.repo_id == repo_id, + GraphNode.commit_id == commit.id, + GraphNode.avg_complexity > 5.0, + ) ) nodes = nodes_result.scalars().all() if not nodes: @@ -864,14 +898,19 @@ async def get_hotspots(repo_id: int, sha: str | None = None, db: AsyncSession = file_paths = [node.file_path for node in nodes] commits_result = await db.execute( - select(Commit).where(Commit.repo_id == repo_id).order_by(desc(Commit.committed_at)).limit(20) + select(Commit) + .where(Commit.repo_id == repo_id) + .order_by(desc(Commit.committed_at)) + .limit(20) ) recent_commits = commits_result.scalars().all() churn_counts = {fpath: 0 for fpath in file_paths} for recent in recent_commits: # Querying full historical file lists is not stored; approximate churn by graph node presence. node_result = await db.execute( - select(GraphNode.file_path).where(GraphNode.repo_id == repo_id, GraphNode.commit_id == recent.id) + select(GraphNode.file_path).where( + GraphNode.repo_id == repo_id, GraphNode.commit_id == recent.id + ) ) recent_paths = set(node_result.scalars().all()) for fpath in file_paths: @@ -884,12 +923,14 @@ async def get_hotspots(repo_id: int, sha: str | None = None, db: AsyncSession = if churn_count <= 2: continue risk_score = min(100.0, node.avg_complexity * 8.0 + churn_count * 6.0) - hotspots.append({ - "file": node.file_path, - "complexity": round(node.avg_complexity, 2), - "churn_count": churn_count, - "risk_score": round(risk_score, 1), - }) + hotspots.append( + { + "file": node.file_path, + "complexity": round(node.avg_complexity, 2), + "churn_count": churn_count, + "risk_score": round(risk_score, 1), + } + ) return { "repo_id": repo_id, diff --git a/backend/features/repo_ingestion/semantic_analyzer.py b/backend/features/repo_ingestion/semantic_analyzer.py index fc7fec2..8ed171b 100644 --- a/backend/features/repo_ingestion/semantic_analyzer.py +++ b/backend/features/repo_ingestion/semantic_analyzer.py @@ -4,6 +4,7 @@ Runs during ingestion only. Request handlers should read the stored drift scores from SQLite rather than invoking this module. """ + from __future__ import annotations import difflib @@ -162,7 +163,11 @@ def compute_repo_semantic_health(file_drifts: list[dict]) -> dict: scores = [float(item.get("semantic_drift_score", 0.0)) for item in file_drifts] avg_drift = sum(scores) / max(len(scores), 1) max_drift = max(scores) - method = "graphcodebert" if any(item.get("method") == "graphcodebert" for item in file_drifts) else file_drifts[0].get("method", "none") + method = ( + "graphcodebert" + if any(item.get("method") == "graphcodebert" for item in file_drifts) + else file_drifts[0].get("method", "none") + ) return { "avg_semantic_drift": round(avg_drift, 4), diff --git a/backend/main.py b/backend/main.py index 476db6c..6c0d835 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,11 +1,12 @@ +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from contextlib import asynccontextmanager from backend.config import CORS_ORIGINS from backend.database import engine, init_db -from backend.features.repo_ingestion.router import router as ingestion_router from backend.features.llm_analysis.router import router as llm_router +from backend.features.repo_ingestion.router import router as ingestion_router @asynccontextmanager diff --git a/backend/shared/models.py b/backend/shared/models.py index aef8a33..7612598 100644 --- a/backend/shared/models.py +++ b/backend/shared/models.py @@ -1,58 +1,71 @@ from sqlalchemy import ( - Column, Integer, String, Float, Boolean, DateTime, Text, - ForeignKey, UniqueConstraint, Index + Boolean, + Column, + DateTime, + Float, + ForeignKey, + Index, + Integer, + String, + Text, + UniqueConstraint, ) from sqlalchemy.orm import relationship from sqlalchemy.sql import func + from backend.database import Base class Repo(Base): __tablename__ = "repos" - id = Column(Integer, primary_key=True, autoincrement=True) - url = Column(String, nullable=False, unique=True) - name = Column(String, nullable=False) - owner = Column(String, nullable=False, default="") - repo_slug = Column(String, nullable=False, unique=True) - default_branch = Column(String, nullable=False, default="main") - ingested_at = Column(DateTime(timezone=True), server_default=func.now()) - last_updated_at = Column(DateTime(timezone=True), nullable=True) - total_commits = Column(Integer, nullable=False, default=0) - analyzed_commits = Column(Integer, nullable=False, default=0) - status = Column(String, nullable=False, default="pending") - error_message = Column(Text, nullable=True) + id = Column(Integer, primary_key=True, autoincrement=True) + url = Column(String, nullable=False, unique=True) + name = Column(String, nullable=False) + owner = Column(String, nullable=False, default="") + repo_slug = Column(String, nullable=False, unique=True) + default_branch = Column(String, nullable=False, default="main") + ingested_at = Column(DateTime(timezone=True), server_default=func.now()) + last_updated_at = Column(DateTime(timezone=True), nullable=True) + total_commits = Column(Integer, nullable=False, default=0) + analyzed_commits = Column(Integer, nullable=False, default=0) + status = Column(String, nullable=False, default="pending") + error_message = Column(Text, nullable=True) max_commits_setting = Column(Integer, nullable=False, default=150) - github_stars = Column(Integer, nullable=True) - github_language = Column(String, nullable=True) - github_description = Column(Text, nullable=True) + github_stars = Column(Integer, nullable=True) + github_language = Column(String, nullable=True) + github_description = Column(Text, nullable=True) - commits = relationship("Commit", back_populates="repo", cascade="all, delete-orphan") + commits = relationship("Commit", back_populates="repo", cascade="all, delete-orphan") analysis_jobs = relationship("AnalysisJob", back_populates="repo", cascade="all, delete-orphan") - bus_factor = relationship("BusFactor", back_populates="repo", cascade="all, delete-orphan") + bus_factor = relationship("BusFactor", back_populates="repo", cascade="all, delete-orphan") class Commit(Base): __tablename__ = "commits" - id = Column(Integer, primary_key=True, autoincrement=True) - repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) - sha = Column(String, nullable=False) - full_sha = Column(String, nullable=False) - message = Column(Text, nullable=True) - author_name = Column(String, nullable=True) - author_email = Column(String, nullable=True) - committed_at = Column(DateTime(timezone=True), nullable=False) - insertions = Column(Integer, nullable=False, default=0) - deletions = Column(Integer, nullable=False, default=0) + id = Column(Integer, primary_key=True, autoincrement=True) + repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) + sha = Column(String, nullable=False) + full_sha = Column(String, nullable=False) + message = Column(Text, nullable=True) + author_name = Column(String, nullable=True) + author_email = Column(String, nullable=True) + committed_at = Column(DateTime(timezone=True), nullable=False) + insertions = Column(Integer, nullable=False, default=0) + deletions = Column(Integer, nullable=False, default=0) files_changed = Column(Integer, nullable=False, default=0) - parent_sha = Column(String, nullable=True) + parent_sha = Column(String, nullable=True) - repo = relationship("Repo", back_populates="commits") - health_snapshot = relationship("HealthSnapshot", back_populates="commit", uselist=False, cascade="all, delete-orphan") - graph_nodes = relationship("GraphNode", back_populates="commit", cascade="all, delete-orphan") - graph_edges = relationship("GraphEdge", back_populates="commit", cascade="all, delete-orphan") - llm_narratives = relationship("LLMNarrative", back_populates="commit", cascade="all, delete-orphan") + repo = relationship("Repo", back_populates="commits") + health_snapshot = relationship( + "HealthSnapshot", back_populates="commit", uselist=False, cascade="all, delete-orphan" + ) + graph_nodes = relationship("GraphNode", back_populates="commit", cascade="all, delete-orphan") + graph_edges = relationship("GraphEdge", back_populates="commit", cascade="all, delete-orphan") + llm_narratives = relationship( + "LLMNarrative", back_populates="commit", cascade="all, delete-orphan" + ) __table_args__ = ( UniqueConstraint("repo_id", "full_sha", name="uq_commits_repo_fullsha"), @@ -64,38 +77,40 @@ class Commit(Base): class HealthSnapshot(Base): __tablename__ = "health_snapshots" - id = Column(Integer, primary_key=True, autoincrement=True) - repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) - commit_id = Column(Integer, ForeignKey("commits.id", ondelete="CASCADE"), nullable=False, unique=True) - full_sha = Column(String, nullable=False) - health_score = Column(Float, nullable=False) - avg_complexity = Column(Float, nullable=False, default=0.0) - max_complexity = Column(Float, nullable=False, default=0.0) - total_loc = Column(Integer, nullable=False, default=0) - churn_rate = Column(Float, nullable=False, default=0.0) + id = Column(Integer, primary_key=True, autoincrement=True) + repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) + commit_id = Column( + Integer, ForeignKey("commits.id", ondelete="CASCADE"), nullable=False, unique=True + ) + full_sha = Column(String, nullable=False) + health_score = Column(Float, nullable=False) + avg_complexity = Column(Float, nullable=False, default=0.0) + max_complexity = Column(Float, nullable=False, default=0.0) + total_loc = Column(Integer, nullable=False, default=0) + churn_rate = Column(Float, nullable=False, default=0.0) num_files_changed = Column(Integer, nullable=False, default=0) - bus_factor_min = Column(Integer, nullable=False, default=1) - health_delta = Column(Float, nullable=True) - cc_score = Column(Float, nullable=False, default=0.0) - churn_score = Column(Float, nullable=False, default=0.0) - bus_score = Column(Float, nullable=False, default=0.0) - loc_score = Column(Float, nullable=False, default=0.0) - complexity_drift_score = Column(Float, nullable=False, default=0.0) - churn_risk_score = Column(Float, nullable=False, default=0.0) - bus_factor_risk_score = Column(Float, nullable=False, default=0.0) + bus_factor_min = Column(Integer, nullable=False, default=1) + health_delta = Column(Float, nullable=True) + cc_score = Column(Float, nullable=False, default=0.0) + churn_score = Column(Float, nullable=False, default=0.0) + bus_score = Column(Float, nullable=False, default=0.0) + loc_score = Column(Float, nullable=False, default=0.0) + complexity_drift_score = Column(Float, nullable=False, default=0.0) + churn_risk_score = Column(Float, nullable=False, default=0.0) + bus_factor_risk_score = Column(Float, nullable=False, default=0.0) dependency_health_score = Column(Float, nullable=False, default=0.0) - dependency_density = Column(Float, nullable=False, default=0.0) - has_cycles = Column(Boolean, nullable=False, default=False) - hotspot_count = Column(Integer, nullable=False, default=0) - avg_semantic_drift = Column(Float, nullable=False, default=0.0) - semantic_health_score = Column(Float, nullable=False, default=100.0) - high_drift_files = Column(Integer, nullable=False, default=0) - semantic_drift_method = Column(String, nullable=False, default="none") - risk_reasons_json = Column(Text, nullable=True) + dependency_density = Column(Float, nullable=False, default=0.0) + has_cycles = Column(Boolean, nullable=False, default=False) + hotspot_count = Column(Integer, nullable=False, default=0) + avg_semantic_drift = Column(Float, nullable=False, default=0.0) + semantic_health_score = Column(Float, nullable=False, default=100.0) + high_drift_files = Column(Integer, nullable=False, default=0) + semantic_drift_method = Column(String, nullable=False, default="none") + risk_reasons_json = Column(Text, nullable=True) hotspot_persistence_score = Column(Float, nullable=False, default=0.0) persistent_hotspots_json = Column(Text, nullable=True) - top_files_json = Column(Text, nullable=True) - computed_at = Column(DateTime(timezone=True), server_default=func.now()) + top_files_json = Column(Text, nullable=True) + computed_at = Column(DateTime(timezone=True), server_default=func.now()) commit = relationship("Commit", back_populates="health_snapshot") @@ -108,18 +123,18 @@ class HealthSnapshot(Base): class GraphNode(Base): __tablename__ = "graph_nodes" - id = Column(Integer, primary_key=True, autoincrement=True) - repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) - commit_id = Column(Integer, ForeignKey("commits.id", ondelete="CASCADE"), nullable=False) - full_sha = Column(String, nullable=False) - file_path = Column(String, nullable=False) - module_name = Column(String, nullable=True) - loc = Column(Integer, nullable=False, default=0) + id = Column(Integer, primary_key=True, autoincrement=True) + repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) + commit_id = Column(Integer, ForeignKey("commits.id", ondelete="CASCADE"), nullable=False) + full_sha = Column(String, nullable=False) + file_path = Column(String, nullable=False) + module_name = Column(String, nullable=True) + loc = Column(Integer, nullable=False, default=0) avg_complexity = Column(Float, nullable=False, default=0.0) - health_color = Column(String, nullable=False, default="green") + health_color = Column(String, nullable=False, default="green") is_entry_point = Column(Boolean, nullable=False, default=False) semantic_drift_score = Column(Float, nullable=False, default=0.0) - drift_method = Column(String, nullable=False, default="none") + drift_method = Column(String, nullable=False, default="none") commit = relationship("Commit", back_populates="graph_nodes") @@ -133,14 +148,14 @@ class GraphNode(Base): class GraphEdge(Base): __tablename__ = "graph_edges" - id = Column(Integer, primary_key=True, autoincrement=True) - repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) - commit_id = Column(Integer, ForeignKey("commits.id", ondelete="CASCADE"), nullable=False) - full_sha = Column(String, nullable=False) - source_file = Column(String, nullable=False) - target_file = Column(String, nullable=False) - edge_type = Column(String, nullable=False) # 'import' | 'co_change' - weight = Column(Integer, nullable=False, default=1) + id = Column(Integer, primary_key=True, autoincrement=True) + repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) + commit_id = Column(Integer, ForeignKey("commits.id", ondelete="CASCADE"), nullable=False) + full_sha = Column(String, nullable=False) + source_file = Column(String, nullable=False) + target_file = Column(String, nullable=False) + edge_type = Column(String, nullable=False) # 'import' | 'co_change' + weight = Column(Integer, nullable=False, default=1) cochange_count = Column(Integer, nullable=True) commit = relationship("Commit", back_populates="graph_edges") @@ -156,17 +171,19 @@ class GraphEdge(Base): class BusFactor(Base): __tablename__ = "bus_factor" - id = Column(Integer, primary_key=True, autoincrement=True) - repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) - module_path = Column(String, nullable=False) - contributor_count = Column(Integer, nullable=False) - top_contributor = Column(String, nullable=True) - top_contributor_email = Column(String, nullable=True) - top_contributor_pct = Column(Float, nullable=False, default=0.0) + id = Column(Integer, primary_key=True, autoincrement=True) + repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) + module_path = Column(String, nullable=False) + contributor_count = Column(Integer, nullable=False) + top_contributor = Column(String, nullable=True) + top_contributor_email = Column(String, nullable=True) + top_contributor_pct = Column(Float, nullable=False, default=0.0) total_commits_to_module = Column(Integer, nullable=False, default=0) - risk_level = Column(String, nullable=False, default="low") - last_commit_sha = Column(String, nullable=True) - last_updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + risk_level = Column(String, nullable=False, default="low") + last_commit_sha = Column(String, nullable=True) + last_updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) repo = relationship("Repo", back_populates="bus_factor") @@ -179,20 +196,20 @@ class BusFactor(Base): class LLMNarrative(Base): __tablename__ = "llm_narratives" - id = Column(Integer, primary_key=True, autoincrement=True) - repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) - commit_id = Column(Integer, ForeignKey("commits.id", ondelete="CASCADE"), nullable=False) - full_sha = Column(String, nullable=False) - prompt_type = Column(String, nullable=False) # 'explain_drop' | 'predict_merge' - cache_key = Column(String, nullable=False, unique=True) - prompt_input = Column(Text, nullable=False) + id = Column(Integer, primary_key=True, autoincrement=True) + repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) + commit_id = Column(Integer, ForeignKey("commits.id", ondelete="CASCADE"), nullable=False) + full_sha = Column(String, nullable=False) + prompt_type = Column(String, nullable=False) # 'explain_drop' | 'predict_merge' + cache_key = Column(String, nullable=False, unique=True) + prompt_input = Column(Text, nullable=False) response_text = Column(Text, nullable=False) - tokens_input = Column(Integer, nullable=False, default=0) + tokens_input = Column(Integer, nullable=False, default=0) tokens_output = Column(Integer, nullable=False, default=0) - cost_usd = Column(Float, nullable=False, default=0.0) - model_used = Column(String, nullable=False, default="claude-sonnet-4-20250514") + cost_usd = Column(Float, nullable=False, default=0.0) + model_used = Column(String, nullable=False, default="claude-sonnet-4-20250514") is_pre_cached = Column(Boolean, nullable=False, default=False) - created_at = Column(DateTime(timezone=True), server_default=func.now()) + created_at = Column(DateTime(timezone=True), server_default=func.now()) commit = relationship("Commit", back_populates="llm_narratives") @@ -206,23 +223,21 @@ class LLMNarrative(Base): class AnalysisJob(Base): __tablename__ = "analysis_jobs" - id = Column(Integer, primary_key=True, autoincrement=True) - repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) - status = Column(String, nullable=False, default="queued") - total_commits = Column(Integer, nullable=False, default=0) + id = Column(Integer, primary_key=True, autoincrement=True) + repo_id = Column(Integer, ForeignKey("repos.id", ondelete="CASCADE"), nullable=False) + status = Column(String, nullable=False, default="queued") + total_commits = Column(Integer, nullable=False, default=0) processed_commits = Column(Integer, nullable=False, default=0) - current_sha = Column(String, nullable=True) - current_stage = Column(String, nullable=True) - progress_pct = Column(Float, nullable=False, default=0.0) - started_at = Column(DateTime(timezone=True), nullable=True) - completed_at = Column(DateTime(timezone=True), nullable=True) - duration_seconds = Column(Float, nullable=True) - error_message = Column(Text, nullable=True) - triggered_by = Column(String, nullable=False, default="user") - created_at = Column(DateTime(timezone=True), server_default=func.now()) + current_sha = Column(String, nullable=True) + current_stage = Column(String, nullable=True) + progress_pct = Column(Float, nullable=False, default=0.0) + started_at = Column(DateTime(timezone=True), nullable=True) + completed_at = Column(DateTime(timezone=True), nullable=True) + duration_seconds = Column(Float, nullable=True) + error_message = Column(Text, nullable=True) + triggered_by = Column(String, nullable=False, default="user") + created_at = Column(DateTime(timezone=True), server_default=func.now()) repo = relationship("Repo", back_populates="analysis_jobs") - __table_args__ = ( - Index("idx_jobs_repo_created", "repo_id", "created_at"), - ) + __table_args__ = (Index("idx_jobs_repo_created", "repo_id", "created_at"),) diff --git a/backend/shared/schemas.py b/backend/shared/schemas.py index 5191ffc..78a83f9 100644 --- a/backend/shared/schemas.py +++ b/backend/shared/schemas.py @@ -2,12 +2,21 @@ from datetime import datetime from typing import Literal -from backend.config import MAX_COMMITS from pydantic import BaseModel, ConfigDict, Field, field_validator +from backend.config import MAX_COMMITS RepoStatus = Literal["pending", "processing", "ready", "error"] -JobStatus = Literal["queued", "cloning", "analyzing", "building_graph", "computing_bus_factor", "ready", "error", "cancelled"] +JobStatus = Literal[ + "queued", + "cloning", + "analyzing", + "building_graph", + "computing_bus_factor", + "ready", + "error", + "cancelled", +] PromptType = Literal["explain_drop", "predict_merge"] RiskLevel = Literal["low", "medium", "high", "critical"] GraphEdgeType = Literal["import", "co_change"] diff --git a/backend/sitecustomize.py b/backend/sitecustomize.py index 56b84ab..b174068 100644 --- a/backend/sitecustomize.py +++ b/backend/sitecustomize.py @@ -4,6 +4,7 @@ `backend/`, while the application imports use the package name `backend`. Adding the project root to sys.path keeps both invocation styles working. """ + from __future__ import annotations import sys diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index c33102e..791b15c 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -2,9 +2,18 @@ def test_normalize_database_url_uses_asyncpg_for_postgres_urls(): - assert _normalize_database_url("postgres://user:pass@host/db") == "postgresql+asyncpg://user:pass@host/db" - assert _normalize_database_url("postgresql://user:pass@host/db") == "postgresql+asyncpg://user:pass@host/db" - assert _normalize_database_url("sqlite+aiosqlite:///./commitiq.db") == "sqlite+aiosqlite:///./commitiq.db" + assert ( + _normalize_database_url("postgres://user:pass@host/db") + == "postgresql+asyncpg://user:pass@host/db" + ) + assert ( + _normalize_database_url("postgresql://user:pass@host/db") + == "postgresql+asyncpg://user:pass@host/db" + ) + assert ( + _normalize_database_url("sqlite+aiosqlite:///./commitiq.db") + == "sqlite+aiosqlite:///./commitiq.db" + ) def test_parse_csv_trims_empty_values(): diff --git a/backend/tests/test_database_migrations.py b/backend/tests/test_database_migrations.py index 7231e62..bb2b0f2 100644 --- a/backend/tests/test_database_migrations.py +++ b/backend/tests/test_database_migrations.py @@ -70,6 +70,7 @@ def test_apply_sql_migrations_records_and_skips_applied_files(tmp_path: Path): """, encoding="utf-8", ) + async def run_assertions(): conn = FakeAsyncConnection() @@ -92,6 +93,7 @@ def test_apply_sql_migrations_keeps_sqlite_add_column_idempotent(tmp_path: Path) "ALTER TABLE repos ADD COLUMN status TEXT DEFAULT 'pending';", encoding="utf-8", ) + async def run_assertions(): conn = FakeAsyncConnection() await conn.execute("CREATE TABLE repos (id INTEGER PRIMARY KEY)") diff --git a/backend/tests/test_llm_logic.py b/backend/tests/test_llm_logic.py index bd77835..8b2f153 100644 --- a/backend/tests/test_llm_logic.py +++ b/backend/tests/test_llm_logic.py @@ -3,7 +3,11 @@ from types import SimpleNamespace from backend.features.llm_analysis.cache import make_cache_key -from backend.features.llm_analysis.cost_guard import check_budget, estimate_cost_usd, get_usage_summary +from backend.features.llm_analysis.cost_guard import ( + check_budget, + estimate_cost_usd, + get_usage_summary, +) from backend.features.llm_analysis.llm_router import ( LLMProvider, model_for_provider, diff --git a/backend/tests/test_repo_api.py b/backend/tests/test_repo_api.py index 235395a..9a37cb6 100644 --- a/backend/tests/test_repo_api.py +++ b/backend/tests/test_repo_api.py @@ -1,6 +1,6 @@ +import json from collections.abc import AsyncIterator from datetime import datetime, timezone -import json import pytest from fastapi import HTTPException @@ -15,10 +15,10 @@ get_bus_factor, get_commit_detail, get_graph, - ingest_progress, get_llm_usage, get_repo_by_slug, get_timeline, + ingest_progress, ingest_repo, list_repos, run_ingestion, @@ -179,120 +179,124 @@ def _seed_repo(session: Session) -> None: session.add_all([first_commit, second_commit]) session.flush() - session.add_all([ - HealthSnapshot( - repo_id=repo.id, - commit_id=first_commit.id, - full_sha=first_commit.full_sha, - health_score=82.0, - avg_complexity=2.0, - max_complexity=3.0, - total_loc=120, - churn_rate=0.2, - num_files_changed=1, - bus_factor_min=2, - health_delta=None, - cc_score=90, - churn_score=80, - bus_score=40, - loc_score=85, - complexity_drift_score=90, - churn_risk_score=80, - bus_factor_risk_score=40, - dependency_health_score=85, - top_files_json='[{"path":"src/app.py","complexity":2.0,"loc":120}]', - ), - HealthSnapshot( - repo_id=repo.id, - commit_id=second_commit.id, - full_sha=second_commit.full_sha, - health_score=68.5, - avg_complexity=7.25, - max_complexity=12.0, - total_loc=240, - churn_rate=0.35, - num_files_changed=2, - bus_factor_min=1, - health_delta=-13.5, - cc_score=63.8, - churn_score=65, - bus_score=20, - loc_score=80, - complexity_drift_score=63.8, - churn_risk_score=65, - bus_factor_risk_score=20, - dependency_health_score=80, - dependency_density=0.5, - avg_semantic_drift=0.12, - semantic_health_score=88, - high_drift_files=0, - semantic_drift_method="fallback_levenshtein", - risk_reasons_json='[{"code":"single_owner","severity":"critical","label":"Single-owner risk","detail":"At least one critical module has only one active contributor.","impact":30.0}]', - hotspot_persistence_score=37.5, - persistent_hotspots_json='[{"path":"src/service.py","recent_commit_count":3,"complexity":7.25,"loc":160}]', - top_files_json='[{"path":"src/service.py","complexity":7.25,"loc":160}]', - ), - ]) - - session.add_all([ - GraphNode( - repo_id=repo.id, - commit_id=second_commit.id, - full_sha=second_commit.full_sha, - file_path="src/service.py", - module_name="service.py", - loc=160, - avg_complexity=7.25, - health_color="yellow", - is_entry_point=False, - semantic_drift_score=0.12, - drift_method="fallback_levenshtein", - ), - GraphNode( - repo_id=repo.id, - commit_id=second_commit.id, - full_sha=second_commit.full_sha, - file_path="src/app.py", - module_name="app.py", - loc=80, - avg_complexity=2.0, - health_color="green", - is_entry_point=True, - ), - GraphEdge( - repo_id=repo.id, - commit_id=second_commit.id, - full_sha=second_commit.full_sha, - source_file="src/app.py", - target_file="src/service.py", - edge_type="import", - weight=1, - ), - BusFactor( - repo_id=repo.id, - module_path="src/service.py", - contributor_count=1, - top_contributor="Noor", - top_contributor_email="noor@example.com", - top_contributor_pct=1.0, - total_commits_to_module=2, - risk_level="critical", - last_commit_sha=second_commit.sha, - ), - LLMNarrative( - repo_id=repo.id, - commit_id=second_commit.id, - full_sha=second_commit.full_sha, - prompt_type="explain_drop", - cache_key=make_cache_key(repo.id, second_commit.full_sha, "explain_drop"), - prompt_input="{}", - response_text="Complexity increased in the service layer.", - tokens_input=10, - tokens_output=8, - cost_usd=0.00015, - model_used="claude-3-5-sonnet-20241022", - ), - ]) + session.add_all( + [ + HealthSnapshot( + repo_id=repo.id, + commit_id=first_commit.id, + full_sha=first_commit.full_sha, + health_score=82.0, + avg_complexity=2.0, + max_complexity=3.0, + total_loc=120, + churn_rate=0.2, + num_files_changed=1, + bus_factor_min=2, + health_delta=None, + cc_score=90, + churn_score=80, + bus_score=40, + loc_score=85, + complexity_drift_score=90, + churn_risk_score=80, + bus_factor_risk_score=40, + dependency_health_score=85, + top_files_json='[{"path":"src/app.py","complexity":2.0,"loc":120}]', + ), + HealthSnapshot( + repo_id=repo.id, + commit_id=second_commit.id, + full_sha=second_commit.full_sha, + health_score=68.5, + avg_complexity=7.25, + max_complexity=12.0, + total_loc=240, + churn_rate=0.35, + num_files_changed=2, + bus_factor_min=1, + health_delta=-13.5, + cc_score=63.8, + churn_score=65, + bus_score=20, + loc_score=80, + complexity_drift_score=63.8, + churn_risk_score=65, + bus_factor_risk_score=20, + dependency_health_score=80, + dependency_density=0.5, + avg_semantic_drift=0.12, + semantic_health_score=88, + high_drift_files=0, + semantic_drift_method="fallback_levenshtein", + risk_reasons_json='[{"code":"single_owner","severity":"critical","label":"Single-owner risk","detail":"At least one critical module has only one active contributor.","impact":30.0}]', + hotspot_persistence_score=37.5, + persistent_hotspots_json='[{"path":"src/service.py","recent_commit_count":3,"complexity":7.25,"loc":160}]', + top_files_json='[{"path":"src/service.py","complexity":7.25,"loc":160}]', + ), + ] + ) + + session.add_all( + [ + GraphNode( + repo_id=repo.id, + commit_id=second_commit.id, + full_sha=second_commit.full_sha, + file_path="src/service.py", + module_name="service.py", + loc=160, + avg_complexity=7.25, + health_color="yellow", + is_entry_point=False, + semantic_drift_score=0.12, + drift_method="fallback_levenshtein", + ), + GraphNode( + repo_id=repo.id, + commit_id=second_commit.id, + full_sha=second_commit.full_sha, + file_path="src/app.py", + module_name="app.py", + loc=80, + avg_complexity=2.0, + health_color="green", + is_entry_point=True, + ), + GraphEdge( + repo_id=repo.id, + commit_id=second_commit.id, + full_sha=second_commit.full_sha, + source_file="src/app.py", + target_file="src/service.py", + edge_type="import", + weight=1, + ), + BusFactor( + repo_id=repo.id, + module_path="src/service.py", + contributor_count=1, + top_contributor="Noor", + top_contributor_email="noor@example.com", + top_contributor_pct=1.0, + total_commits_to_module=2, + risk_level="critical", + last_commit_sha=second_commit.sha, + ), + LLMNarrative( + repo_id=repo.id, + commit_id=second_commit.id, + full_sha=second_commit.full_sha, + prompt_type="explain_drop", + cache_key=make_cache_key(repo.id, second_commit.full_sha, "explain_drop"), + prompt_input="{}", + response_text="Complexity increased in the service layer.", + tokens_input=10, + tokens_output=8, + cost_usd=0.00015, + model_used="claude-3-5-sonnet-20241022", + ), + ] + ) session.commit() @@ -341,7 +345,9 @@ async def test_timeline_returns_snapshot_payloads(db_session: AsyncSessionAdapte assert payload["commits"][1]["persistent_hotspots"][0]["path"] == "src/service.py" -async def test_graph_bus_factor_and_usage_endpoints_return_seeded_data(db_session: AsyncSessionAdapter): +async def test_graph_bus_factor_and_usage_endpoints_return_seeded_data( + db_session: AsyncSessionAdapter, +): graph = await get_graph(repo_id=1, sha="def456", db=db_session) assert graph["commit_sha"] == "def456abc123" assert {node["file"] for node in graph["nodes"]} == {"src/app.py", "src/service.py"} @@ -365,7 +371,9 @@ async def test_graph_bus_factor_and_usage_endpoints_return_seeded_data(db_sessio assert usage["total_tokens"] == 18 -async def test_commit_detail_includes_nested_snapshot_graph_and_cached_narrative(db_session: AsyncSessionAdapter): +async def test_commit_detail_includes_nested_snapshot_graph_and_cached_narrative( + db_session: AsyncSessionAdapter, +): detail = await get_commit_detail(repo_id=1, sha="def456", db=db_session) assert detail["repo"].repo_slug == "example-project" @@ -378,7 +386,9 @@ async def test_commit_detail_includes_nested_snapshot_graph_and_cached_narrative assert detail["narrative"]["explanation"] == "Complexity increased in the service layer." -async def test_streaming_narrative_falls_back_to_demo_mode(db_session: AsyncSessionAdapter, monkeypatch): +async def test_streaming_narrative_falls_back_to_demo_mode( + db_session: AsyncSessionAdapter, monkeypatch +): async def failing_stream(prompt: str): raise RuntimeError("provider keys missing") yield prompt, None @@ -399,15 +409,21 @@ async def failing_stream(prompt: str): assert "DEMO MODE" in final_payload["explanation"] assert "error" not in final_payload - cached = db_session.session.query(LLMNarrative).filter( - LLMNarrative.cache_key - == make_cache_key(1, "abc123def4567890abc123def4567890abc123de", "explain_drop") - ).one() + cached = ( + db_session.session.query(LLMNarrative) + .filter( + LLMNarrative.cache_key + == make_cache_key(1, "abc123def4567890abc123def4567890abc123de", "explain_drop") + ) + .one() + ) assert cached.model_used == "demo-mode" assert cached.cost_usd == 0.0 -async def test_ingest_repo_reuses_active_job_without_scheduling_duplicate(db_session: AsyncSessionAdapter): +async def test_ingest_repo_reuses_active_job_without_scheduling_duplicate( + db_session: AsyncSessionAdapter, +): active_job = AnalysisJob( repo_id=1, status="analyzing", @@ -435,7 +451,9 @@ async def test_ingest_repo_reuses_active_job_without_scheduling_duplicate(db_ses assert background_tasks.tasks == [] -async def test_ingest_repo_schedules_created_job_by_id(db_session: AsyncSessionAdapter, monkeypatch): +async def test_ingest_repo_schedules_created_job_by_id( + db_session: AsyncSessionAdapter, monkeypatch +): async def fake_fetch_github_metadata(owner: str, repo: str): return { "github_stars": 0, @@ -570,27 +588,30 @@ async def test_ingest_progress_stream_picks_up_cancelled_job_updates(monkeypatch async def skip_sleep(seconds: float): return None - db = _mock_progress_db(monkeypatch, [ - AnalysisJob( - repo_id=1, - status="queued", - total_commits=5, - processed_commits=0, - current_stage="Queued", - progress_pct=0.0, - triggered_by="user", - ), - AnalysisJob( - repo_id=1, - status="cancelled", - total_commits=5, - processed_commits=0, - current_stage="Cancelled", - progress_pct=0.0, - error_message="Ingestion cancelled by user.", - triggered_by="user", - ), - ]) + db = _mock_progress_db( + monkeypatch, + [ + AnalysisJob( + repo_id=1, + status="queued", + total_commits=5, + processed_commits=0, + current_stage="Queued", + progress_pct=0.0, + triggered_by="user", + ), + AnalysisJob( + repo_id=1, + status="cancelled", + total_commits=5, + processed_commits=0, + current_stage="Cancelled", + progress_pct=0.0, + error_message="Ingestion cancelled by user.", + triggered_by="user", + ), + ], + ) monkeypatch.setattr("backend.features.repo_ingestion.router.asyncio.sleep", skip_sleep) response = await ingest_progress(repo_id=1) diff --git a/backend/tests/test_repo_ingestion_logic.py b/backend/tests/test_repo_ingestion_logic.py index ba7843a..d96c7de 100644 --- a/backend/tests/test_repo_ingestion_logic.py +++ b/backend/tests/test_repo_ingestion_logic.py @@ -4,6 +4,7 @@ from pydantic import ValidationError from backend.config import MAX_COMMITS +from backend.features.repo_ingestion import semantic_analyzer from backend.features.repo_ingestion.bus_factor import is_code_file from backend.features.repo_ingestion.clone_service import ( cleanup_repo, @@ -19,7 +20,6 @@ resolve_import_to_file, ) from backend.features.repo_ingestion.health_scorer import compute_full_snapshot -from backend.features.repo_ingestion import semantic_analyzer from backend.shared.schemas import IngestRequest @@ -109,15 +109,13 @@ def test_import_extractors_and_resolver_cover_common_python_and_ts_patterns(): assert "os" in python_imports assert "package.module" in python_imports - js_imports = extract_js_imports( - """ + js_imports = extract_js_imports(""" import type { User } from './types' export { Button } from './Button' import './polyfill' const mod = require('../lib/mod') const lazy = import('./lazy') - """ - ) + """) assert js_imports == ["./types", "./Button", "./polyfill", "../lib/mod", "./lazy"] files = [ @@ -187,7 +185,9 @@ def fail_if_model_loads(): monkeypatch.setattr(semantic_analyzer, "ENABLE_GRAPHCODEBERT", False) monkeypatch.setattr(semantic_analyzer, "_load_model", fail_if_model_loads) - result = semantic_analyzer.compute_semantic_drift("def value():\n return 1\n", "def value():\n return 2\n") + result = semantic_analyzer.compute_semantic_drift( + "def value():\n return 1\n", "def value():\n return 2\n" + ) assert result["method"] == "fallback_levenshtein" assert result["model"] == "difflib.SequenceMatcher" diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index af6c948..3db6abe 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -59,13 +59,17 @@ vi.mock('./components/HealthTimeline', () => ({ vi.mock('./components/HotspotMap', () => ({ HotspotMap: ({ repoId, sha }: { repoId: number; sha: string | null }) => ( -
hotspots {repoId}:{sha ?? 'none'}
+
+ hotspots {repoId}:{sha ?? 'none'} +
), })) vi.mock('./components/NarrativeCard', () => ({ NarrativeCard: ({ repoId, commitSha }: { repoId: number; commitSha: string }) => ( -
narrative {repoId}:{commitSha}
+
+ narrative {repoId}:{commitSha} +
), })) @@ -141,7 +145,7 @@ function renderApp() { return render( new Map(), dedupingInterval: 0, revalidateOnFocus: false }}> - , + ) } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ced0e27..18d989a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,7 +12,9 @@ const NotFoundPage = lazy(() => import('./pages/NotFoundPage')) function RouteFallback() { return (
-
Loading workspace...
+
+ Loading workspace... +
) } diff --git a/frontend/src/components/AmbientBackground.tsx b/frontend/src/components/AmbientBackground.tsx index 6f22a5b..b04360a 100644 --- a/frontend/src/components/AmbientBackground.tsx +++ b/frontend/src/components/AmbientBackground.tsx @@ -1,24 +1,24 @@ export default function AmbientBackground() { return (
-
-
-
-
-
{ last_commit_sha: 'abc123', }, ]} - />, + /> ) expect(screen.getByText('src/core/risky.ts')).toBeInTheDocument() expect(consoleError).not.toHaveBeenCalledWith( expect.stringContaining('validateDOMNesting'), expect.anything(), - expect.anything(), + expect.anything() ) }) }) diff --git a/frontend/src/components/BusFactorTable.tsx b/frontend/src/components/BusFactorTable.tsx index 9fd7cb2..3d79717 100644 --- a/frontend/src/components/BusFactorTable.tsx +++ b/frontend/src/components/BusFactorTable.tsx @@ -11,7 +11,9 @@ const RISK_COLORS: Record = { const RISK_ORDER = { critical: 0, high: 1, medium: 2, low: 3 } export function BusFactorTable({ modules }: BusFactorTableProps) { - const sorted = [...modules].sort((a, b) => RISK_ORDER[a.risk_level] - RISK_ORDER[b.risk_level]).slice(0, 20) + const sorted = [...modules] + .sort((a, b) => RISK_ORDER[a.risk_level] - RISK_ORDER[b.risk_level]) + .slice(0, 20) return (
@@ -21,9 +23,13 @@ export function BusFactorTable({ modules }: BusFactorTableProps) {
-

Bus Factor Index

+

+ Bus Factor Index +

-

Contributor concentration distribution per system module

+

+ Contributor concentration distribution per system module +

@@ -38,20 +44,29 @@ export function BusFactorTable({ modules }: BusFactorTableProps) { - - - - + + + + {sorted.map((module) => { const rgb = RISK_COLORS[module.risk_level] || '156, 163, 175' - const isCritical = module.risk_level === 'critical' || module.risk_level === 'high' + const isCritical = + module.risk_level === 'critical' || module.risk_level === 'high' return ( - @@ -60,14 +75,14 @@ export function BusFactorTable({ modules }: BusFactorTableProps) { {module.module_path} - +
Module PathRisk TierContributorsPrincipal Owner + Module Path + + Risk Tier + + Contributors + + Principal Owner +
- {isCritical ? ( @@ -80,7 +95,7 @@ export function BusFactorTable({ modules }: BusFactorTableProps) { - diff --git a/frontend/src/components/CommitList.tsx b/frontend/src/components/CommitList.tsx index 4a1da7a..45d7d49 100644 --- a/frontend/src/components/CommitList.tsx +++ b/frontend/src/components/CommitList.tsx @@ -30,11 +30,11 @@ export function CommitList({ commits, repoSlug, selectedSha, onSelect }: CommitL const healthColor = getHealthColor(commit.health_score) return ( -
@@ -42,37 +42,37 @@ export function CommitList({ commits, repoSlug, selectedSha, onSelect }: CommitL )} - - diff --git a/frontend/src/components/CostMeter.tsx b/frontend/src/components/CostMeter.tsx index 554746a..8e6c060 100644 --- a/frontend/src/components/CostMeter.tsx +++ b/frontend/src/components/CostMeter.tsx @@ -22,9 +22,7 @@ export function CostMeter({ usage, loading, error }: CostMeterProps) { if (!usage) { return ( -
- No active resource tracking established. -
+
No active resource tracking established.
) } @@ -40,10 +38,10 @@ export function CostMeter({ usage, loading, error }: CostMeterProps) { API Allocation
- @@ -52,13 +50,13 @@ export function CostMeter({ usage, loading, error }: CostMeterProps) {
-
diff --git a/frontend/src/components/GraphDiffPanel.tsx b/frontend/src/components/GraphDiffPanel.tsx index 6dda05e..90b4834 100644 --- a/frontend/src/components/GraphDiffPanel.tsx +++ b/frontend/src/components/GraphDiffPanel.tsx @@ -19,17 +19,25 @@ function StatChip({ label, value }: { label: string; value: number }) { export function GraphDiffPanel({ repoId, commitSha, previousSha }: GraphDiffPanelProps) { const diffState = useSWR( previousSha ? ['graph-diff', repoId, previousSha, commitSha] : null, - () => getGraphDiff(repoId, previousSha as string, commitSha), + () => getGraphDiff(repoId, previousSha as string, commitSha) ) if (!previousSha) return null if (diffState.isLoading) { - return
Loading structural diff...
+ return ( +
+ Loading structural diff... +
+ ) } if (diffState.error || !diffState.data) { - return
Could not load structural diff.
+ return ( +
+ Could not load structural diff. +
+ ) } const diff = diffState.data @@ -47,13 +55,25 @@ export function GraphDiffPanel({ repoId, commitSha, previousSha }: GraphDiffPane {diff.nodes_changed.length > 0 ? (
-

Biggest Complexity Shifts

+

+ Biggest Complexity Shifts +

{diff.nodes_changed.slice(0, 5).map((node) => ( -
+
{node.file} - 0 ? 'text-health-critical font-mono' : 'text-health-good font-mono'}> - {node.delta_pct > 0 ? '+' : ''}{node.delta_pct}% + 0 + ? 'text-health-critical font-mono' + : 'text-health-good font-mono' + } + > + {node.delta_pct > 0 ? '+' : ''} + {node.delta_pct}%
))} diff --git a/frontend/src/components/GraphExplorer.tsx b/frontend/src/components/GraphExplorer.tsx index 0d51879..66b6c56 100644 --- a/frontend/src/components/GraphExplorer.tsx +++ b/frontend/src/components/GraphExplorer.tsx @@ -1,25 +1,25 @@ import { useCallback, useState, useEffect, useRef, useMemo } from 'react' import ForceGraph2D, { type ForceGraphMethods } from 'react-force-graph-2d' import { forceCollide } from 'd3-force' -import { - Play, - Pause, - Search, - Filter, - AlertTriangle, - ShieldCheck, - Activity, - Maximize2, - Minimize2, - ZoomIn, - ZoomOut, - RefreshCw, - Layers, - Compass, +import { + Play, + Pause, + Search, + Filter, + AlertTriangle, + ShieldCheck, + Activity, + Maximize2, + Minimize2, + ZoomIn, + ZoomOut, + RefreshCw, + Layers, + Compass, Info, ChevronRight, TrendingUp, - GitCommit + GitCommit, } from 'lucide-react' import type { ForceGraphLink, ForceGraphNode, GraphExplorerProps } from '../types' @@ -48,7 +48,13 @@ const HEALTH_COLORS_RGB: Record = { neutral: '156, 163, 175', } -const NODE_SIZE_METRICS = new Set(['loc', 'churn', 'coupling', 'instability', 'equal']) +const NODE_SIZE_METRICS = new Set([ + 'loc', + 'churn', + 'coupling', + 'instability', + 'equal', +]) function isNodeSizeMetric(value: string): value is NodeSizeMetric { return NODE_SIZE_METRICS.has(value as NodeSizeMetric) @@ -63,7 +69,12 @@ const getNodeId = (node: unknown): string => { return String(node) } -export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCommit }: GraphExplorerProps) { +export function GraphExplorer({ + graphData, + selectedSha, + commits = [], + onSelectCommit, +}: GraphExplorerProps) { const [hoveredNode, setHoveredNode] = useState(null) const [selectedNodeId, setSelectedNodeId] = useState(null) const [searchQuery, setSearchQuery] = useState('') @@ -77,7 +88,7 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo const [showCoChange, setShowCoChange] = useState(true) const [selectedModule, setSelectedModule] = useState('all') const [selectedRisk, setSelectedRisk] = useState('all') - + const [highlightCyclic, setHighlightCyclic] = useState(false) const [highlightHotspots, setHighlightHotspots] = useState(false) const [highlightStability, setHighlightStability] = useState(false) @@ -144,15 +155,15 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo if (graphData && graphData.nodes.length > 0) { if (prevNodesRef.current.length > 0) { const prevSet = new Set(prevNodesRef.current) - const currentSet = new Set(graphData.nodes.map(n => n.id)) + const currentSet = new Set(graphData.nodes.map((n) => n.id)) const newlyAdded = new Set() - - currentSet.forEach(id => { + + currentSet.forEach((id) => { if (!prevSet.has(id)) newlyAdded.add(id) }) setAddedNodeIds(newlyAdded) } - prevNodesRef.current = graphData.nodes.map(n => n.id) + prevNodesRef.current = graphData.nodes.map((n) => n.id) } }, [graphData]) @@ -167,16 +178,20 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo health_color: node.health_color, loc: node.loc, health: node.health, - is_entry_point: node.is_entry_point || node.file.includes('main') || node.file.includes('index') || node.file.includes('App') + is_entry_point: + node.is_entry_point || + node.file.includes('main') || + node.file.includes('index') || + node.file.includes('App'), })) }, [graphData]) const links = useMemo(() => { if (!graphData) return [] - const filteredEdges = graphData.edges.filter((edge) => ( - (showImports && edge.type === 'import') || - (showCoChange && edge.type === 'co_change') - )) + const filteredEdges = graphData.edges.filter( + (edge) => + (showImports && edge.type === 'import') || (showCoChange && edge.type === 'co_change') + ) return filteredEdges.map((edge) => ({ source: edge.source, target: edge.target, @@ -195,11 +210,12 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo // Client-Side Cyclic Dependency Detection DFS const cyclicNodesAndEdges = useMemo(() => { - if (!links.length || !nodes.length) return { nodes: new Set(), edges: new Set() } - - const importLinks = links.filter(l => l.type === 'import') + if (!links.length || !nodes.length) + return { nodes: new Set(), edges: new Set() } + + const importLinks = links.filter((l) => l.type === 'import') const adj = new Map() - importLinks.forEach(l => { + importLinks.forEach((l) => { const s = getNodeId(l.source) const t = getNodeId(l.target) if (!adj.has(s)) adj.set(s, []) @@ -215,13 +231,13 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo visiting.add(node) parentPath.push(node) const neighbors = adj.get(node) || [] - + for (const next of neighbors) { if (visiting.has(next)) { const startIdx = parentPath.indexOf(next) if (startIdx !== -1) { const cyclePath = parentPath.slice(startIdx) - cyclePath.forEach(n => cyclicNodes.add(n)) + cyclePath.forEach((n) => cyclicNodes.add(n)) for (let i = 0; i < cyclePath.length; i++) { const u = cyclePath[i] const v = cyclePath[(i + 1) % cyclePath.length] @@ -237,7 +253,7 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo visited.add(node) } - nodes.forEach(n => { + nodes.forEach((n) => { if (!visited.has(n.id)) { dfs(n.id, []) } @@ -248,7 +264,7 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo const uniqueModules = useMemo(() => { const mods = new Set() - nodes.forEach(n => { + nodes.forEach((n) => { if (n.module) mods.add(n.module) }) return Array.from(mods) @@ -258,16 +274,16 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo const couplingMetrics = useMemo(() => { const afferent = new Map() const efferent = new Map() - - nodes.forEach(n => { + + nodes.forEach((n) => { afferent.set(n.id, 0) efferent.set(n.id, 0) }) - links.forEach(l => { + links.forEach((l) => { const s = getNodeId(l.source) const t = getNodeId(l.target) - + if (l.type === 'import') { efferent.set(s, (efferent.get(s) || 0) + 1) afferent.set(t, (afferent.get(t) || 0) + 1) @@ -275,7 +291,7 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo }) const instMap = new Map() - nodes.forEach(n => { + nodes.forEach((n) => { const ca = afferent.get(n.id) || 0 const ce = efferent.get(n.id) || 0 const denominator = ca + ce @@ -288,13 +304,13 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo const selectedNodeDetails = useMemo(() => { if (!selectedNodeId) return null - const matched = nodes.find(n => n.id === selectedNodeId) + const matched = nodes.find((n) => n.id === selectedNodeId) if (!matched) return null const importsList: string[] = [] const importedByList: string[] = [] - links.forEach(l => { + links.forEach((l) => { const s = getNodeId(l.source) const t = getNodeId(l.target) if (l.type === 'import') { @@ -315,21 +331,21 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo instability, isCyclic, imports: importsList, - importedBy: importedByList + importedBy: importedByList, } }, [selectedNodeId, nodes, links, couplingMetrics, cyclicNodesAndEdges]) const systemStability = useMemo(() => { if (nodes.length === 0) return 1.0 let totalInstability = 0 - nodes.forEach(n => { + nodes.forEach((n) => { totalInstability += couplingMetrics.instability.get(n.id) || 0 }) - return Math.max(0, Math.min(1, 1 - (totalInstability / nodes.length))) + return Math.max(0, Math.min(1, 1 - totalInstability / nodes.length)) }, [nodes, couplingMetrics]) const filteredGraphData = useMemo(() => { - const matchingNodes = nodes.filter(n => { + const matchingNodes = nodes.filter((n) => { if (selectedModule !== 'all' && n.module !== selectedModule) return false if (selectedRisk !== 'all' && n.health_color !== selectedRisk) return false if (searchQuery) { @@ -339,9 +355,9 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo return true }) - const matchingNodeIds = new Set(matchingNodes.map(n => n.id)) + const matchingNodeIds = new Set(matchingNodes.map((n) => n.id)) - const matchingLinks = links.filter(l => { + const matchingLinks = links.filter((l) => { const s = getNodeId(l.source) const t = getNodeId(l.target) return matchingNodeIds.has(s) && matchingNodeIds.has(t) @@ -350,23 +366,26 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo return { nodes: matchingNodes, links: matchingLinks } }, [nodes, links, selectedModule, selectedRisk, searchQuery]) - const getNodeSize = useCallback((node: RenderNode): number => { - let base = 5 - if (nodeSizeMetric === 'loc') { - base = Math.sqrt(Math.max(node.loc || 0, 10)) * 0.9 + 2.5 - } else if (nodeSizeMetric === 'churn') { - base = Math.sqrt(Math.max(node.churn || 0, 0.05)) * 5.5 + 2.5 - } else if (nodeSizeMetric === 'coupling') { - const afferent = couplingMetrics.afferent.get(node.id) || 0 - const efferent = couplingMetrics.efferent.get(node.id) || 0 - base = Math.sqrt(afferent + efferent) * 2.2 + 2.5 - } else if (nodeSizeMetric === 'instability') { - base = (couplingMetrics.instability.get(node.id) || 0) * 6.5 + 2.5 - } else { - base = 5.5 - } - return Math.min(Math.max(base, 4.0), 16) - }, [nodeSizeMetric, couplingMetrics]) + const getNodeSize = useCallback( + (node: RenderNode): number => { + let base = 5 + if (nodeSizeMetric === 'loc') { + base = Math.sqrt(Math.max(node.loc || 0, 10)) * 0.9 + 2.5 + } else if (nodeSizeMetric === 'churn') { + base = Math.sqrt(Math.max(node.churn || 0, 0.05)) * 5.5 + 2.5 + } else if (nodeSizeMetric === 'coupling') { + const afferent = couplingMetrics.afferent.get(node.id) || 0 + const efferent = couplingMetrics.efferent.get(node.id) || 0 + base = Math.sqrt(afferent + efferent) * 2.2 + 2.5 + } else if (nodeSizeMetric === 'instability') { + base = (couplingMetrics.instability.get(node.id) || 0) * 6.5 + 2.5 + } else { + base = 5.5 + } + return Math.min(Math.max(base, 4.0), 16) + }, + [nodeSizeMetric, couplingMetrics] + ) // D3 force-directed stable layout fit centering const refitPendingRef = useRef(true) @@ -377,7 +396,7 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo if (charge) charge.strength(-650) const link = graphRef.current.d3Force('link') if (link) link.distance(160) - + const collide = forceCollide().radius((node) => getNodeSize(node) + 32) graphRef.current.d3Force('collide', collide) } @@ -404,19 +423,22 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo } }, [filteredGraphData]) - const handleFocusNode = useCallback((nodeId: string) => { - const canvasNode = nodes.find(n => n.id === nodeId) - if (canvasNode && graphRef.current) { - graphRef.current.centerAt(canvasNode.x, canvasNode.y, 800) - graphRef.current.zoom(1.8, 800) - setSelectedNodeId(nodeId) - setIsSidebarOpen(true) - } - }, [nodes]) + const handleFocusNode = useCallback( + (nodeId: string) => { + const canvasNode = nodes.find((n) => n.id === nodeId) + if (canvasNode && graphRef.current) { + graphRef.current.centerAt(canvasNode.x, canvasNode.y, 800) + graphRef.current.zoom(1.8, 800) + setSelectedNodeId(nodeId) + setIsSidebarOpen(true) + } + }, + [nodes] + ) useEffect(() => { if (isPlaying && commits.length > 0 && onSelectCommit) { - const currentIndex = commits.findIndex(c => c.sha === selectedSha) + const currentIndex = commits.findIndex((c) => c.sha === selectedSha) playIntervalRef.current = setInterval(() => { const nextIndex = (currentIndex + 1) % commits.length onSelectCommit(commits[nextIndex]) @@ -430,244 +452,270 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo } }, [isPlaying, commits, selectedSha, playSpeed, onSelectCommit]) - const getNodeColor = useCallback((node: RenderNode): string => { - const focusNodeId = selectedNodeId || hoveredNode - - let isFocused = true - if (focusNodeId) { - isFocused = node.id === focusNodeId || links.some(l => { - const s = getNodeId(l.source) - const t = getNodeId(l.target) - return (s === focusNodeId && t === node.id) || (t === focusNodeId && s === node.id) - }) - } - const baseOpacity = focusNodeId ? (isFocused ? 1.0 : 0.12) : 1.0 + const getNodeColor = useCallback( + (node: RenderNode): string => { + const focusNodeId = selectedNodeId || hoveredNode + + let isFocused = true + if (focusNodeId) { + isFocused = + node.id === focusNodeId || + links.some((l) => { + const s = getNodeId(l.source) + const t = getNodeId(l.target) + return (s === focusNodeId && t === node.id) || (t === focusNodeId && s === node.id) + }) + } + const baseOpacity = focusNodeId ? (isFocused ? 1.0 : 0.12) : 1.0 - if (highlightStability) { - const instability = couplingMetrics.instability.get(node.id) || 0.5 - const red = Math.round(instability * 239) - const green = Math.round((1 - instability) * 197) - return `rgba(${red}, ${green}, 120, ${baseOpacity})` - } + if (highlightStability) { + const instability = couplingMetrics.instability.get(node.id) || 0.5 + const red = Math.round(instability * 239) + const green = Math.round((1 - instability) * 197) + return `rgba(${red}, ${green}, 120, ${baseOpacity})` + } - const baseColor = HEALTH_COLORS_RGB[node.health_color] || HEALTH_COLORS_RGB.neutral - return `rgba(${baseColor}, ${baseOpacity})` - }, [hoveredNode, selectedNodeId, links, highlightStability, couplingMetrics]) - - const drawNodeCanvas = useCallback((node: RenderNode, ctx: CanvasRenderingContext2D, globalScale: number) => { - if ( - !node || - node.x === undefined || - node.y === undefined || - node.x === null || - node.y === null || - isNaN(node.x) || - isNaN(node.y) || - !isFinite(node.x) || - !isFinite(node.y) - ) return - - const size = getNodeSize(node) - const color = getNodeColor(node) - - const isHotspot = highlightHotspots && node.health_color === 'red' && (couplingMetrics.afferent.get(node.id) || 0) > 2 - if (isHotspot) { - const pulseTime = Date.now() / 300 - const radius = size + 4 + Math.sin(pulseTime) * 2 - ctx.beginPath() - ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI) - ctx.fillStyle = 'rgba(239, 68, 68, 0.12)' - ctx.fill() - ctx.strokeStyle = 'rgba(239, 68, 68, 0.35)' - ctx.lineWidth = 1.0 / globalScale - ctx.stroke() - } + const baseColor = HEALTH_COLORS_RGB[node.health_color] || HEALTH_COLORS_RGB.neutral + return `rgba(${baseColor}, ${baseOpacity})` + }, + [hoveredNode, selectedNodeId, links, highlightStability, couplingMetrics] + ) - const isAdded = addedNodeIds.has(node.id) - if (isAdded) { - const pulseTime = Date.now() / 200 - const radius = size + 5 + Math.sin(pulseTime) * 1.5 - ctx.beginPath() - ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI) - ctx.fillStyle = 'rgba(52, 211, 153, 0.08)' - ctx.fill() - ctx.strokeStyle = 'rgba(52, 211, 153, 0.5)' - ctx.lineWidth = 1.5 / globalScale - ctx.stroke() - } + const drawNodeCanvas = useCallback( + (node: RenderNode, ctx: CanvasRenderingContext2D, globalScale: number) => { + if ( + !node || + node.x === undefined || + node.y === undefined || + node.x === null || + node.y === null || + isNaN(node.x) || + isNaN(node.y) || + !isFinite(node.x) || + !isFinite(node.y) + ) + return + + const size = getNodeSize(node) + const color = getNodeColor(node) + + const isHotspot = + highlightHotspots && + node.health_color === 'red' && + (couplingMetrics.afferent.get(node.id) || 0) > 2 + if (isHotspot) { + const pulseTime = Date.now() / 300 + const radius = size + 4 + Math.sin(pulseTime) * 2 + ctx.beginPath() + ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI) + ctx.fillStyle = 'rgba(239, 68, 68, 0.12)' + ctx.fill() + ctx.strokeStyle = 'rgba(239, 68, 68, 0.35)' + ctx.lineWidth = 1.0 / globalScale + ctx.stroke() + } - const isCyclic = highlightCyclic && cyclicNodesAndEdges.nodes.has(node.id) - if (isCyclic) { - ctx.beginPath() - ctx.arc(node.x, node.y, size + 2, 0, 2 * Math.PI) - ctx.strokeStyle = 'rgba(245, 158, 11, 0.65)' - ctx.lineWidth = 2.0 / globalScale - ctx.setLineDash([2.5, 2]) - ctx.stroke() - ctx.setLineDash([]) - } + const isAdded = addedNodeIds.has(node.id) + if (isAdded) { + const pulseTime = Date.now() / 200 + const radius = size + 5 + Math.sin(pulseTime) * 1.5 + ctx.beginPath() + ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI) + ctx.fillStyle = 'rgba(52, 211, 153, 0.08)' + ctx.fill() + ctx.strokeStyle = 'rgba(52, 211, 153, 0.5)' + ctx.lineWidth = 1.5 / globalScale + ctx.stroke() + } - ctx.save() - ctx.beginPath() - if (node.is_entry_point) { - ctx.moveTo(node.x, node.y - size) - ctx.lineTo(node.x + size, node.y) - ctx.lineTo(node.x, node.y + size) - ctx.lineTo(node.x - size, node.y) - ctx.closePath() - } else { - ctx.arc(node.x, node.y, size, 0, 2 * Math.PI, false) - } + const isCyclic = highlightCyclic && cyclicNodesAndEdges.nodes.has(node.id) + if (isCyclic) { + ctx.beginPath() + ctx.arc(node.x, node.y, size + 2, 0, 2 * Math.PI) + ctx.strokeStyle = 'rgba(245, 158, 11, 0.65)' + ctx.lineWidth = 2.0 / globalScale + ctx.setLineDash([2.5, 2]) + ctx.stroke() + ctx.setLineDash([]) + } - const gradient = ctx.createRadialGradient( - node.x - size * 0.35, - node.y - size * 0.35, - size * 0.1, - node.x, - node.y, - size - ) - - gradient.addColorStop(0, '#ffffff') - gradient.addColorStop(0.15, color) - gradient.addColorStop(0.85, color.replace(/[\d.]+\)$/, '0.9)')) - gradient.addColorStop(1, color.replace(/[\d.]+\)$/, '0.7)')) - - ctx.fillStyle = gradient - ctx.fill() - - const isHovered = hoveredNode === node.id - const isSelected = selectedNodeId === node.id - if (isHovered || isSelected) { - ctx.shadowColor = isSelected ? '#A78BFA' : '#60A5FA' - ctx.shadowBlur = 12 / globalScale - ctx.lineWidth = isSelected ? 2.5 / globalScale : 1.5 / globalScale - ctx.strokeStyle = isSelected ? '#C084FC' : '#93C5FD' - ctx.stroke() - } else { - ctx.lineWidth = 0.85 / globalScale - ctx.strokeStyle = 'rgba(255, 255, 255, 0.22)' - ctx.stroke() - } - ctx.restore() - - if (globalScale > 0.45) { - const label = node.file.split('/').pop() || node.name - const fontSize = Math.max(3.5, size * 0.6) - ctx.font = `500 ${fontSize}px var(--font-mono, monospace)` - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - - const textWidth = ctx.measureText(label).width - const isFocused = isHovered || isSelected - - const paddingX = 6 - const paddingY = 3.5 - const rectX = node.x - textWidth / 2 - paddingX - const rectY = node.y + size + 5 - const rectW = textWidth + paddingX * 2 - const rectH = fontSize + paddingY * 2 - - ctx.fillStyle = 'rgba(10, 11, 16, 0.72)' + ctx.save() ctx.beginPath() - if (typeof ctx.roundRect === 'function') { - ctx.roundRect(rectX, rectY, rectW, rectH, 6) + if (node.is_entry_point) { + ctx.moveTo(node.x, node.y - size) + ctx.lineTo(node.x + size, node.y) + ctx.lineTo(node.x, node.y + size) + ctx.lineTo(node.x - size, node.y) + ctx.closePath() } else { - ctx.rect(rectX, rectY, rectW, rectH) + ctx.arc(node.x, node.y, size, 0, 2 * Math.PI, false) } + + const gradient = ctx.createRadialGradient( + node.x - size * 0.35, + node.y - size * 0.35, + size * 0.1, + node.x, + node.y, + size + ) + + gradient.addColorStop(0, '#ffffff') + gradient.addColorStop(0.15, color) + gradient.addColorStop(0.85, color.replace(/[\d.]+\)$/, '0.9)')) + gradient.addColorStop(1, color.replace(/[\d.]+\)$/, '0.7)')) + + ctx.fillStyle = gradient ctx.fill() - - ctx.strokeStyle = isFocused - ? 'rgba(167, 139, 250, 0.75)' - : 'rgba(255, 255, 255, 0.08)' - ctx.lineWidth = 0.65 / globalScale - ctx.stroke() - - ctx.fillStyle = isFocused ? '#FFFFFF' : '#E2E8F0' - ctx.fillText(label, node.x, rectY + rectH / 2 + 0.2) - } - }, [getNodeSize, getNodeColor, hoveredNode, selectedNodeId, addedNodeIds, cyclicNodesAndEdges, highlightCyclic, highlightHotspots, couplingMetrics]) - const drawBackgroundClusters = useCallback((ctx: CanvasRenderingContext2D, globalScale: number) => { - if (nodes.length === 0) return + const isHovered = hoveredNode === node.id + const isSelected = selectedNodeId === node.id + if (isHovered || isSelected) { + ctx.shadowColor = isSelected ? '#A78BFA' : '#60A5FA' + ctx.shadowBlur = 12 / globalScale + ctx.lineWidth = isSelected ? 2.5 / globalScale : 1.5 / globalScale + ctx.strokeStyle = isSelected ? '#C084FC' : '#93C5FD' + ctx.stroke() + } else { + ctx.lineWidth = 0.85 / globalScale + ctx.strokeStyle = 'rgba(255, 255, 255, 0.22)' + ctx.stroke() + } + ctx.restore() + + if (globalScale > 0.45) { + const label = node.file.split('/').pop() || node.name + const fontSize = Math.max(3.5, size * 0.6) + ctx.font = `500 ${fontSize}px var(--font-mono, monospace)` + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + const textWidth = ctx.measureText(label).width + const isFocused = isHovered || isSelected + + const paddingX = 6 + const paddingY = 3.5 + const rectX = node.x - textWidth / 2 - paddingX + const rectY = node.y + size + 5 + const rectW = textWidth + paddingX * 2 + const rectH = fontSize + paddingY * 2 + + ctx.fillStyle = 'rgba(10, 11, 16, 0.72)' + ctx.beginPath() + if (typeof ctx.roundRect === 'function') { + ctx.roundRect(rectX, rectY, rectW, rectH, 6) + } else { + ctx.rect(rectX, rectY, rectW, rectH) + } + ctx.fill() - const moduleGroups = new Map() - nodes.forEach(node => { - const parts = node.file.split('/') - const moduleName = parts.length > 1 ? parts[0] : 'core' - if (!moduleGroups.has(moduleName)) { - moduleGroups.set(moduleName, []) + ctx.strokeStyle = isFocused ? 'rgba(167, 139, 250, 0.75)' : 'rgba(255, 255, 255, 0.08)' + ctx.lineWidth = 0.65 / globalScale + ctx.stroke() + + ctx.fillStyle = isFocused ? '#FFFFFF' : '#E2E8F0' + ctx.fillText(label, node.x, rectY + rectH / 2 + 0.2) } - moduleGroups.get(moduleName)!.push(node) - }) + }, + [ + getNodeSize, + getNodeColor, + hoveredNode, + selectedNodeId, + addedNodeIds, + cyclicNodesAndEdges, + highlightCyclic, + highlightHotspots, + couplingMetrics, + ] + ) - const clusterAccent = (mod: string): string => { - const hash = mod.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0) - const hues = [267, 190, 142, 35, 12, 335] - const hue = hues[hash % hues.length] - return `hsla(${hue}, 70%, 40%, 0.035)` - } - - const clusterStroke = (mod: string): string => { - const hash = mod.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0) - const hues = [267, 190, 142, 35, 12, 335] - const hue = hues[hash % hues.length] - return `hsla(${hue}, 75%, 55%, 0.09)` - } + const drawBackgroundClusters = useCallback( + (ctx: CanvasRenderingContext2D, globalScale: number) => { + if (nodes.length === 0) return - ctx.save() - moduleGroups.forEach((groupNodes, moduleName) => { - if (groupNodes.length < 2) return - - let minX = Infinity, minY = Infinity - let maxX = -Infinity, maxY = -Infinity - groupNodes.forEach(n => { - if ( - !n || - n.x === undefined || - n.y === undefined || - n.x === null || - n.y === null || - isNaN(n.x) || - isNaN(n.y) || - !isFinite(n.x) || - !isFinite(n.y) - ) return - const size = getNodeSize(n) - minX = Math.min(minX, n.x - size) - minY = Math.min(minY, n.y - size) - maxX = Math.max(maxX, n.x + size) - maxY = Math.max(maxY, n.y + size) + const moduleGroups = new Map() + nodes.forEach((node) => { + const parts = node.file.split('/') + const moduleName = parts.length > 1 ? parts[0] : 'core' + if (!moduleGroups.has(moduleName)) { + moduleGroups.set(moduleName, []) + } + moduleGroups.get(moduleName)!.push(node) }) - if (minX === Infinity || !isFinite(minX) || isNaN(minX)) return - - const padding = 28 - const x = minX - padding - const y = minY - padding - const w = (maxX - minX) + padding * 2 - const h = (maxY - minY) + padding * 2 + const clusterAccent = (mod: string): string => { + const hash = mod.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0) + const hues = [267, 190, 142, 35, 12, 335] + const hue = hues[hash % hues.length] + return `hsla(${hue}, 70%, 40%, 0.035)` + } - ctx.beginPath() - if (ctx.roundRect) { - ctx.roundRect(x, y, w, h, 24) - } else { - ctx.rect(x, y, w, h) + const clusterStroke = (mod: string): string => { + const hash = mod.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0) + const hues = [267, 190, 142, 35, 12, 335] + const hue = hues[hash % hues.length] + return `hsla(${hue}, 75%, 55%, 0.09)` } - - ctx.fillStyle = clusterAccent(moduleName) - ctx.fill() - ctx.strokeStyle = clusterStroke(moduleName) - ctx.lineWidth = 1.0 / globalScale - ctx.stroke() - - const fontSize = Math.max(9, 12 / globalScale) - ctx.font = `bold ${fontSize}px var(--font-sans, system-ui)` - ctx.fillStyle = 'rgba(255, 255, 255, 0.25)' - ctx.fillText(moduleName.toUpperCase(), x + 16, y + fontSize + 10) - }) - ctx.restore() - }, [nodes, getNodeSize]) + + ctx.save() + moduleGroups.forEach((groupNodes, moduleName) => { + if (groupNodes.length < 2) return + + let minX = Infinity, + minY = Infinity + let maxX = -Infinity, + maxY = -Infinity + groupNodes.forEach((n) => { + if ( + !n || + n.x === undefined || + n.y === undefined || + n.x === null || + n.y === null || + isNaN(n.x) || + isNaN(n.y) || + !isFinite(n.x) || + !isFinite(n.y) + ) + return + const size = getNodeSize(n) + minX = Math.min(minX, n.x - size) + minY = Math.min(minY, n.y - size) + maxX = Math.max(maxX, n.x + size) + maxY = Math.max(maxY, n.y + size) + }) + + if (minX === Infinity || !isFinite(minX) || isNaN(minX)) return + + const padding = 28 + const x = minX - padding + const y = minY - padding + const w = maxX - minX + padding * 2 + const h = maxY - minY + padding * 2 + + ctx.beginPath() + if (ctx.roundRect) { + ctx.roundRect(x, y, w, h, 24) + } else { + ctx.rect(x, y, w, h) + } + + ctx.fillStyle = clusterAccent(moduleName) + ctx.fill() + ctx.strokeStyle = clusterStroke(moduleName) + ctx.lineWidth = 1.0 / globalScale + ctx.stroke() + + const fontSize = Math.max(9, 12 / globalScale) + ctx.font = `bold ${fontSize}px var(--font-sans, system-ui)` + ctx.fillStyle = 'rgba(255, 255, 255, 0.25)' + ctx.fillText(moduleName.toUpperCase(), x + 16, y + fontSize + 10) + }) + ctx.restore() + }, + [nodes, getNodeSize] + ) const toggleFullscreen = () => { if (!wrapperRef.current) return @@ -701,10 +749,12 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo const autocompleteSuggestions = useMemo(() => { if (!searchQuery) return [] const q = searchQuery.toLowerCase() - return nodes.filter(n => n.file.toLowerCase().includes(q) || n.module.toLowerCase().includes(q)).slice(0, 5) + return nodes + .filter((n) => n.file.toLowerCase().includes(q) || n.module.toLowerCase().includes(q)) + .slice(0, 5) }, [searchQuery, nodes]) - const activeCommitIndex = commits.findIndex(c => c.sha === selectedSha) + const activeCommitIndex = commits.findIndex((c) => c.sha === selectedSha) const driftRate = useMemo(() => { if (commits.length === 0 || activeCommitIndex === -1) return 0 const startScore = commits[0].health_score @@ -713,17 +763,21 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo }, [commits, activeCommitIndex]) return ( -
-

Software Knowledge Graph

+

+ Software Knowledge Graph +

@@ -736,7 +790,7 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo
- {searchQuery && ( - ))}
@@ -773,23 +829,23 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo
- - -
@@ -855,10 +917,18 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo className="w-full px-3 py-2 text-xs bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:border-purple-500/45 cursor-pointer" > - - - - + + + +
@@ -872,11 +942,21 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo }} className="w-full px-3 py-2 text-xs bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:border-purple-500/45 cursor-pointer" > - - - - - + + + + +
@@ -887,23 +967,31 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo Observability Layer
- - - - - -
@@ -1029,10 +1135,11 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo onRenderFramePre={drawBackgroundClusters} linkColor={(link) => { const key = getEdgeKey(link) - if (highlightCyclic && cyclicNodesAndEdges.edges.has(key)) return 'rgba(245, 158, 11, 0.75)' + if (highlightCyclic && cyclicNodesAndEdges.edges.has(key)) + return 'rgba(245, 158, 11, 0.75)' const isImport = (link as ForceGraphLink).type === 'import' const focusNodeId = selectedNodeId || hoveredNode - + let edgeOpacity = 0.28 if (focusNodeId) { const s = getNodeId(link.source) @@ -1040,8 +1147,10 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo const isEdgeFocused = s === focusNodeId || t === focusNodeId edgeOpacity = isEdgeFocused ? 0.85 : 0.03 } - - return isImport ? `rgba(96, 165, 250, ${edgeOpacity})` : `rgba(249, 115, 22, ${edgeOpacity})` + + return isImport + ? `rgba(96, 165, 250, ${edgeOpacity})` + : `rgba(249, 115, 22, ${edgeOpacity})` }} linkWidth={(link) => { const key = getEdgeKey(link) @@ -1114,11 +1223,17 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo
- {selectedNodeDetails.module} -

{selectedNodeDetails.file.split('/').pop()}

-

{selectedNodeDetails.file}

+ + {selectedNodeDetails.module} + +

+ {selectedNodeDetails.file.split('/').pop()} +

+

+ {selectedNodeDetails.file} +

-
)}
-
Lines of Code
-
{selectedNodeDetails.loc}
+
+ Lines of Code +
+
+ {selectedNodeDetails.loc} +
-
Complexity Score
-
{selectedNodeDetails.health.toFixed(1)}
+
+ Complexity Score +
+
+ {selectedNodeDetails.health.toFixed(1)} +
@@ -1159,7 +1283,7 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo
-
@@ -1179,7 +1303,7 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo Outbound Dependencies ({selectedNodeDetails.imports.length})
- {selectedNodeDetails.imports.map(fileId => ( + {selectedNodeDetails.imports.map((fileId) => (
- {selectedNodeDetails.importedBy.map(fileId => ( + {selectedNodeDetails.importedBy.map((fileId) => (
) : (
-
)}
@@ -1240,15 +1369,19 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo {commits.length > 0 && onSelectCommit && (
-
@@ -1296,7 +1439,7 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo }} className="w-full h-1 bg-white/10 rounded-full appearance-none cursor-pointer accent-purple-500 focus:outline-none" /> -
@@ -1311,17 +1454,26 @@ export function GraphExplorer({ graphData, selectedSha, commits = [], onSelectCo
DRIFT:{' '} - 0 ? 'text-emerald-400' : driftRate < 0 ? 'text-rose-400' : 'text-slate-400' - }`}> - {driftRate > 0 ? '+' : ''}{driftRate.toFixed(1)} + 0 + ? 'text-emerald-400' + : driftRate < 0 + ? 'text-rose-400' + : 'text-slate-400' + }`} + > + {driftRate > 0 ? '+' : ''} + {driftRate.toFixed(1)}
- {activeCommitIndex + 1} / {commits.length} + + {activeCommitIndex + 1} / {commits.length} +
diff --git a/frontend/src/components/HealthTimeline.tsx b/frontend/src/components/HealthTimeline.tsx index 4acf700..bc39f1a 100644 --- a/frontend/src/components/HealthTimeline.tsx +++ b/frontend/src/components/HealthTimeline.tsx @@ -11,7 +11,12 @@ import { XAxis, YAxis, } from 'recharts' -import type { AreaDotProps, ChartClickState, ChartTooltipProps, HealthTimelineProps } from '../types' +import type { + AreaDotProps, + ChartClickState, + ChartTooltipProps, + HealthTimelineProps, +} from '../types' import { formatSha, getHealthColor } from '../types' import { formatDateShort } from '../lib/utils' import { GitCommit, TrendingUp, Cpu, Flame } from 'lucide-react' @@ -33,7 +38,7 @@ function CommitTooltip({ active, payload }: ChartTooltipProps) { {formatDateShort(data.committed_at)}
- +
{data.message || 'No commit message'}
@@ -48,7 +53,7 @@ function CommitTooltip({ active, payload }: ChartTooltipProps) { {data.health_score.toFixed(1)}
- +
@@ -67,7 +72,7 @@ function CommitTooltip({ active, payload }: ChartTooltipProps) { {(data.churn_rate * 100).toFixed(1)}%
- +
@@ -88,7 +93,12 @@ function CommitTooltip({ active, payload }: ChartTooltipProps) { ) } -export function HealthTimeline({ commits, repoSlug, selectedSha, onSelectCommit }: HealthTimelineProps) { +export function HealthTimeline({ + commits, + repoSlug, + selectedSha, + onSelectCommit, +}: HealthTimelineProps) { const navigate = useNavigate() const [visibleLines, setVisibleLines] = useState({ complexity_drift: false, @@ -115,7 +125,7 @@ export function HealthTimeline({ commits, repoSlug, selectedSha, onSelectCommit } const toggleLine = (key: keyof typeof visibleLines) => { - setVisibleLines(prev => ({ ...prev, [key]: !prev[key] })) + setVisibleLines((prev) => ({ ...prev, [key]: !prev[key] })) } return ( @@ -127,11 +137,15 @@ export function HealthTimeline({ commits, repoSlug, selectedSha, onSelectCommit
-

Codebase Health Timeline

+

+ Codebase Health Timeline +

-

Interactive timeline tracker highlighting drift over recent commits

+

+ Interactive timeline tracker highlighting drift over recent commits +

- +
@@ -144,7 +158,8 @@ export function HealthTimeline({ commits, repoSlug, selectedSha, onSelectCommit label: 'Complexity', color: 'text-amber-300', dotBg: 'bg-amber-400', - activeBg: 'bg-amber-500/15 border-amber-500/30 shadow-[0_0_15px_rgba(245,158,11,0.15)]', + activeBg: + 'bg-amber-500/15 border-amber-500/30 shadow-[0_0_15px_rgba(245,158,11,0.15)]', }, { key: 'churn_risk', @@ -158,14 +173,16 @@ export function HealthTimeline({ commits, repoSlug, selectedSha, onSelectCommit label: 'Bus Factor', color: 'text-fuchsia-300', dotBg: 'bg-fuchsia-400', - activeBg: 'bg-fuchsia-500/15 border-fuchsia-500/30 shadow-[0_0_15px_rgba(217,70,239,0.15)]', + activeBg: + 'bg-fuchsia-500/15 border-fuchsia-500/30 shadow-[0_0_15px_rgba(217,70,239,0.15)]', }, { key: 'dependency_health', label: 'Dependencies', color: 'text-emerald-300', dotBg: 'bg-emerald-400', - activeBg: 'bg-emerald-500/15 border-emerald-500/30 shadow-[0_0_15px_rgba(16,185,129,0.15)]', + activeBg: + 'bg-emerald-500/15 border-emerald-500/30 shadow-[0_0_15px_rgba(16,185,129,0.15)]', }, { key: 'semantic_drift', @@ -173,7 +190,7 @@ export function HealthTimeline({ commits, repoSlug, selectedSha, onSelectCommit color: 'text-cyan-300', dotBg: 'bg-cyan-400', activeBg: 'bg-cyan-500/15 border-cyan-500/30 shadow-[0_0_15px_rgba(6,182,212,0.15)]', - } + }, ].map((item) => { const isActive = visibleLines[item.key as keyof typeof visibleLines] return ( @@ -181,12 +198,14 @@ export function HealthTimeline({ commits, repoSlug, selectedSha, onSelectCommit key={item.key} onClick={() => toggleLine(item.key as keyof typeof visibleLines)} className={`flex items-center gap-2 px-3 py-1.5 rounded-full transition-all duration-300 border text-xs font-semibold cursor-pointer ${ - isActive - ? `${item.activeBg} ${item.color}` + isActive + ? `${item.activeBg} ${item.color}` : 'bg-white/5 border-white/5 text-slate-400 hover:bg-white/10 hover:text-slate-300' }`} > - + {item.label} ) @@ -204,23 +223,23 @@ export function HealthTimeline({ commits, repoSlug, selectedSha, onSelectCommit - formatDateShort(value)} - tick={{ fill: 'var(--color-muted)', fontSize: 10, fontFamily: 'var(--font-body)' }} - axisLine={false} - tickLine={false} - minTickGap={40} + formatDateShort(value)} + tick={{ fill: 'var(--color-muted)', fontSize: 10, fontFamily: 'var(--font-body)' }} + axisLine={false} + tickLine={false} + minTickGap={40} /> - - } + } cursor={{ stroke: 'var(--glass-border)', strokeWidth: 1.5 }} wrapperStyle={{ zIndex: 999999, pointerEvents: 'none' }} useTranslate3d={true} @@ -241,90 +260,90 @@ export function HealthTimeline({ commits, repoSlug, selectedSha, onSelectCommit return ( {isSelected && ( - )} - ) }} /> - - - - - - - - formatDateShort(value)} - height={26} - stroke="var(--glass-border)" - fill="var(--glass-bg)" - travellerWidth={7} + formatDateShort(value)} + height={26} + stroke="var(--glass-border)" + fill="var(--glass-bg)" + travellerWidth={7} /> diff --git a/frontend/src/components/HotspotMap.tsx b/frontend/src/components/HotspotMap.tsx index ed78fcc..d88f00e 100644 --- a/frontend/src/components/HotspotMap.tsx +++ b/frontend/src/components/HotspotMap.tsx @@ -41,9 +41,26 @@ function HotspotCell(props: TreemapNode) { const label = name.length > labelLimit ? `${name.slice(0, labelLimit)}...` : name return ( - + {width > 44 && height > 22 && ( - + {label} )} @@ -52,7 +69,9 @@ function HotspotCell(props: TreemapNode) { } export function HotspotMap({ repoId, sha }: HotspotMapProps) { - const hotspotState = useSWR(['hotspots', repoId, sha], () => getHotspots(repoId, sha || undefined)) + const hotspotState = useSWR(['hotspots', repoId, sha], () => + getHotspots(repoId, sha || undefined) + ) const hotspots = hotspotState.data?.hotspots || [] const treemapData: TreemapNode[] = hotspots.map((hotspot) => ({ name: hotspot.file.split('/').pop() || hotspot.file, @@ -69,21 +88,38 @@ export function HotspotMap({ repoId, sha }: HotspotMapProps) {
-

Complexity Churn Hotspots

-

Area represents file complexity scaled by recent churn volume

+

+ Complexity Churn Hotspots +

+

+ Area represents file complexity scaled by recent churn volume +

- Critical - High - Medium + + {' '} + Critical + + + {' '} + High + + + {' '} + Medium +
{hotspotState.isLoading ? ( -
Loading hotspots...
+
+ Loading hotspots... +
) : hotspots.length === 0 ? ( -
No high-complexity churn hotspots found for this commit.
+
+ No high-complexity churn hotspots found for this commit. +
) : ( -

{item.fullPath}

+

+ {item.fullPath} +

-

Complexity: {item.complexity ?? 0}

-

Churn count: {item.churnCount ?? 0}

-

Risk score: {item.riskScore ?? 0}/100

+

+ Complexity:{' '} + {item.complexity ?? 0} +

+

+ Churn count:{' '} + {item.churnCount ?? 0} +

+

+ Risk score:{' '} + {item.riskScore ?? 0}/100 +

) diff --git a/frontend/src/components/NarrativeCard.tsx b/frontend/src/components/NarrativeCard.tsx index 2edf4fe..55d195f 100644 --- a/frontend/src/components/NarrativeCard.tsx +++ b/frontend/src/components/NarrativeCard.tsx @@ -71,13 +71,15 @@ export function NarrativeCard({ repoId, commitSha }: NarrativeCardProps) { return (
- +
- AI Narrative Analyst + + AI Narrative Analyst +
Claude to Gemini @@ -90,17 +92,20 @@ export function NarrativeCard({ repoId, commitSha }: NarrativeCardProps) {
-

Generate Intelligence Narrative

+

+ Generate Intelligence Narrative +

- Request an AI-generated natural language report explaining the architectural health fluctuations, code coupling anomalies, and refactor opportunities. + Request an AI-generated natural language report explaining the architectural + health fluctuations, code coupling anomalies, and refactor opportunities.

- +
-
@@ -143,12 +155,16 @@ export function NarrativeCard({ repoId, commitSha }: NarrativeCardProps) {
{meta.provider && PROVIDER_BADGE[meta.provider] ? ( - + {PROVIDER_BADGE[meta.provider].label} ) : ( - {meta.model || 'model unavailable'} + + {meta.model || 'model unavailable'} + )}
@@ -157,8 +173,16 @@ export function NarrativeCard({ repoId, commitSha }: NarrativeCardProps) {
Tokens: {(meta.tokens_total || 0).toLocaleString()} - {meta.cached && CACHED} - {meta.demo_mode && DEMO MODE} + {meta.cached && ( + + CACHED + + )} + {meta.demo_mode && ( + + DEMO MODE + + )}
)} diff --git a/frontend/src/components/ui/HealthBadge.tsx b/frontend/src/components/ui/HealthBadge.tsx index a7bb13e..b3dc277 100644 --- a/frontend/src/components/ui/HealthBadge.tsx +++ b/frontend/src/components/ui/HealthBadge.tsx @@ -20,26 +20,33 @@ export function HealthBadge({ score, delta, size = 'md' }: HealthBadgeProps) { } return ( - - - {score.toFixed(0)} + + {score.toFixed(0)} + {delta !== undefined && delta !== null && ( - = 0 ? 'text-emerald-400' : 'text-rose-400')}> + = 0 ? 'text-emerald-400' : 'text-rose-400' + )} + > {delta >= 0 ? `+${delta.toFixed(1)}` : delta.toFixed(1)} )} diff --git a/frontend/src/components/ui/ThemeToggle.tsx b/frontend/src/components/ui/ThemeToggle.tsx index 9383e40..524b9ec 100644 --- a/frontend/src/components/ui/ThemeToggle.tsx +++ b/frontend/src/components/ui/ThemeToggle.tsx @@ -31,7 +31,7 @@ export function ThemeToggle() { }, [theme]) const toggleTheme = () => { - setTheme(prev => (prev === 'light' ? 'dark' : 'light')) + setTheme((prev) => (prev === 'light' ? 'dark' : 'light')) } return ( @@ -50,21 +50,45 @@ export function ThemeToggle() { ) : ( - + - + )}
- - + + - + - + diff --git a/frontend/src/index.css b/frontend/src/index.css index 694502e..57aa8d9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -8,30 +8,30 @@ @layer base { :root { /* Brand Spatial Global Tokens */ - --color-brand-50: #F3F4F6; - --color-brand-100: #E5E7EB; - --color-brand-200: #D1D5DB; - --color-brand-300: #9CA3AF; - --color-brand-400: #A78BFA; - --color-brand-500: #8B5CF6; - --color-brand-600: #7C3AED; - --color-brand-700: #6D28D9; - --color-brand-800: #5B21B6; - --color-brand-900: #4C1D95; + --color-brand-50: #f3f4f6; + --color-brand-100: #e5e7eb; + --color-brand-200: #d1d5db; + --color-brand-300: #9ca3af; + --color-brand-400: #a78bfa; + --color-brand-500: #8b5cf6; + --color-brand-600: #7c3aed; + --color-brand-700: #6d28d9; + --color-brand-800: #5b21b6; + --color-brand-900: #4c1d95; /* Status Tokens */ - --color-healthy: #34D399; /* Emerald */ - --color-healthy-500: #10B981; - --color-warning: #FBBF24; /* Amber */ - --color-warning-500: #F59E0B; - --color-critical: #F87171; /* Coral Red */ - --color-critical-500: #EF4444; - --color-info: #60A5FA; /* Soft Icy Blue */ + --color-healthy: #34d399; /* Emerald */ + --color-healthy-500: #10b981; + --color-warning: #fbbf24; /* Amber */ + --color-warning-500: #f59e0b; + --color-critical: #f87171; /* Coral Red */ + --color-critical-500: #ef4444; + --color-info: #60a5fa; /* Soft Icy Blue */ /* Typography Hierarchy */ --font-display: 'Outfit', -apple-system, system-ui, sans-serif; - --font-body: 'Plus Jakarta Sans', -apple-system, system-ui, sans-serif; - --font-mono: 'JetBrains Mono', monospace; + --font-body: 'Plus Jakarta Sans', -apple-system, system-ui, sans-serif; + --font-mono: 'JetBrains Mono', monospace; /* Radii consistent with Apple Spatial UI */ --radius-sm: 8px; @@ -42,76 +42,86 @@ --radius-pill: 9999px; /* Base Layout Colors (Frosted Silver/White Light Theme by Default) */ - --color-base: #F4F5F8; - --color-surface: rgba(255, 255, 255, 0.45); - --color-elevated: rgba(255, 255, 255, 0.7); - --color-border: rgba(0, 0, 0, 0.06); + --color-base: #f4f5f8; + --color-surface: rgba(255, 255, 255, 0.45); + --color-elevated: rgba(255, 255, 255, 0.7); + --color-border: rgba(0, 0, 0, 0.06); --color-border-subtle: rgba(0, 0, 0, 0.03); - --color-primary: #111827; /* Near Black */ - --color-secondary: #374151; /* Dark Gray */ - --color-muted: #6B7280; /* System Gray */ - --color-mono: #4B5563; - --color-disabled: rgba(0, 0, 0, 0.02); + --color-primary: #111827; /* Near Black */ + --color-secondary: #374151; /* Dark Gray */ + --color-muted: #6b7280; /* System Gray */ + --color-mono: #4b5563; + --color-disabled: rgba(0, 0, 0, 0.02); - --color-brand: #7C3AED; /* Vibrant Violet Accent */ + --color-brand: #7c3aed; /* Vibrant Violet Accent */ /* Glassmorphism Variables (Light Mode) */ - --glass-bg: rgba(255, 255, 255, 0.45); - --glass-bg-hover: rgba(255, 255, 255, 0.65); - --glass-border: rgba(0, 0, 0, 0.06); - --glass-border-hover: rgba(0, 0, 0, 0.12); - --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.8); - --glass-shadow-hover: 0 12px 40px rgba(0, 0, 0, 0.08), inset 0 1px 1.5px rgba(255, 255, 255, 0.95), 0 0 20px rgba(124, 58, 237, 0.06); - - --glass-bg-bright: rgba(255, 255, 255, 0.75); + --glass-bg: rgba(255, 255, 255, 0.45); + --glass-bg-hover: rgba(255, 255, 255, 0.65); + --glass-border: rgba(0, 0, 0, 0.06); + --glass-border-hover: rgba(0, 0, 0, 0.12); + --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.8); + --glass-shadow-hover: + 0 12px 40px rgba(0, 0, 0, 0.08), inset 0 1px 1.5px rgba(255, 255, 255, 0.95), + 0 0 20px rgba(124, 58, 237, 0.06); + + --glass-bg-bright: rgba(255, 255, 255, 0.75); --glass-border-bright: rgba(0, 0, 0, 0.09); - --glass-shadow-bright: 0 8px 32px rgba(0, 0, 0, 0.04), inset 0 1px 1.5px rgba(255, 255, 255, 0.95); + --glass-shadow-bright: + 0 8px 32px rgba(0, 0, 0, 0.04), inset 0 1px 1.5px rgba(255, 255, 255, 0.95); - --glass-bg-dark: rgba(255, 255, 255, 0.85); - --glass-border-dark: rgba(0, 0, 0, 0.05); - --glass-shadow-dark: 0 12px 48px rgba(0, 0, 0, 0.06), inset 0 1px 0.5px rgba(255, 255, 255, 0.95); + --glass-bg-dark: rgba(255, 255, 255, 0.85); + --glass-border-dark: rgba(0, 0, 0, 0.05); + --glass-shadow-dark: + 0 12px 48px rgba(0, 0, 0, 0.06), inset 0 1px 0.5px rgba(255, 255, 255, 0.95); - --liquid-btn-bg: rgba(0, 0, 0, 0.03); - --liquid-btn-bg-hover: rgba(0, 0, 0, 0.06); - --liquid-btn-border: rgba(0, 0, 0, 0.06); + --liquid-btn-bg: rgba(0, 0, 0, 0.03); + --liquid-btn-bg-hover: rgba(0, 0, 0, 0.06); + --liquid-btn-border: rgba(0, 0, 0, 0.06); --liquid-btn-border-hover: rgba(0, 0, 0, 0.12); } .dark { - --color-base: #0A0B10; - --color-surface: rgba(255, 255, 255, 0.035); - --color-elevated: rgba(255, 255, 255, 0.07); - --color-border: rgba(255, 255, 255, 0.08); + --color-base: #0a0b10; + --color-surface: rgba(255, 255, 255, 0.035); + --color-elevated: rgba(255, 255, 255, 0.07); + --color-border: rgba(255, 255, 255, 0.08); --color-border-subtle: rgba(255, 255, 255, 0.05); - --color-primary: #F9FAFB; - --color-secondary: #E5E7EB; - --color-muted: #9CA3AF; - --color-mono: #D1D5DB; - --color-disabled: rgba(255, 255, 255, 0.02); + --color-primary: #f9fafb; + --color-secondary: #e5e7eb; + --color-muted: #9ca3af; + --color-mono: #d1d5db; + --color-disabled: rgba(255, 255, 255, 0.02); - --color-brand: #A78BFA; + --color-brand: #a78bfa; /* Glassmorphism Variables (Dark Mode) */ - --glass-bg: rgba(255, 255, 255, 0.035); - --glass-bg-hover: rgba(255, 255, 255, 0.06); - --glass-border: rgba(255, 255, 255, 0.08); - --glass-border-hover: rgba(255, 255, 255, 0.13); - --glass-shadow: 0 4px 30px rgba(0, 0, 0, 0.35), inset 0 1px 1px rgba(255, 255, 255, 0.06), inset 0 -1px 1px rgba(0, 0, 0, 0.2); - --glass-shadow-hover: 0 12px 40px rgba(0, 0, 0, 0.45), inset 0 1px 1.5px rgba(255, 255, 255, 0.12), 0 0 20px rgba(167, 139, 250, 0.05); - - --glass-bg-bright: rgba(255, 255, 255, 0.08); + --glass-bg: rgba(255, 255, 255, 0.035); + --glass-bg-hover: rgba(255, 255, 255, 0.06); + --glass-border: rgba(255, 255, 255, 0.08); + --glass-border-hover: rgba(255, 255, 255, 0.13); + --glass-shadow: + 0 4px 30px rgba(0, 0, 0, 0.35), inset 0 1px 1px rgba(255, 255, 255, 0.06), + inset 0 -1px 1px rgba(0, 0, 0, 0.2); + --glass-shadow-hover: + 0 12px 40px rgba(0, 0, 0, 0.45), inset 0 1px 1.5px rgba(255, 255, 255, 0.12), + 0 0 20px rgba(167, 139, 250, 0.05); + + --glass-bg-bright: rgba(255, 255, 255, 0.08); --glass-border-bright: rgba(255, 255, 255, 0.14); - --glass-shadow-bright: 0 8px 32px rgba(0, 0, 0, 0.4), inset 0 1px 1.5px rgba(255, 255, 255, 0.12); + --glass-shadow-bright: + 0 8px 32px rgba(0, 0, 0, 0.4), inset 0 1px 1.5px rgba(255, 255, 255, 0.12); - --glass-bg-dark: rgba(10, 11, 16, 0.6); - --glass-border-dark: rgba(255, 255, 255, 0.05); - --glass-shadow-dark: 0 12px 48px rgba(0, 0, 0, 0.5), inset 0 1px 0.5px rgba(255, 255, 255, 0.03); + --glass-bg-dark: rgba(10, 11, 16, 0.6); + --glass-border-dark: rgba(255, 255, 255, 0.05); + --glass-shadow-dark: + 0 12px 48px rgba(0, 0, 0, 0.5), inset 0 1px 0.5px rgba(255, 255, 255, 0.03); - --liquid-btn-bg: rgba(255, 255, 255, 0.06); - --liquid-btn-bg-hover: rgba(255, 255, 255, 0.12); - --liquid-btn-border: rgba(255, 255, 255, 0.08); + --liquid-btn-bg: rgba(255, 255, 255, 0.06); + --liquid-btn-bg-hover: rgba(255, 255, 255, 0.12); + --liquid-btn-border: rgba(255, 255, 255, 0.08); --liquid-btn-border-hover: rgba(255, 255, 255, 0.18); } @@ -121,7 +131,9 @@ scroll-behavior: smooth; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - transition: background-color 0.4s cubic-bezier(0.16, 1, 0.3, 1), color 0.4s cubic-bezier(0.16, 1, 0.3, 1); + transition: + background-color 0.4s cubic-bezier(0.16, 1, 0.3, 1), + color 0.4s cubic-bezier(0.16, 1, 0.3, 1); } body { @@ -129,7 +141,9 @@ color: var(--color-primary); font-family: var(--font-body); overflow-x: hidden; - transition: background-color 0.4s cubic-bezier(0.16, 1, 0.3, 1), color 0.4s cubic-bezier(0.16, 1, 0.3, 1); + transition: + background-color 0.4s cubic-bezier(0.16, 1, 0.3, 1), + color 0.4s cubic-bezier(0.16, 1, 0.3, 1); } * { @@ -142,16 +156,16 @@ width: 6px; height: 6px; } - ::-webkit-scrollbar-track { - background: transparent; + ::-webkit-scrollbar-track { + background: transparent; } - ::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.08); - border-radius: 99px; + ::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.08); + border-radius: 99px; transition: background-color 0.3s ease; } - ::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.18); + ::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.18); } /* Override default tailwind colors to support light mode dynamically */ @@ -195,7 +209,7 @@ html:not(.dark) .bg-white\/\[0\.06\] { background-color: rgba(0, 0, 0, 0.06) !important; } - + html:not(.dark) .border-white\/5 { border-color: rgba(0, 0, 0, 0.05) !important; } @@ -205,13 +219,13 @@ html:not(.dark) .border-white\/15 { border-color: rgba(0, 0, 0, 0.12) !important; } - - html:not(.dark) aside, + + html:not(.dark) aside, html:not(.dark) .bg-\[\#0a0b10\]\/40 { background-color: rgba(255, 255, 255, 0.5) !important; border-color: rgba(0, 0, 0, 0.06) !important; } - + html:not(.dark) .bg-\[\#07080d\] { background-color: var(--color-base) !important; } @@ -219,7 +233,7 @@ html:not(.dark) .bg-\[\#05060b\] { background-color: var(--color-surface) !important; } - + html:not(.dark) .text-white\/10 { color: rgba(0, 0, 0, 0.09) !important; } @@ -242,7 +256,10 @@ -webkit-backdrop-filter: blur(28px) saturate(120%); border: 1px solid var(--glass-border); box-shadow: var(--glass-shadow); - transition: background-color 0.4s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.4s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.4s cubic-bezier(0.16, 1, 0.3, 1); + transition: + background-color 0.4s cubic-bezier(0.16, 1, 0.3, 1), + border-color 0.4s cubic-bezier(0.16, 1, 0.3, 1), + box-shadow 0.4s cubic-bezier(0.16, 1, 0.3, 1); } .glass-panel:hover { @@ -265,16 +282,16 @@ backdrop-filter: blur(20px) saturate(140%); -webkit-backdrop-filter: blur(20px) saturate(140%); border: 1px solid rgba(255, 255, 255, 0.15) !important; - box-shadow: - 0 12px 40px rgba(0, 0, 0, 0.75), + box-shadow: + 0 12px 40px rgba(0, 0, 0, 0.75), inset 0 1px 1px rgba(255, 255, 255, 0.1), 0 0 25px rgba(167, 139, 250, 0.15); } html:not(.dark) .glass-tooltip { background: rgba(255, 255, 255, 0.95) !important; border-color: rgba(0, 0, 0, 0.1) !important; - box-shadow: - 0 12px 40px rgba(0, 0, 0, 0.08), + box-shadow: + 0 12px 40px rgba(0, 0, 0, 0.08), inset 0 1px 1px rgba(255, 255, 255, 0.9), 0 0 25px rgba(124, 58, 237, 0.06); } @@ -293,7 +310,7 @@ backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid var(--liquid-btn-border); - box-shadow: + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2), inset 0 1px 0.5px rgba(255, 255, 255, 0.1); color: var(--color-primary); @@ -304,7 +321,7 @@ background: var(--liquid-btn-bg-hover); border-color: var(--liquid-btn-border-hover); transform: translateY(-1px); - box-shadow: + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), inset 0 1px 1px rgba(255, 255, 255, 0.15); } @@ -346,18 +363,32 @@ } @keyframes floatSlow { - 0% { transform: translateY(0) scale(1); } - 100% { transform: translateY(-18px) scale(1.03); } + 0% { + transform: translateY(0) scale(1); + } + 100% { + transform: translateY(-18px) scale(1.03); + } } @keyframes floatMedium { - 0% { transform: translate(0, 0) scale(1.02); } - 100% { transform: translate(25px, -20px) scale(0.97); } + 0% { + transform: translate(0, 0) scale(1.02); + } + 100% { + transform: translate(25px, -20px) scale(0.97); + } } @keyframes glowPulse { - 0% { opacity: 0.35; filter: brightness(0.9); } - 100% { opacity: 0.75; filter: brightness(1.2); } + 0% { + opacity: 0.35; + filter: brightness(0.9); + } + 100% { + opacity: 0.75; + filter: brightness(1.2); + } } } @@ -370,9 +401,8 @@ } /* Ensure the force graph tooltips sit below floating sidebar components and don't capture pointer events */ -.graph-tooltip, .scene-tooltip { +.graph-tooltip, +.scene-tooltip { z-index: 30 !important; pointer-events: none !important; } - - diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index f043369..81c168c 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -22,12 +22,14 @@ describe('streamNarrative', () => { it('parses server-sent narrative chunks split across network reads', async () => { const chunks: NarrativeStreamChunk[] = [] - const fetchMock = vi.fn(async () => streamResponse([ - 'data: {"token":"The ","done":false}\n\n', - 'data: {"token":"risk","done":false}\n', - '\n', - 'data: {"done":true,"explanation":"The risk","tokens_total":4,"cost_usd":0.001,"cached":false,"model":"demo"}\n\n', - ])) + const fetchMock = vi.fn(async () => + streamResponse([ + 'data: {"token":"The ","done":false}\n\n', + 'data: {"token":"risk","done":false}\n', + '\n', + 'data: {"done":true,"explanation":"The risk","tokens_total":4,"cost_usd":0.001,"cached":false,"model":"demo"}\n\n', + ]) + ) vi.stubGlobal('fetch', fetchMock) await streamNarrative(7, 'abcdef123456', (chunk) => chunks.push(chunk)) @@ -50,7 +52,9 @@ describe('streamNarrative', () => { it('surfaces API error details for failed streams', async () => { vi.stubGlobal( 'fetch', - vi.fn(async () => new Response(JSON.stringify({ detail: 'LLM budget exceeded' }), { status: 429 })), + vi.fn( + async () => new Response(JSON.stringify({ detail: 'LLM budget exceeded' }), { status: 429 }) + ) ) await expect(streamNarrative(7, 'abcdef123456', vi.fn())).rejects.toThrow('LLM budget exceeded') diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index cf6ebb7..33aeeaf 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -68,7 +68,7 @@ export async function getHealthTimeline(repoId: string | number): Promise { return request(client.get(`/repos/${repoId}/commit/${sha}`)) } @@ -86,7 +86,7 @@ export async function getBusFactor(repoId: string | number): Promise { return request( client.get(`/repos/${repoId}/graph/diff`, { @@ -135,7 +135,7 @@ export async function cancelIngest(repoId: string | number): Promise void, + onChunk: (chunk: NarrativeStreamChunk) => void ): Promise { const response = await fetch(`${API_ROOT}/explain/stream`, { method: 'POST', diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 600f535..d05b760 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -6,12 +6,15 @@ export function cn(...inputs: ClassValue[]) { export function formatDate(iso: string): string { return new Date(iso).toLocaleDateString('en-US', { - month: 'short', day: 'numeric', year: 'numeric' + month: 'short', + day: 'numeric', + year: 'numeric', }) } export function formatDateShort(iso: string): string { return new Date(iso).toLocaleDateString('en-US', { - month: 'short', day: 'numeric' + month: 'short', + day: 'numeric', }) } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..2caec89 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,5 +6,5 @@ import App from './App.tsx' createRoot(document.getElementById('root')!).render( - , + ) diff --git a/frontend/src/pages/AnalyzePage.test.tsx b/frontend/src/pages/AnalyzePage.test.tsx index 75f08cc..e645d76 100644 --- a/frontend/src/pages/AnalyzePage.test.tsx +++ b/frontend/src/pages/AnalyzePage.test.tsx @@ -34,7 +34,7 @@ function renderAnalyzePage(source = new MockEventSource()) { Landing
} /> Dashboard
} /> - , + ) return source } diff --git a/frontend/src/pages/AnalyzePage.tsx b/frontend/src/pages/AnalyzePage.tsx index c47d1da..6b3aa06 100644 --- a/frontend/src/pages/AnalyzePage.tsx +++ b/frontend/src/pages/AnalyzePage.tsx @@ -44,11 +44,17 @@ export default function AnalyzePage() { setProgress(data) if (data.status === 'ready') { source.close() - getRepo(repoId).then((repo) => { - navigate(`/dashboard/${repo.repo_slug}`, { replace: true }) - }).catch((err) => { - setError(err instanceof Error ? err.message : 'Analysis completed, but repository metadata could not load.') - }) + getRepo(repoId) + .then((repo) => { + navigate(`/dashboard/${repo.repo_slug}`, { replace: true }) + }) + .catch((err) => { + setError( + err instanceof Error + ? err.message + : 'Analysis completed, but repository metadata could not load.' + ) + }) } if (data.status === 'error') { source.close() @@ -67,7 +73,9 @@ export default function AnalyzePage() { }, [repoId, navigate]) const currentStageIdx = progress.status === 'queued' ? 0 : stageIndex(progress.status) - const canCancel = Boolean(repoId && !error && !['ready', 'error', 'cancelled'].includes(progress.status)) + const canCancel = Boolean( + repoId && !error && !['ready', 'error', 'cancelled'].includes(progress.status) + ) async function handleCancel() { if (!repoId || isCancelling) return @@ -92,12 +100,24 @@ export default function AnalyzePage() {
- - + +
-

Analyzing Repository

+

+ Analyzing Repository +

{repoName}

@@ -108,8 +128,8 @@ export default function AnalyzePage() { const isDone = currentStageIdx > index || progress.status === 'ready' const isActive = currentStageIdx === index && progress.status !== 'ready' && !error return ( -
- + {isDone ? stage.done : stage.label} {isActive && progress.current > 0 && progress.total > 0 && ( @@ -142,13 +164,13 @@ export default function AnalyzePage() { {(isActive || isDone) && (
-
)} @@ -161,7 +183,9 @@ export default function AnalyzePage() { {progress.current_sha && (
ACTIVE SNAPSHOT - {progress.current_sha} + + {progress.current_sha} +
)} @@ -181,8 +205,8 @@ export default function AnalyzePage() { {error && (

{error}

- - - - + / - +
- {repo.name} + + {repo.name} +
@@ -116,8 +134,8 @@ export default function DashboardPage() {
-
- +
- { setSelected(commit) setIsSidebarOpen(false) - }} + }} />
@@ -186,27 +217,29 @@ export default function DashboardPage() { No analyzed commits are currently compiled for this repository workspace.
) : ( - )} {selected && (
- +
{selected.sha.slice(0, 12)} -

{selected.message || 'No commit message'}

+

+ {selected.message || 'No commit message'} +

-
{[ - { - label: 'Codebase Complexity', - value: selected.avg_complexity === 0 ? '-' : selected.avg_complexity.toFixed(1), - unit: selected.avg_complexity === 0 ? 'no code changed' : 'Avg cyclomatic score', - icon: + { + label: 'Codebase Complexity', + value: selected.avg_complexity === 0 ? '-' : selected.avg_complexity.toFixed(1), + unit: + selected.avg_complexity === 0 ? 'no code changed' : 'Avg cyclomatic score', + icon: , }, - { - label: 'Commit Churn', - value: `${selectedChurnPct.toFixed(0)}%`, + { + label: 'Commit Churn', + value: `${selectedChurnPct.toFixed(0)}%`, unit: `${selected.num_files_changed} modified components`, - icon: + icon: , }, - { - label: 'Minimum Bus Factor', - value: String(selected.bus_factor_min), + { + label: 'Minimum Bus Factor', + value: String(selected.bus_factor_min), unit: 'Crucial owners limit', - icon: + icon: , }, { label: 'Semantic Drift', value: `${(selected.subscores?.semantic_drift ?? selected.semantic_health_score ?? 100).toFixed(0)}`, unit: `${selected.avg_semantic_drift?.toFixed(2) ?? '0.00'} avg drift`, - badge: selected.semantic_drift_method === 'graphcodebert' ? 'GraphCodeBERT' : undefined, - icon: + badge: + selected.semantic_drift_method === 'graphcodebert' + ? 'GraphCodeBERT' + : undefined, + icon: , }, ].map((metric) => ( -
@@ -261,9 +298,7 @@ export default function DashboardPage() {
{metric.value}
-
- {metric.unit} -
+
{metric.unit}
))}
@@ -276,18 +311,25 @@ export default function DashboardPage() {
{selectedRiskReasons.map((reason) => ( -
+
{reason.label}
-
{reason.detail}
+
+ {reason.detail} +
- + {reason.severity}
@@ -308,10 +350,16 @@ export default function DashboardPage() {
{selectedPersistentHotspots.map((hotspot) => ( -
- {hotspot.path} +
+ + {hotspot.path} + - {hotspot.recent_commit_count} commits / cx {hotspot.complexity.toFixed(1)} + {hotspot.recent_commit_count} commits / cx{' '} + {hotspot.complexity.toFixed(1)}
))} @@ -332,9 +380,9 @@ export default function DashboardPage() { Could not construct software import dependency landscape.
) : ( - @@ -349,7 +397,7 @@ export default function DashboardPage() { ) : ( )} - + {repoId && }
diff --git a/frontend/src/pages/DemoPage.test.tsx b/frontend/src/pages/DemoPage.test.tsx index 0eead6d..ee03c8e 100644 --- a/frontend/src/pages/DemoPage.test.tsx +++ b/frontend/src/pages/DemoPage.test.tsx @@ -15,7 +15,7 @@ function renderDemoPage() { return render( - , + ) } diff --git a/frontend/src/pages/DemoPage.tsx b/frontend/src/pages/DemoPage.tsx index 0b84d6f..97fda02 100644 --- a/frontend/src/pages/DemoPage.tsx +++ b/frontend/src/pages/DemoPage.tsx @@ -14,7 +14,9 @@ export default function DemoPage() { try { const demoUrl = 'https://github.com/facebook/react' const response = await ingestRepo(demoUrl, 100) - navigate(`/analyze?repo_id=${response.repo_id}&name=${encodeURIComponent(demoUrl)}`, { replace: true }) + navigate(`/analyze?repo_id=${response.repo_id}&name=${encodeURIComponent(demoUrl)}`, { + replace: true, + }) } catch (err) { setError(err instanceof Error ? err.message : 'Could not start the demo analysis.') setLoading(false) @@ -36,12 +38,17 @@ export default function DemoPage() { )}
-

- {error || (loading ? 'Starting React demo analysis...' : 'Analyze facebook/react with a smaller demo-sized commit window.')} +

+ {error || + (loading + ? 'Starting React demo analysis...' + : 'Analyze facebook/react with a smaller demo-sized commit window.')}

-
- - GitHub @@ -143,17 +145,28 @@ export default function LandingPage() {

- Every commit has a story. + Every commit has a{' '} + + story. +

- Decipher architecture, complexity shifts, and knowledge dynamics directly from your codebase history. + Decipher architecture, complexity shifts, and knowledge dynamics directly from your + codebase history.

-
+
- +
{loading ? ( <> - - - + + + Parsing... @@ -218,42 +246,55 @@ export default function LandingPage() { )}
-
-
+
{[ { title: 'Health Timeline', desc: 'Replay commit activity visually and scrub through complexity, churn, and code risk dynamics over time.', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z', - glow: 'group-hover:border-indigo-500/30 group-hover:shadow-[0_0_30px_rgba(99,102,241,0.06)]' + glow: 'group-hover:border-indigo-500/30 group-hover:shadow-[0_0_30px_rgba(99,102,241,0.06)]', }, { title: 'Knowledge Graph', desc: 'Fly through 3D dependency streams to identify hidden import coupling and import risk structural flaws.', icon: 'M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7', - glow: 'group-hover:border-purple-500/30 group-hover:shadow-[0_0_30px_rgba(167,139,250,0.06)]' + glow: 'group-hover:border-purple-500/30 group-hover:shadow-[0_0_30px_rgba(167,139,250,0.06)]', }, { title: 'Bus Factor Index', desc: 'Audit critical files single-person dependencies to mitigate key-person risks before refactoring.', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z', - glow: 'group-hover:border-cyan-500/30 group-hover:shadow-[0_0_30px_rgba(6,182,212,0.06)]' - } + glow: 'group-hover:border-cyan-500/30 group-hover:shadow-[0_0_30px_rgba(6,182,212,0.06)]', + }, ].map((cap) => ( -
- - + +

{cap.title}

diff --git a/frontend/src/pages/NotFoundPage.tsx b/frontend/src/pages/NotFoundPage.tsx index 1674b0c..4431ee2 100644 --- a/frontend/src/pages/NotFoundPage.tsx +++ b/frontend/src/pages/NotFoundPage.tsx @@ -12,14 +12,19 @@ export default function NotFoundPage() {
-

404

-

Workspace Lost in Space

+

+ 404 +

+

+ Workspace Lost in Space +

- The requested coordinate snapshot index was not compiled or does not exist in the active computing grid. + The requested coordinate snapshot index was not compiled or does not exist in the active + computing grid.

-