codeabc 0.1.2__py3-none-any.whl → 0.1.3__py3-none-any.whl
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.
- backend/models.py +21 -1
- backend/routers/project.py +29 -7
- backend/services/action_plan.py +10 -3
- backend/services/envscan.py +44 -0
- backend/services/pr_reading.py +129 -0
- backend/services/unused_exports.py +185 -0
- {codeabc-0.1.2.dist-info → codeabc-0.1.3.dist-info}/METADATA +7 -3
- {codeabc-0.1.2.dist-info → codeabc-0.1.3.dist-info}/RECORD +11 -10
- {codeabc-0.1.2.dist-info → codeabc-0.1.3.dist-info}/WHEEL +1 -1
- {codeabc-0.1.2.dist-info → codeabc-0.1.3.dist-info}/entry_points.txt +0 -0
- {codeabc-0.1.2.dist-info → codeabc-0.1.3.dist-info}/licenses/LICENSE +0 -0
backend/models.py
CHANGED
|
@@ -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):
|
backend/routers/project.py
CHANGED
|
@@ -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
|
backend/services/action_plan.py
CHANGED
|
@@ -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
|
{
|
backend/services/envscan.py
CHANGED
|
@@ -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
|
backend/services/pr_reading.py
CHANGED
|
@@ -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,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.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
backend/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
2
|
backend/app.py,sha256=diNqZZhw7NjIWG7OYR7fuwH16tMyLQIpP6o1AMtccbc,3059
|
|
3
3
|
backend/desktop_server.py,sha256=sBMYZOxoa2diFMtVAALJvqCyntmf6uEvYFu4xVz3D1M,1875
|
|
4
|
-
backend/models.py,sha256=
|
|
4
|
+
backend/models.py,sha256=UmP2DdvOYGC4xLP8JYAPlbbbIKIA5HvUvQZf-4gf7vk,18763
|
|
5
5
|
backend/prompts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
6
|
backend/prompts/annotate.py,sha256=Hiuz8QrWg5gh5dgZrfyMZjnB-HiDwR1nydtkc4XnPKA,1805
|
|
7
7
|
backend/prompts/edit.py,sha256=dPlKxLNxpfHAnfU_hmWwIZtM103EY3j7dDXNfhOJxss,2111
|
|
@@ -9,9 +9,9 @@ backend/prompts/overview.py,sha256=nQGN4NrbCYXxWIli0S1CzttF4SoHrlIPLceW08UbAL4,2
|
|
|
9
9
|
backend/prompts/qa.py,sha256=6g2crmr7uM4XnlqzE0juXuUtzyaXlPLly58DCE1mDN0,1653
|
|
10
10
|
backend/routers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
11
|
backend/routers/analyze.py,sha256=kXyXO785zHEiwqWL6j77roBM2KZ_TiDyc6vSgI8LQh8,9474
|
|
12
|
-
backend/routers/project.py,sha256=
|
|
12
|
+
backend/routers/project.py,sha256=H6YqbBZiDTSQcmAQNffPmliUqvUv6qk8KifLNcW6nUE,33410
|
|
13
13
|
backend/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
-
backend/services/action_plan.py,sha256=
|
|
14
|
+
backend/services/action_plan.py,sha256=cMOHFzVVplsR1OSZGNkBqiD9rM7ON-1qE__NQVk6Clg,14913
|
|
15
15
|
backend/services/activity.py,sha256=86cDXEN_1eBrL9CWRM4U4avQUTC2r7wY_3UQvw0gopY,11125
|
|
16
16
|
backend/services/apimap.py,sha256=-CNwurd5kvUCPL3DUzf5e2OV1zGY3Zclim-UMEs8w9w,13988
|
|
17
17
|
backend/services/async_surface.py,sha256=vlLAeqh-pTaIFNRQLgDEXAmhOxJ9aYzF46AYDh_x3Io,4807
|
|
@@ -31,7 +31,7 @@ backend/services/docs.py,sha256=9bzTtlVgnw0uu7tfx0yT8OnjptqThsIm8kvMHWK17b0,8076
|
|
|
31
31
|
backend/services/docstrings.py,sha256=VPBQETdldMRf4l--nDZxeHA_86yAwE0LH6OMcOaYC8E,6736
|
|
32
32
|
backend/services/duplicate_code.py,sha256=8uybJ21JAIGK7BW4Ff7Zp4OjfsY9WkuXs7fCnK2HR7E,6776
|
|
33
33
|
backend/services/entrypoints.py,sha256=gvT4ebaKYaUXZqkmbjYEt52svWGh47A9FhWn_K5rqDs,6417
|
|
34
|
-
backend/services/envscan.py,sha256=
|
|
34
|
+
backend/services/envscan.py,sha256=7QY-zymr4NV5WMxZLWiDUla28bQKvMKz4EcRAz3wvfM,9247
|
|
35
35
|
backend/services/error_handling.py,sha256=_tm8z71nD08SNAlDE-2XB5Mkhr7svhnUhQgzGKY_Vo0,8889
|
|
36
36
|
backend/services/filenames.py,sha256=W01M2z1pm26JT2ONKD3sG3oELUygnglZ9T0ir7QxKcU,11875
|
|
37
37
|
backend/services/github_clone.py,sha256=JXjr2T76R_9anJlxCiJL2LDZc2tjfexg6gRIyEj1n3I,4393
|
|
@@ -44,7 +44,7 @@ backend/services/llm.py,sha256=BFKOIxPkO8vIsdzMRMgQus5kWd564EzKBgm3BHzQi68,4410
|
|
|
44
44
|
backend/services/long_functions.py,sha256=gDQrYsU-WE9wSQgI6ZkDS7gmakWeOACgpXqKyCJzVX4,4452
|
|
45
45
|
backend/services/ownership.py,sha256=4BzcCbYEht-Wv8YHEjepfzhzutoT10LEpDRVhqTZnY0,7311
|
|
46
46
|
backend/services/package_purposes.py,sha256=LIjvOPcfSdF2ynhHqQMaTnhcl4tyyPYNFy5Nz2t1j6c,7162
|
|
47
|
-
backend/services/pr_reading.py,sha256=
|
|
47
|
+
backend/services/pr_reading.py,sha256=HGYM7Wp8Aqi5fU_e26A3m4K5NK0pm_vO-PjiFLun1IE,11010
|
|
48
48
|
backend/services/release_map.py,sha256=nimpVDnqg8SWTQ5tgeBaW-1HCC3VkwmvGwbvnqlp61A,15998
|
|
49
49
|
backend/services/report_export.py,sha256=vxzSuwGx7l6HvL_BzyfjRMVilyutcamfx9Qxscb4rps,8529
|
|
50
50
|
backend/services/risk.py,sha256=NNtInmaklWz6MYsOMwPg0_VKmQ_Wtrdj2sk4LhikTbM,3375
|
|
@@ -56,8 +56,9 @@ backend/services/symbols.py,sha256=2sLmLavz0OeJF0IBCV6msWwE1wBlMhkHpl1-SQJKTno,2
|
|
|
56
56
|
backend/services/techdebt.py,sha256=og7-mzIsYjKzYRHOnO0DVKmnjDrOgeyZ2FVhHoy0Jsw,4720
|
|
57
57
|
backend/services/too_many_params.py,sha256=z4VW2uX13iameFhmzjby8qgu1YUGBCWVDsT6ITj-aGs,4432
|
|
58
58
|
backend/services/typing_coverage.py,sha256=4qH_HnXlzK1OJtm6gD5QZp2qaePj5k1eIcS70XlZF_U,7451
|
|
59
|
-
|
|
60
|
-
codeabc-0.1.
|
|
61
|
-
codeabc-0.1.
|
|
62
|
-
codeabc-0.1.
|
|
63
|
-
codeabc-0.1.
|
|
59
|
+
backend/services/unused_exports.py,sha256=4_lm7Xr9-xl9ZuLbeVLj9zybx3oHPMn4kzXChB8Kmuc,7033
|
|
60
|
+
codeabc-0.1.3.dist-info/METADATA,sha256=ylszuXllXMlIacMSOLP74Ks7WmQ1npSaijFQRuaXwos,21163
|
|
61
|
+
codeabc-0.1.3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
62
|
+
codeabc-0.1.3.dist-info/entry_points.txt,sha256=VX1hw16wgWy0owLA2Dgc7FPh0tx3qWFbBvBuTKSLj9o,60
|
|
63
|
+
codeabc-0.1.3.dist-info/licenses/LICENSE,sha256=Gjpln1GILd5PHMjjXZpdojQ-DGlvmxFP_nr19R6m9xo,1066
|
|
64
|
+
codeabc-0.1.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|