codeabc 0.1.2__tar.gz → 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. {codeabc-0.1.2 → codeabc-0.1.3}/PKG-INFO +7 -3
  2. {codeabc-0.1.2 → codeabc-0.1.3}/README.md +4 -0
  3. {codeabc-0.1.2 → codeabc-0.1.3}/README_CN.md +4 -0
  4. {codeabc-0.1.2 → codeabc-0.1.3}/backend/models.py +21 -1
  5. {codeabc-0.1.2 → codeabc-0.1.3}/backend/routers/project.py +29 -7
  6. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/action_plan.py +10 -3
  7. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/envscan.py +44 -0
  8. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/pr_reading.py +129 -0
  9. codeabc-0.1.3/backend/services/unused_exports.py +185 -0
  10. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/App.tsx +2 -0
  11. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/lib/api.ts +60 -5
  12. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/pages/Home.tsx +9 -3
  13. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/pages/Overview.tsx +105 -0
  14. codeabc-0.1.3/frontend/src/pages/PrView.tsx +127 -0
  15. {codeabc-0.1.2 → codeabc-0.1.3}/pyproject.toml +3 -2
  16. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_action_plan.py +26 -0
  17. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_envscan.py +36 -0
  18. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_github_clone.py +26 -0
  19. codeabc-0.1.3/tests/test_pr_reading.py +191 -0
  20. codeabc-0.1.3/tests/test_unused_exports.py +93 -0
  21. codeabc-0.1.2/tests/test_pr_reading.py +0 -102
  22. {codeabc-0.1.2 → codeabc-0.1.3}/.env.example +0 -0
  23. {codeabc-0.1.2 → codeabc-0.1.3}/.github/workflows/ci.yml +0 -0
  24. {codeabc-0.1.2 → codeabc-0.1.3}/.gitignore +0 -0
  25. {codeabc-0.1.2 → codeabc-0.1.3}/LICENSE +0 -0
  26. {codeabc-0.1.2 → codeabc-0.1.3}/backend/__init__.py +0 -0
  27. {codeabc-0.1.2 → codeabc-0.1.3}/backend/app.py +0 -0
  28. {codeabc-0.1.2 → codeabc-0.1.3}/backend/desktop_server.py +0 -0
  29. {codeabc-0.1.2 → codeabc-0.1.3}/backend/prompts/__init__.py +0 -0
  30. {codeabc-0.1.2 → codeabc-0.1.3}/backend/prompts/annotate.py +0 -0
  31. {codeabc-0.1.2 → codeabc-0.1.3}/backend/prompts/edit.py +0 -0
  32. {codeabc-0.1.2 → codeabc-0.1.3}/backend/prompts/overview.py +0 -0
  33. {codeabc-0.1.2 → codeabc-0.1.3}/backend/prompts/qa.py +0 -0
  34. {codeabc-0.1.2 → codeabc-0.1.3}/backend/routers/__init__.py +0 -0
  35. {codeabc-0.1.2 → codeabc-0.1.3}/backend/routers/analyze.py +0 -0
  36. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/__init__.py +0 -0
  37. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/activity.py +0 -0
  38. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/apimap.py +0 -0
  39. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/async_surface.py +0 -0
  40. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/cache.py +0 -0
  41. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/churn.py +0 -0
  42. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/ci_checks.py +0 -0
  43. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/codemap_export.py +0 -0
  44. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/commands.py +0 -0
  45. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/complexity.py +0 -0
  46. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/config_files.py +0 -0
  47. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/contributing.py +0 -0
  48. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/coverage.py +0 -0
  49. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/datamodels.py +0 -0
  50. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/deep_nesting.py +0 -0
  51. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/dependencies.py +0 -0
  52. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/docs.py +0 -0
  53. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/docstrings.py +0 -0
  54. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/duplicate_code.py +0 -0
  55. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/entrypoints.py +0 -0
  56. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/error_handling.py +0 -0
  57. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/filenames.py +0 -0
  58. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/github_clone.py +0 -0
  59. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/glossary.py +0 -0
  60. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/health_score.py +0 -0
  61. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/importgraph.py +0 -0
  62. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/integrations.py +0 -0
  63. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/licenses.py +0 -0
  64. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/llm.py +0 -0
  65. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/long_functions.py +0 -0
  66. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/ownership.py +0 -0
  67. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/package_purposes.py +0 -0
  68. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/release_map.py +0 -0
  69. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/report_export.py +0 -0
  70. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/risk.py +0 -0
  71. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/scanner.py +0 -0
  72. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/schedules.py +0 -0
  73. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/security.py +0 -0
  74. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/settings_map.py +0 -0
  75. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/symbols.py +0 -0
  76. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/techdebt.py +0 -0
  77. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/too_many_params.py +0 -0
  78. {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/typing_coverage.py +0 -0
  79. {codeabc-0.1.2 → codeabc-0.1.3}/docs/architecture.png +0 -0
  80. {codeabc-0.1.2 → codeabc-0.1.3}/docs/banner.png +0 -0
  81. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/.gitignore +0 -0
  82. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/README.md +0 -0
  83. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/eslint.config.js +0 -0
  84. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/index.html +0 -0
  85. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/package-lock.json +0 -0
  86. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/package.json +0 -0
  87. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/components/AnnotationTooltip.tsx +0 -0
  88. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/components/ApiKeyModal.tsx +0 -0
  89. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/components/CodeViewer.tsx +0 -0
  90. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/components/PrReadingCard.tsx +0 -0
  91. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/components/UploadZone.tsx +0 -0
  92. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/index.css +0 -0
  93. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/lib/errors.ts +0 -0
  94. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/lib/i18n.tsx +0 -0
  95. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/main.tsx +0 -0
  96. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/pages/FileView.tsx +0 -0
  97. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/stores/project.ts +0 -0
  98. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/vite-env.d.ts +0 -0
  99. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/.gitignore +0 -0
  100. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/Cargo.lock +0 -0
  101. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/Cargo.toml +0 -0
  102. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/build.rs +0 -0
  103. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/capabilities/default.json +0 -0
  104. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/128x128.png +0 -0
  105. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/128x128@2x.png +0 -0
  106. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/32x32.png +0 -0
  107. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/Square107x107Logo.png +0 -0
  108. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/Square142x142Logo.png +0 -0
  109. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/Square150x150Logo.png +0 -0
  110. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/Square284x284Logo.png +0 -0
  111. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/Square30x30Logo.png +0 -0
  112. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/Square310x310Logo.png +0 -0
  113. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/Square44x44Logo.png +0 -0
  114. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/Square71x71Logo.png +0 -0
  115. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/Square89x89Logo.png +0 -0
  116. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/StoreLogo.png +0 -0
  117. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/icon.icns +0 -0
  118. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/icon.ico +0 -0
  119. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/icon.png +0 -0
  120. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/src/lib.rs +0 -0
  121. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/src/main.rs +0 -0
  122. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/tauri.conf.json +0 -0
  123. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/tsconfig.app.json +0 -0
  124. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/tsconfig.json +0 -0
  125. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/tsconfig.node.json +0 -0
  126. {codeabc-0.1.2 → codeabc-0.1.3}/frontend/vite.config.ts +0 -0
  127. {codeabc-0.1.2 → codeabc-0.1.3}/run.py +0 -0
  128. {codeabc-0.1.2 → codeabc-0.1.3}/scripts/build_desktop_sidecar.py +0 -0
  129. {codeabc-0.1.2 → codeabc-0.1.3}/start.bat +0 -0
  130. {codeabc-0.1.2 → codeabc-0.1.3}/start.sh +0 -0
  131. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_activity.py +0 -0
  132. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_annotate_parsing.py +0 -0
  133. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_apimap.py +0 -0
  134. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_async_surface.py +0 -0
  135. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_cache.py +0 -0
  136. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_churn.py +0 -0
  137. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_ci_checks.py +0 -0
  138. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_codemap_export.py +0 -0
  139. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_commands.py +0 -0
  140. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_complexity.py +0 -0
  141. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_config_files.py +0 -0
  142. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_contributing.py +0 -0
  143. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_coverage.py +0 -0
  144. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_datamodels.py +0 -0
  145. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_deep_nesting.py +0 -0
  146. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_dependencies.py +0 -0
  147. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_docs.py +0 -0
  148. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_docstrings.py +0 -0
  149. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_duplicate_code.py +0 -0
  150. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_edit_prompt.py +0 -0
  151. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_entrypoints.py +0 -0
  152. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_error_handling.py +0 -0
  153. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_filenames.py +0 -0
  154. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_glossary.py +0 -0
  155. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_health_score.py +0 -0
  156. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_importgraph.py +0 -0
  157. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_integrations.py +0 -0
  158. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_licenses.py +0 -0
  159. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_llm.py +0 -0
  160. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_long_functions.py +0 -0
  161. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_ownership.py +0 -0
  162. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_package_purposes.py +0 -0
  163. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_project.py +0 -0
  164. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_qa_prompt.py +0 -0
  165. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_release_map.py +0 -0
  166. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_report_export.py +0 -0
  167. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_risk.py +0 -0
  168. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_run_launcher.py +0 -0
  169. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_scanner.py +0 -0
  170. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_schedules.py +0 -0
  171. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_security.py +0 -0
  172. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_settings_map.py +0 -0
  173. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_symbols.py +0 -0
  174. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_techdebt.py +0 -0
  175. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_too_many_params.py +0 -0
  176. {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_typing_coverage.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: codeabc
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: AI-powered code reader for non-programmers
5
5
  Author-email: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
6
6
  License-Expression: MIT
@@ -22,7 +22,7 @@ Requires-Python: >=3.10
22
22
  Requires-Dist: aiofiles>=24.0.0
23
23
  Requires-Dist: aiosqlite>=0.20.0
24
24
  Requires-Dist: fastapi>=0.115.0
25
- Requires-Dist: litellm>=1.40.0
25
+ Requires-Dist: litellm!=1.100.0,!=1.98.0,!=1.99.0,>=1.40.0
26
26
  Requires-Dist: pydantic>=2.0.0
27
27
  Requires-Dist: python-multipart>=0.0.9
28
28
  Requires-Dist: uvicorn[standard]>=0.30.0
@@ -125,6 +125,10 @@ Before trusting a script you didn't write, you usually want to know what it reac
125
125
 
126
126
  The most dangerous line in a codebase is often the one that quietly hides a problem: a bare `except:` that catches everything, an empty `catch {}` that throws the error away, a failure that gets logged and then ignored. To a non-coder these are invisible; to a maintainer they are where bugs go to hide. CodeABC flags these silent-failure spots and explains, in everyday terms, why "the program kept going as if nothing happened" can be worse than crashing loudly.
127
127
 
128
+ ### Which Functions Does Nobody Call?
129
+
130
+ Orphan modules tell you which files nobody imports; this goes one level finer and asks which functions and classes inside otherwise used files nobody calls. A public helper written for a caller that no longer exists is dead weight every reader still has to load into their head, and it is usually the code that breaks first in a refactor because nothing exercises it. CodeABC flags top-level functions, classes, and exported symbols whose name never appears outside their own file, with the reasoning spelled out. Decorated definitions (routes, fixtures, commands) are skipped on purpose, and the report calls itself a candidate list for human review rather than a verdict, because string references and dynamic imports are invisible to any text scan.
131
+
128
132
  ### Which Files Are Begging for Docs?
129
133
 
130
134
  Not every file needs a comment, but the ones that other files lean on heavily and that still have almost no explanation are the ones that cost a newcomer the most. CodeABC scores each file on how under-documented it is relative to how central it is, and surfaces the handful that would help the most if someone wrote a few sentences at the top. It is a reading aid as much as a writing one: these are usually the files worth understanding first.
@@ -90,6 +90,10 @@ Before trusting a script you didn't write, you usually want to know what it reac
90
90
 
91
91
  The most dangerous line in a codebase is often the one that quietly hides a problem: a bare `except:` that catches everything, an empty `catch {}` that throws the error away, a failure that gets logged and then ignored. To a non-coder these are invisible; to a maintainer they are where bugs go to hide. CodeABC flags these silent-failure spots and explains, in everyday terms, why "the program kept going as if nothing happened" can be worse than crashing loudly.
92
92
 
93
+ ### Which Functions Does Nobody Call?
94
+
95
+ Orphan modules tell you which files nobody imports; this goes one level finer and asks which functions and classes inside otherwise used files nobody calls. A public helper written for a caller that no longer exists is dead weight every reader still has to load into their head, and it is usually the code that breaks first in a refactor because nothing exercises it. CodeABC flags top-level functions, classes, and exported symbols whose name never appears outside their own file, with the reasoning spelled out. Decorated definitions (routes, fixtures, commands) are skipped on purpose, and the report calls itself a candidate list for human review rather than a verdict, because string references and dynamic imports are invisible to any text scan.
96
+
93
97
  ### Which Files Are Begging for Docs?
94
98
 
95
99
  Not every file needs a comment, but the ones that other files lean on heavily and that still have almost no explanation are the ones that cost a newcomer the most. CodeABC scores each file on how under-documented it is relative to how central it is, and surfaces the handful that would help the most if someone wrote a few sentences at the top. It is a reading aid as much as a writing one: these are usually the files worth understanding first.
@@ -98,6 +98,10 @@ Cursor/VS Code 是给程序员用的瑞士军刀,码上懂是给普通人用
98
98
 
99
99
  一份代码里最危险的一行,往往是悄悄把问题藏起来的那行:一个 `except:` 把所有异常一网打尽、一个空的 `catch {}` 把错误直接扔掉、一个失败被记了个日志然后就当没事发生。对不懂代码的人这些完全看不见,对维护者这些正是 bug 藏身的地方。码上懂会把这些"静默失败"的位置标出来,用大白话讲清楚为什么"程序假装什么都没发生地继续跑"有时比当场报错崩掉更糟。
100
100
 
101
+ ### 哪些函数根本没有人在用?
102
+
103
+ "没人 import 的文件"好找,更细一层的问题是:那些正常被用到的文件里,有没有谁写了却没人调用的函数和类?一个当年为某个调用方写的公开函数,调用方没了,函数还在,每个读代码的人都得白白把它装进脑子,重构时又因为它没有测试覆盖而最先坏掉。码上懂会把"名字在项目里其他地方一次都没出现过"的顶层函数、类和导出符号挑出来,并把判断依据写清楚。带装饰器的定义(路由、fixture、命令)会特意跳过,报告也明说自己是"待人工复核的候选"而不是判决书,因为字符串引用和动态 import 是任何文本扫描都看不见的。
104
+
101
105
  ### 哪些文件最该补说明?
102
106
 
103
107
  不是每个文件都需要注释,但那些被很多别的文件依赖、自己却几乎没有任何说明的文件,是最让新人头疼的。码上懂会按"有多核心"对比"有多缺说明"给每个文件打分,挑出那几个"只要有人在开头写几句话就最能帮到大家"的文件。它既是写文档的提示,也是读代码的向导:这几个文件通常正是最值得先搞懂的。
@@ -183,6 +183,21 @@ class ErrorHandling(BaseModel):
183
183
  notes: list[str] = []
184
184
 
185
185
 
186
+ class UnusedExport(BaseModel):
187
+ path: str
188
+ name: str
189
+ kind: str # "function" | "class"
190
+ line: int
191
+ reason: str # plain-language explanation for a non-programmer
192
+
193
+
194
+ class UnusedExports(BaseModel):
195
+ total: int = 0
196
+ files_affected: int = 0
197
+ findings: list[UnusedExport] = []
198
+ notes: list[str] = []
199
+
200
+
186
201
  class ExternalService(BaseModel):
187
202
  name: str
188
203
  category: str # AI / 大模型 | 云服务 | 数据库 | 支付 | 通讯 | ...
@@ -202,6 +217,7 @@ class EnvVar(BaseModel):
202
217
  name: str
203
218
  required: bool # read via os.environ["X"] somewhere, so it must be set
204
219
  count: int = 1 # how many places read it
220
+ documented: bool = True # mentioned in .env.example / README / docs anywhere
205
221
 
206
222
 
207
223
  class EntryPoint(BaseModel):
@@ -484,6 +500,7 @@ class ProjectMeta(BaseModel):
484
500
  action_plan: ActionPlan | None = None
485
501
  doc_coverage: DocCoverage | None = None
486
502
  error_handling: ErrorHandling | None = None
503
+ unused_exports: UnusedExports | None = None
487
504
  integrations: Integrations | None = None
488
505
  release: ReleaseSummary | None = None
489
506
  contribution: ContributionGuide | None = None
@@ -614,7 +631,10 @@ class FileOutline(BaseModel):
614
631
 
615
632
 
616
633
  class GitHubRequest(BaseModel):
617
- url: str = Field(..., pattern=r"^https?://github\.com/.+/.+")
634
+ # Shape validation lives in github_clone._parse_github_url, which already
635
+ # accepts bare github.com/... links, inner-page URLs, git@ SSH and the
636
+ # owner/repo shorthand; a regex here rejected those before reaching it.
637
+ url: str = Field(..., min_length=1, description="GitHub URL or owner/repo shorthand")
618
638
 
619
639
 
620
640
  class UploadedFile(BaseModel):
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import uuid
6
6
 
7
- from fastapi import APIRouter, HTTPException
7
+ from fastapi import APIRouter, HTTPException, Request
8
8
  from fastapi.responses import Response
9
9
 
10
10
  from backend.models import (
@@ -71,6 +71,8 @@ from backend.models import (
71
71
  TechDebtFile,
72
72
  TestCoverageSummary,
73
73
  TunableSetting,
74
+ UnusedExport,
75
+ UnusedExports,
74
76
  UploadedFile,
75
77
  )
76
78
  from backend.services import (
@@ -116,6 +118,7 @@ from backend.services import (
116
118
  techdebt,
117
119
  too_many_params,
118
120
  typing_coverage,
121
+ unused_exports,
119
122
  )
120
123
  from backend.services import (
121
124
  health_score as health_score_svc,
@@ -252,6 +255,7 @@ def _content_analyses(
252
255
  """
253
256
  tech_debt = techdebt.scan_tech_debt(file_contents)
254
257
  env = envscan.scan_env_vars(file_contents)
258
+ undocumented_env = set(envscan.find_undocumented_env_vars(env, file_contents))
255
259
  entries = entrypoints.find_entry_points(file_contents)
256
260
  cli_commands = commands.find_cli_commands(file_contents)
257
261
  data_models = datamodels.find_data_models(file_contents)
@@ -266,6 +270,7 @@ def _content_analyses(
266
270
  api = apimap.scan_api_routes(file_contents)
267
271
  doc = docs.assess_doc_coverage(file_contents)
268
272
  silent = error_handling.find_swallowed_errors(file_contents)
273
+ dead = unused_exports.find_unused_exports(file_contents)
269
274
  integ = integrations.detect_external_services(file_contents)
270
275
  rel = release_map.find_release_info(file_contents)
271
276
  contrib = contributing.find_contribution_guide(file_contents)
@@ -285,7 +290,13 @@ def _content_analyses(
285
290
  TechDebtFile(path=f["path"], count=f["count"]) for f in tech_debt["files"]
286
291
  ],
287
292
  "env_vars": [
288
- EnvVar(name=v["name"], required=v["required"], count=v["count"]) for v in env["vars"]
293
+ EnvVar(
294
+ name=v["name"],
295
+ required=v["required"],
296
+ count=v["count"],
297
+ documented=v["name"] not in undocumented_env,
298
+ )
299
+ for v in env["vars"]
289
300
  ],
290
301
  "entry_points": [
291
302
  EntryPoint(path=e["path"], kind=e["kind"], command=e["command"], reason=e["reason"])
@@ -410,6 +421,12 @@ def _content_analyses(
410
421
  findings=[SilentFailure(**f) for f in silent["findings"]],
411
422
  notes=silent["notes"],
412
423
  ),
424
+ "unused_exports": UnusedExports(
425
+ total=dead["total"],
426
+ files_affected=dead["files_affected"],
427
+ findings=[UnusedExport(**f) for f in dead["findings"]],
428
+ notes=dead["notes"],
429
+ ),
413
430
  "integrations": Integrations(
414
431
  total=integ["total"],
415
432
  services=[ExternalService(**s) for s in integ["services"]],
@@ -796,15 +813,20 @@ async def get_references(project_id: str, name: str):
796
813
 
797
814
 
798
815
  @router.get("/pr-reading")
799
- async def get_pr_reading(url: str):
816
+ async def get_pr_reading(url: str, request: Request, explain: bool = False):
800
817
  """Turn a PR link into a plain-language diff walkthrough.
801
818
 
802
- Deterministic (no LLM): fetches the PR's unified diff, breaks it into
803
- per-file changes with add/del counts, classifies each file as code, test,
804
- docs, or config, and proposes a review reading order (biggest production
805
- diffs first). An LLM explanation layer can sit on this payload later.
819
+ The deterministic core (no LLM) fetches the PR's unified diff, breaks it
820
+ into per-file changes with add/del counts, classifies each file as code,
821
+ test, docs, or config, and proposes a review reading order (biggest
822
+ production diffs first). With explain=true the payload also carries
823
+ per-file plain-language explanations: LLM-written when the caller passes
824
+ an x-api-key, deterministic diff-derived notes otherwise.
806
825
  """
807
826
  try:
827
+ if explain:
828
+ api_key = request.headers.get("x-api-key")
829
+ return await pr_reading.analyze_pr_explained(url, explain=True, api_key=api_key)
808
830
  return pr_reading.analyze_pr(url)
809
831
  except ValueError as exc:
810
832
  raise HTTPException(400, str(exc)) from exc
@@ -198,6 +198,13 @@ def _too_many_params_actions(too_many_params_files: list[dict] | None) -> list[d
198
198
  return actions
199
199
 
200
200
 
201
+ def _missing_count(f: dict) -> int:
202
+ # typing_coverage emits the sample of unannotated symbol names (a list);
203
+ # older cached payloads may carry a bare count. Either way compare counts.
204
+ m = f.get("missing", 0)
205
+ return len(m) if isinstance(m, list) else m
206
+
207
+
201
208
  def _typing_actions(typing_files: list[dict] | None) -> list[dict]:
202
209
  if not typing_files:
203
210
  return []
@@ -205,11 +212,11 @@ def _typing_actions(typing_files: list[dict] | None) -> list[dict]:
205
212
  # Files with the most unannotated public symbols and low coverage first;
206
213
  # only surface genuinely under-annotated files (not a stray missing hint).
207
214
  weak = sorted(
208
- (f for f in typing_files if f.get("missing", 0) >= 3 and f.get("coverage", 1) < 0.6),
209
- key=lambda f: (-f.get("missing", 0), f.get("coverage", 1)),
215
+ (f for f in typing_files if _missing_count(f) >= 3 and f.get("coverage", 1) < 0.6),
216
+ key=lambda f: (-_missing_count(f), f.get("coverage", 1)),
210
217
  )
211
218
  for f in weak[:_MAX_PER_CATEGORY]:
212
- missing = f.get("missing", 0)
219
+ missing = _missing_count(f)
213
220
  pct = round(f.get("coverage", 0) * 100)
214
221
  actions.append(
215
222
  {
@@ -165,3 +165,47 @@ def render_env_markdown(project_name: str, env_data: dict | None) -> str:
165
165
  lines.append("")
166
166
 
167
167
  return "\n".join(lines).rstrip() + "\n"
168
+
169
+
170
+ _DOC_BASENAMES = (
171
+ ".env.example",
172
+ ".env.sample",
173
+ ".env.template",
174
+ ".env.defaults",
175
+ )
176
+
177
+
178
+ def _is_doc_candidate(path: str) -> bool:
179
+ """Whether a file is a place a newcomer would look for env configuration."""
180
+ lower = path.lower()
181
+ base = lower.rsplit("/", 1)[-1]
182
+ if base in _DOC_BASENAMES or base.startswith("readme"):
183
+ return True
184
+ if base.endswith(".md") and (
185
+ "/" not in lower or lower.startswith(("docs/", "doc/", ".github/"))
186
+ ):
187
+ return True
188
+ return False
189
+
190
+
191
+ def find_undocumented_env_vars(scan: dict, file_contents: dict[str, str]) -> list[str]:
192
+ """Names of env vars that no documentation file mentions.
193
+
194
+ ``.env.example`` and the README are where a newcomer learns what to
195
+ configure; a variable the code reads but nobody writes down is a setup
196
+ landmine, doubly so when it is required. Matching is a whole-word search
197
+ over every documentation candidate, so ``HOST`` does not count as
198
+ documented just because ``DB_HOST`` appears. Pure over the scan result and
199
+ the file texts, so it is unit-testable with plain dicts.
200
+ """
201
+ doc_text = "\n".join(
202
+ content for path, content in file_contents.items() if _is_doc_candidate(path)
203
+ )
204
+ undocumented = []
205
+ for var in scan.get("vars", []):
206
+ name = var.get("name")
207
+ if not name:
208
+ continue
209
+ if not re.search(rf"(?<![A-Za-z0-9_]){re.escape(name)}(?![A-Za-z0-9_])", doc_text):
210
+ undocumented.append(name)
211
+ return undocumented
@@ -7,6 +7,7 @@ order. An LLM explanation layer can sit on top of this payload later.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import asyncio
10
11
  import json
11
12
  import re
12
13
  import subprocess
@@ -179,3 +180,131 @@ def render_markdown(analysis: dict) -> str:
179
180
 
180
181
  def to_json(analysis: dict) -> str:
181
182
  return json.dumps(analysis, ensure_ascii=False, indent=2)
183
+
184
+
185
+ _EXPLAIN_MAX_FILES = 8
186
+ _DIFF_EXCERPT_MAX_LINES = 120
187
+
188
+ _SYMBOL_RE = re.compile(
189
+ r"^\+\s*(?:async\s+def|def|class|function|export\s+(?:async\s+)?function|export\s+class)\s+([A-Za-z_][\w]*)"
190
+ )
191
+
192
+
193
+ def _diff_sections(diff_text: str) -> dict[str, list[str]]:
194
+ """Map each file path to its raw diff lines (headers + hunks)."""
195
+ sections: dict[str, list[str]] = {}
196
+ current: str | None = None
197
+ for line in diff_text.splitlines():
198
+ if line.startswith("diff --git "):
199
+ match = re.match(r"diff --git a/(\S+) b/(\S+)", line)
200
+ current = match.group(2) if match else None
201
+ if current is not None:
202
+ sections[current] = [line]
203
+ elif current is not None:
204
+ sections[current].append(line)
205
+ return sections
206
+
207
+
208
+ def _symbols_from_excerpt(excerpt_lines: list[str]) -> list[str]:
209
+ names: list[str] = []
210
+ for line in excerpt_lines:
211
+ m = _SYMBOL_RE.match(line)
212
+ if m and m.group(1) not in names:
213
+ names.append(m.group(1))
214
+ return names
215
+
216
+
217
+ def _deterministic_note(change: FileChange, excerpt_lines: list[str]) -> str:
218
+ """Plain-language note without any LLM: what kind of file it is, its size, and the
219
+ functions/classes it adds or updates (from added definition lines in the diff)."""
220
+ symbols = _symbols_from_excerpt(excerpt_lines)
221
+ base = {
222
+ "code": "production code",
223
+ "test": "test",
224
+ "docs": "documentation",
225
+ "config": "configuration",
226
+ }.get(change.change_type, "other")
227
+ size = f"+{change.added} / -{change.deleted} lines"
228
+ if symbols:
229
+ return f"This {base} file ({size}) adds or updates: {', '.join(symbols[:6])}."
230
+ return f"This {base} file ({size}) is edited; open it to see the exact changes."
231
+
232
+
233
+ def _excerpt(lines: list[str]) -> str:
234
+ if len(lines) <= _DIFF_EXCERPT_MAX_LINES:
235
+ return "\n".join(lines)
236
+ head = "\n".join(lines[:_DIFF_EXCERPT_MAX_LINES])
237
+ return head + f"\n... ({len(lines) - _DIFF_EXCERPT_MAX_LINES} more diff lines trimmed)"
238
+
239
+
240
+ async def explain_files(
241
+ files: list[FileChange],
242
+ diff_text: str,
243
+ *,
244
+ api_key: str | None = None,
245
+ model: str | None = None,
246
+ ) -> dict[str, str]:
247
+ """Plain-language note per file. LLM-backed when a key is configured; otherwise a
248
+ deterministic note built from the diff itself, so the feature works key-free."""
249
+ from backend.services.llm import call_llm, is_error_text
250
+
251
+ sections = _diff_sections(diff_text)
252
+ out: dict[str, str] = {}
253
+
254
+ async def explain_one(change: FileChange) -> tuple[str, str]:
255
+ excerpt_lines = sections.get(change.path, [])
256
+ excerpt = _excerpt(excerpt_lines)
257
+ fallback = _deterministic_note(change, excerpt_lines)
258
+ if not api_key:
259
+ return change.path, fallback
260
+ prompt = (
261
+ "You are explaining one file's part of a pull request to someone who does not code.\n"
262
+ f"File: {change.path} ({change.change_type}, +{change.added} / -{change.deleted}).\n"
263
+ f"Diff excerpt:\n{excerpt}\n\n"
264
+ "In 2-3 plain sentences: what changed in this file and why it might matter. "
265
+ "No jargon; say the file name, not the path, when you refer to it."
266
+ )
267
+ text = (await call_llm(prompt, api_key=api_key, model=model)).strip()
268
+ if not text or is_error_text(text):
269
+ return change.path, fallback
270
+ return change.path, text
271
+
272
+ ordered = files[:_EXPLAIN_MAX_FILES]
273
+ results = await asyncio.gather(*(explain_one(f) for f in ordered))
274
+ out.update(results)
275
+ # Files beyond the cap still get the deterministic note so the UI never shows a gap.
276
+ for f in files[_EXPLAIN_MAX_FILES:]:
277
+ out[f.path] = _deterministic_note(f, sections.get(f.path, []))
278
+ return out
279
+
280
+
281
+ async def analyze_pr_explained(
282
+ url: str, *, explain: bool, api_key: str | None = None, model: str | None = None
283
+ ) -> dict:
284
+ """analyze_pr plus optional per-file explanations (LLM when keyed, deterministic otherwise)."""
285
+ ref = parse_pr_url(url)
286
+ diff_text = await asyncio.to_thread(fetch_pr_diff, ref)
287
+ files = parse_diff_files(diff_text)
288
+ ordered = reading_order(files)
289
+ analysis = {
290
+ "url": url,
291
+ "owner": ref.owner,
292
+ "repo": ref.repo,
293
+ "number": ref.number,
294
+ "summary": summarize_diff(files),
295
+ "files": [
296
+ {
297
+ "path": f.path,
298
+ "added": f.added,
299
+ "deleted": f.deleted,
300
+ "change_type": f.change_type,
301
+ "hunks": f.hunks,
302
+ }
303
+ for f in ordered
304
+ ],
305
+ }
306
+ if explain:
307
+ analysis["explanations"] = await explain_files(
308
+ ordered, diff_text, api_key=api_key, model=model
309
+ )
310
+ return analysis
@@ -0,0 +1,185 @@
1
+ """Unused exports: public symbols that nothing else in the project references.
2
+
3
+ Orphan modules answer "which files does nobody import?". This answers the
4
+ finer question one level down: "which functions and classes inside otherwise
5
+ used files does nobody call?". A public helper that was written for a caller
6
+ that no longer exists is dead weight a new reader still has to load into
7
+ their head, and it tends to be the code that breaks first in a refactor
8
+ because nothing exercises it.
9
+
10
+ :func:`find_unused_exports` walks the already-read file contents and flags
11
+ top-level ``def`` / ``class`` definitions (Python) and ``export``ed
12
+ ``function`` / ``class`` / ``const`` declarations (JS/TS) whose name never
13
+ shows up anywhere outside their own file.
14
+
15
+ It deliberately stays conservative to keep the signal worth reading:
16
+
17
+ * Anything decorated is skipped. Decorators are how frameworks register
18
+ things (FastAPI routes, pytest fixtures, click commands, tasks), and a
19
+ decorated definition is used by the framework even when no caller is
20
+ visible in the text.
21
+ * Dunder names, private names (leading ``_``), and definitions inside test
22
+ files are skipped. Tests are consumers, not candidates.
23
+ * A definition referenced only inside its own module still counts as a
24
+ candidate: a public symbol used only locally is effectively private and
25
+ could have been a ``_helper``.
26
+
27
+ Findings are *candidates for human review, not verdicts*. String references,
28
+ ``getattr`` lookups, dynamic imports, and re-export barrels are not tracked,
29
+ so anything here deserves a second look before deletion.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import re
35
+
36
+ _PY = {"py", "pyi"}
37
+ _JSTS = {"js", "jsx", "ts", "tsx", "mjs", "cjs", "mts", "cts"}
38
+
39
+ _TEST_SEGMENTS = {"test", "tests", "__tests__", "spec", "specs"}
40
+
41
+ _PY_DEF_RE = re.compile(r"^(async\s+def|def|class)\s+([A-Za-z_][A-Za-z0-9_]*)")
42
+ _JS_EXPORT_RE = re.compile(
43
+ r"^\s*export\s+(?:async\s+)?(?:function|class|const|let)\s+([A-Za-z_$][A-Za-z0-9_$]*)"
44
+ )
45
+
46
+ # Names that look dead but are entry points by convention.
47
+ _ENTRY_NAMES = {
48
+ "main",
49
+ "app",
50
+ "application",
51
+ "handler",
52
+ "create_app",
53
+ "run",
54
+ "cli",
55
+ "register",
56
+ "setup",
57
+ "teardown",
58
+ "ready",
59
+ "default",
60
+ }
61
+
62
+ _MAX_FINDINGS = 200
63
+
64
+
65
+ def _ext(path: str) -> str:
66
+ name = path.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
67
+ return name.rsplit(".", 1)[-1].lower() if "." in name else ""
68
+
69
+
70
+ def _is_test(path: str) -> bool:
71
+ norm = path.replace("\\", "/")
72
+ segments = [s for s in norm.split("/") if s]
73
+ if any(s in _TEST_SEGMENTS for s in segments):
74
+ return True
75
+ name = segments[-1] if segments else ""
76
+ stem = name.rsplit(".", 1)[0] if "." in name else name
77
+ return stem.startswith("test_") or stem.endswith("_test") or ".test" in name or ".spec" in name
78
+
79
+
80
+ def _collect_python_defs(path: str, text: str) -> list[dict]:
81
+ """Top-level, undecorated def/class definitions in one Python file."""
82
+ defs: list[dict] = []
83
+ pending_decorated = False
84
+ for lineno, line in enumerate(text.splitlines(), start=1):
85
+ stripped = line.strip()
86
+ if not stripped or stripped.startswith("#"):
87
+ continue
88
+ indent = len(line) - len(line.lstrip())
89
+ if stripped.startswith("@"):
90
+ if indent == 0:
91
+ pending_decorated = True
92
+ continue
93
+ if indent != 0:
94
+ pending_decorated = False
95
+ continue
96
+ m = _PY_DEF_RE.match(line)
97
+ if not m:
98
+ pending_decorated = False
99
+ continue
100
+ name = m.group(2)
101
+ decorated = pending_decorated
102
+ pending_decorated = False
103
+ if decorated or name.startswith("_") or name in _ENTRY_NAMES:
104
+ continue
105
+ kind = "class" if m.group(1) == "class" else "function"
106
+ defs.append({"path": path, "name": name, "kind": kind, "line": lineno})
107
+ return defs
108
+
109
+
110
+ def _collect_js_defs(path: str, text: str) -> list[dict]:
111
+ defs: list[dict] = []
112
+ for lineno, line in enumerate(text.splitlines(), start=1):
113
+ m = _JS_EXPORT_RE.match(line)
114
+ if not m:
115
+ continue
116
+ name = m.group(1)
117
+ if name.startswith("_") or name in _ENTRY_NAMES:
118
+ continue
119
+ kind = "class" if "class" in line.split("export", 1)[1].split(name)[0] else "function"
120
+ defs.append({"path": path, "name": name, "kind": kind, "line": lineno})
121
+ return defs
122
+
123
+
124
+ def _referenced_outside(name: str, home: str, contents: dict[str, str]) -> bool:
125
+ """True when the name appears anywhere outside its own file.
126
+
127
+ Test files count as consumers here: a symbol exercised only by tests is
128
+ alive, it is just not part of the runtime surface.
129
+ """
130
+ word = re.compile(r"\b" + re.escape(name) + r"\b")
131
+ for path, text in contents.items():
132
+ if path == home:
133
+ continue
134
+ if word.search(text):
135
+ return True
136
+ return False
137
+
138
+
139
+ def _referenced_locally_only(name: str, home: str, text: str) -> bool:
140
+ """True when the name is used in its own file beyond the definition line."""
141
+ return len(re.findall(r"\b" + re.escape(name) + r"\b", text)) > 1
142
+
143
+
144
+ def find_unused_exports(file_contents: dict[str, str]) -> dict:
145
+ """Flag public definitions with no references outside their own file.
146
+
147
+ Returns a summary dict with ``total``, ``files_affected``, ``findings``
148
+ (path/name/kind/line/reason), and ``notes`` explaining the blind spots.
149
+ """
150
+ candidates: list[dict] = []
151
+ for path, text in file_contents.items():
152
+ ext = _ext(path)
153
+ if ext not in _PY and ext not in _JSTS:
154
+ continue
155
+ if _is_test(path):
156
+ continue
157
+ defs = _collect_python_defs(path, text) if ext in _PY else _collect_js_defs(path, text)
158
+ for d in defs:
159
+ if _referenced_outside(d["name"], path, file_contents):
160
+ continue
161
+ local = _referenced_locally_only(d["name"], path, text)
162
+ reason = (
163
+ f"public {d['kind']} used only inside its own module; "
164
+ "nothing imports it elsewhere (effectively private)"
165
+ if local
166
+ else f"public {d['kind']} with no references anywhere else in the project"
167
+ )
168
+ candidates.append({**d, "reason": reason})
169
+
170
+ candidates.sort(key=lambda c: (c["path"], c["line"]))
171
+ findings = candidates[:_MAX_FINDINGS]
172
+ return {
173
+ "total": len(candidates),
174
+ "files_affected": len({c["path"] for c in candidates}),
175
+ "findings": findings,
176
+ "notes": [
177
+ "Findings are candidates for human review, not proof of dead code: "
178
+ "string references, getattr, dynamic imports, and re-export barrels "
179
+ "are not tracked.",
180
+ "Decorated definitions are skipped because frameworks (FastAPI, "
181
+ "pytest, click, task queues) register them without a visible caller.",
182
+ "A symbol used only inside its own module is flagged as effectively "
183
+ "private, not necessarily dead.",
184
+ ],
185
+ }
@@ -1,5 +1,6 @@
1
1
  import { BrowserRouter, Routes, Route } from "react-router-dom";
2
2
  import Home from "./pages/Home";
3
+ import PrView from "./pages/PrView";
3
4
  import Overview from "./pages/Overview";
4
5
  import FileView from "./pages/FileView";
5
6
  import { I18nProvider } from "./lib/i18n";
@@ -10,6 +11,7 @@ function App() {
10
11
  <BrowserRouter>
11
12
  <Routes>
12
13
  <Route path="/" element={<Home />} />
14
+ <Route path="/pr" element={<PrView />} />
13
15
  <Route path="/project/:projectId" element={<Overview />} />
14
16
  <Route path="/project/:projectId/file/*" element={<FileView />} />
15
17
  </Routes>