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.
- {codeabc-0.1.2 → codeabc-0.1.3}/PKG-INFO +7 -3
- {codeabc-0.1.2 → codeabc-0.1.3}/README.md +4 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/README_CN.md +4 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/models.py +21 -1
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/routers/project.py +29 -7
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/action_plan.py +10 -3
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/envscan.py +44 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/pr_reading.py +129 -0
- codeabc-0.1.3/backend/services/unused_exports.py +185 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/App.tsx +2 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/lib/api.ts +60 -5
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/pages/Home.tsx +9 -3
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/pages/Overview.tsx +105 -0
- codeabc-0.1.3/frontend/src/pages/PrView.tsx +127 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/pyproject.toml +3 -2
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_action_plan.py +26 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_envscan.py +36 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_github_clone.py +26 -0
- codeabc-0.1.3/tests/test_pr_reading.py +191 -0
- codeabc-0.1.3/tests/test_unused_exports.py +93 -0
- codeabc-0.1.2/tests/test_pr_reading.py +0 -102
- {codeabc-0.1.2 → codeabc-0.1.3}/.env.example +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/.github/workflows/ci.yml +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/.gitignore +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/LICENSE +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/__init__.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/app.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/desktop_server.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/prompts/__init__.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/prompts/annotate.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/prompts/edit.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/prompts/overview.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/prompts/qa.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/routers/__init__.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/routers/analyze.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/__init__.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/activity.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/apimap.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/async_surface.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/cache.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/churn.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/ci_checks.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/codemap_export.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/commands.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/complexity.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/config_files.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/contributing.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/coverage.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/datamodels.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/deep_nesting.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/dependencies.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/docs.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/docstrings.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/duplicate_code.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/entrypoints.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/error_handling.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/filenames.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/github_clone.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/glossary.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/health_score.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/importgraph.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/integrations.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/licenses.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/llm.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/long_functions.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/ownership.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/package_purposes.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/release_map.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/report_export.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/risk.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/scanner.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/schedules.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/security.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/settings_map.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/symbols.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/techdebt.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/too_many_params.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/backend/services/typing_coverage.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/docs/architecture.png +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/docs/banner.png +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/.gitignore +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/README.md +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/eslint.config.js +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/index.html +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/package-lock.json +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/package.json +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/components/AnnotationTooltip.tsx +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/components/ApiKeyModal.tsx +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/components/CodeViewer.tsx +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/components/PrReadingCard.tsx +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/components/UploadZone.tsx +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/index.css +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/lib/errors.ts +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/lib/i18n.tsx +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/main.tsx +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/pages/FileView.tsx +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/stores/project.ts +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src/vite-env.d.ts +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/.gitignore +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/Cargo.lock +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/Cargo.toml +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/build.rs +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/capabilities/default.json +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/128x128.png +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/128x128@2x.png +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/32x32.png +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/Square107x107Logo.png +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/Square142x142Logo.png +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/Square150x150Logo.png +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/Square284x284Logo.png +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/Square30x30Logo.png +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/Square310x310Logo.png +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/Square44x44Logo.png +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/Square71x71Logo.png +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/Square89x89Logo.png +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/StoreLogo.png +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/icon.icns +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/icon.ico +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/icons/icon.png +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/src/lib.rs +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/src/main.rs +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/src-tauri/tauri.conf.json +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/tsconfig.app.json +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/tsconfig.json +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/tsconfig.node.json +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/frontend/vite.config.ts +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/run.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/scripts/build_desktop_sidecar.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/start.bat +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/start.sh +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_activity.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_annotate_parsing.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_apimap.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_async_surface.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_cache.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_churn.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_ci_checks.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_codemap_export.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_commands.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_complexity.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_config_files.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_contributing.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_coverage.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_datamodels.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_deep_nesting.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_dependencies.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_docs.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_docstrings.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_duplicate_code.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_edit_prompt.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_entrypoints.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_error_handling.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_filenames.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_glossary.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_health_score.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_importgraph.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_integrations.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_licenses.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_llm.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_long_functions.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_ownership.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_package_purposes.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_project.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_qa_prompt.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_release_map.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_report_export.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_risk.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_run_launcher.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_scanner.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_schedules.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_security.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_settings_map.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_symbols.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_techdebt.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_too_many_params.py +0 -0
- {codeabc-0.1.2 → codeabc-0.1.3}/tests/test_typing_coverage.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: codeabc
|
|
3
|
-
Version: 0.1.
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
803
|
-
per-file changes with add/del counts, classifies each file as code,
|
|
804
|
-
docs, or config, and proposes a review reading order (biggest
|
|
805
|
-
diffs first).
|
|
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
|
|
209
|
-
key=lambda f: (-f
|
|
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
|
|
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>
|