codeabc 0.1.0__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/__init__.py +0 -0
- backend/app.py +95 -0
- backend/desktop_server.py +46 -0
- backend/models.py +629 -0
- backend/prompts/__init__.py +0 -0
- backend/prompts/annotate.py +44 -0
- backend/prompts/edit.py +62 -0
- backend/prompts/overview.py +57 -0
- backend/prompts/qa.py +45 -0
- backend/routers/__init__.py +0 -0
- backend/routers/analyze.py +271 -0
- backend/routers/project.py +872 -0
- backend/services/__init__.py +0 -0
- backend/services/action_plan.py +366 -0
- backend/services/activity.py +313 -0
- backend/services/apimap.py +391 -0
- backend/services/async_surface.py +123 -0
- backend/services/cache.py +183 -0
- backend/services/churn.py +244 -0
- backend/services/ci_checks.py +317 -0
- backend/services/codemap_export.py +136 -0
- backend/services/commands.py +276 -0
- backend/services/complexity.py +111 -0
- backend/services/config_files.py +190 -0
- backend/services/contributing.py +308 -0
- backend/services/coverage.py +283 -0
- backend/services/datamodels.py +190 -0
- backend/services/deep_nesting.py +110 -0
- backend/services/dependencies.py +270 -0
- backend/services/docs.py +255 -0
- backend/services/docstrings.py +163 -0
- backend/services/duplicate_code.py +165 -0
- backend/services/entrypoints.py +152 -0
- backend/services/envscan.py +167 -0
- backend/services/error_handling.py +239 -0
- backend/services/filenames.py +261 -0
- backend/services/github_clone.py +129 -0
- backend/services/glossary.py +96 -0
- backend/services/health_score.py +381 -0
- backend/services/importgraph.py +986 -0
- backend/services/integrations.py +263 -0
- backend/services/licenses.py +505 -0
- backend/services/llm.py +117 -0
- backend/services/long_functions.py +101 -0
- backend/services/ownership.py +194 -0
- backend/services/package_purposes.py +112 -0
- backend/services/pr_reading.py +181 -0
- backend/services/release_map.py +402 -0
- backend/services/report_export.py +232 -0
- backend/services/risk.py +88 -0
- backend/services/scanner.py +546 -0
- backend/services/schedules.py +369 -0
- backend/services/security.py +445 -0
- backend/services/settings_map.py +234 -0
- backend/services/symbols.py +605 -0
- backend/services/techdebt.py +124 -0
- backend/services/too_many_params.py +102 -0
- backend/services/typing_coverage.py +184 -0
- codeabc-0.1.0.dist-info/METADATA +283 -0
- codeabc-0.1.0.dist-info/RECORD +62 -0
- codeabc-0.1.0.dist-info/WHEEL +4 -0
- codeabc-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Test-coverage map: which code files have tests, and which don't.
|
|
2
|
+
|
|
3
|
+
A non-programmer sizing up a codebase — a founder reviewing outsourced work,
|
|
4
|
+
say — wants a blunt answer to "is this code actually tested?" and, more
|
|
5
|
+
usefully, "which important files have *no* test, so changing them is the
|
|
6
|
+
riskiest?". The static reading map and the risk list don't answer that; this
|
|
7
|
+
does.
|
|
8
|
+
|
|
9
|
+
Deterministic, no LLM. It pairs source files with test files two ways:
|
|
10
|
+
|
|
11
|
+
* by import — a test that imports a source file genuinely exercises it; this
|
|
12
|
+
reuses the project import graph, no recomputation, and
|
|
13
|
+
* by name — the near-universal convention that ``test_scanner.py`` /
|
|
14
|
+
``scanner.test.ts`` / ``scanner.spec.ts`` cover ``scanner``.
|
|
15
|
+
|
|
16
|
+
Untested files are then ranked by import fan-in: a file nothing tests but many
|
|
17
|
+
files depend on is exactly where an unnoticed regression spreads furthest.
|
|
18
|
+
|
|
19
|
+
Pure functions over the scanner's file list — unit-testable with plain dicts,
|
|
20
|
+
no repo or git history needed.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
|
|
27
|
+
from .importgraph import _JS_LANGS, _PY_LANGS, _build_import_graph, _posix
|
|
28
|
+
|
|
29
|
+
# a file under one of these directories is a test regardless of its name
|
|
30
|
+
_TEST_DIRS = {"test", "tests", "__tests__", "spec", "specs"}
|
|
31
|
+
# packaging / fixtures, not logic that warrants its own test
|
|
32
|
+
_NON_SOURCE_NAMES = {"__init__.py", "conftest.py", "setup.py"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _segments(path: str) -> list[str]:
|
|
36
|
+
return [s for s in _posix(path).split("/") if s]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _stem(name: str) -> str:
|
|
40
|
+
return name.rsplit(".", 1)[0] if "." in name else name
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _is_test_file(path: str, lang: str) -> bool:
|
|
44
|
+
"""True if the path looks like a test/spec file."""
|
|
45
|
+
segs = _segments(path)
|
|
46
|
+
name = segs[-1] if segs else ""
|
|
47
|
+
stem = _stem(name)
|
|
48
|
+
if any(d in _TEST_DIRS for d in segs[:-1]):
|
|
49
|
+
return True
|
|
50
|
+
if lang in _PY_LANGS:
|
|
51
|
+
return stem == "conftest" or stem.startswith("test_") or stem.endswith("_test")
|
|
52
|
+
if lang in _JS_LANGS:
|
|
53
|
+
# scanner.test.ts / scanner.spec.ts -> stem ends with .test / .spec
|
|
54
|
+
return stem.endswith(".test") or stem.endswith(".spec")
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _tested_stem(path: str, lang: str) -> str | None:
|
|
59
|
+
"""The source stem a test file targets by naming convention, or None."""
|
|
60
|
+
stem = _stem(_segments(path)[-1])
|
|
61
|
+
if lang in _PY_LANGS:
|
|
62
|
+
if stem.startswith("test_"):
|
|
63
|
+
return stem[len("test_") :] or None
|
|
64
|
+
if stem.endswith("_test"):
|
|
65
|
+
return stem[: -len("_test")] or None
|
|
66
|
+
return None
|
|
67
|
+
if lang in _JS_LANGS:
|
|
68
|
+
for suffix in (".test", ".spec"):
|
|
69
|
+
if stem.endswith(suffix):
|
|
70
|
+
return stem[: -len(suffix)] or None
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _untested_reason(fan_in: int) -> str:
|
|
75
|
+
if fan_in >= 5:
|
|
76
|
+
return f"没有测试,却有 {fan_in} 个文件依赖它——这里出问题会牵连一大片,补测试的优先级最高。"
|
|
77
|
+
if fan_in >= 1:
|
|
78
|
+
return f"没有测试,有 {fan_in} 个文件依赖它:改动缺少自动验证,留意回归。"
|
|
79
|
+
return "没有测试:相对孤立的文件,改动靠人工确认即可,风险较低。"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# config files that pin a Python test runner
|
|
83
|
+
_PYTEST_CONFIG_NAMES = {"pytest.ini", "conftest.py", "tox.ini"}
|
|
84
|
+
# JS/TS config-file stems -> the framework they configure
|
|
85
|
+
_JS_TEST_CONFIG_PREFIXES = {"jest.config": "jest", "vitest.config": "vitest", ".mocharc": "mocha"}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _preview_of(files: list[dict], name_lower: str) -> str | None:
|
|
89
|
+
"""The preview of the first file whose basename matches ``name_lower``."""
|
|
90
|
+
for f in files:
|
|
91
|
+
if _segments(f["path"])[-1].lower() == name_lower:
|
|
92
|
+
return f.get("preview") or ""
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def detect_test_tooling(files: list[dict], test_files: set[str], lang_of: dict[str, str]) -> dict:
|
|
97
|
+
"""Detect the test framework(s) in use and the command to run the tests.
|
|
98
|
+
|
|
99
|
+
The coverage map says *which* files are tested; a non-programmer's very next
|
|
100
|
+
question is "so how do I actually run these tests?". This answers it from
|
|
101
|
+
config files and the imports inside the test files themselves — deterministic,
|
|
102
|
+
no LLM, and honest about what it can't see.
|
|
103
|
+
|
|
104
|
+
Returns ``{"frameworks": sorted[str], "run_command": str | None}``. When both
|
|
105
|
+
a Python and a JS/TS runner are present (a monorepo), ``run_command`` is the
|
|
106
|
+
one whose ecosystem has more test files.
|
|
107
|
+
"""
|
|
108
|
+
frameworks: set[str] = set()
|
|
109
|
+
all_names = {_segments(f["path"])[-1].lower() for f in files}
|
|
110
|
+
|
|
111
|
+
if _PYTEST_CONFIG_NAMES & all_names:
|
|
112
|
+
frameworks.add("pytest")
|
|
113
|
+
for name in all_names:
|
|
114
|
+
for prefix, fw in _JS_TEST_CONFIG_PREFIXES.items():
|
|
115
|
+
if name.startswith(prefix):
|
|
116
|
+
frameworks.add(fw)
|
|
117
|
+
for cfg in ("pyproject.toml", "setup.cfg"):
|
|
118
|
+
pv = _preview_of(files, cfg) or ""
|
|
119
|
+
if "[tool.pytest" in pv or "[pytest]" in pv:
|
|
120
|
+
frameworks.add("pytest")
|
|
121
|
+
|
|
122
|
+
# signals from the test files' own imports (previews carry the head, where imports sit)
|
|
123
|
+
preview_by_path = {_posix(f["path"]): (f.get("preview") or "") for f in files}
|
|
124
|
+
py_test = js_test = 0
|
|
125
|
+
for t in test_files:
|
|
126
|
+
pv = preview_by_path.get(t, "")
|
|
127
|
+
lang = lang_of.get(t, "")
|
|
128
|
+
if lang in _PY_LANGS:
|
|
129
|
+
py_test += 1
|
|
130
|
+
if "import pytest" in pv or "from pytest" in pv:
|
|
131
|
+
frameworks.add("pytest")
|
|
132
|
+
elif "import unittest" in pv or "from unittest" in pv or "unittest.TestCase" in pv:
|
|
133
|
+
frameworks.add("unittest")
|
|
134
|
+
elif lang in _JS_LANGS:
|
|
135
|
+
js_test += 1
|
|
136
|
+
if "vitest" in pv:
|
|
137
|
+
frameworks.add("vitest")
|
|
138
|
+
if "@jest/globals" in pv or re.search(r"\bjest\.", pv):
|
|
139
|
+
frameworks.add("jest")
|
|
140
|
+
|
|
141
|
+
pkg = _preview_of(files, "package.json") or ""
|
|
142
|
+
has_npm_test = re.search(r'"test"\s*:', pkg) is not None
|
|
143
|
+
|
|
144
|
+
py_cmd = (
|
|
145
|
+
"pytest"
|
|
146
|
+
if "pytest" in frameworks
|
|
147
|
+
else ("python -m unittest" if "unittest" in frameworks else None)
|
|
148
|
+
)
|
|
149
|
+
js_fw = next((fw for fw in ("vitest", "jest", "mocha") if fw in frameworks), None)
|
|
150
|
+
js_cmd = ("npm test" if has_npm_test else f"npx {js_fw}") if js_fw else None
|
|
151
|
+
|
|
152
|
+
if py_test >= js_test:
|
|
153
|
+
run_command = py_cmd or js_cmd
|
|
154
|
+
else:
|
|
155
|
+
run_command = js_cmd or py_cmd
|
|
156
|
+
|
|
157
|
+
return {"frameworks": sorted(frameworks), "run_command": run_command}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _coverage_notes(
|
|
161
|
+
total: int, pct: int, test_file_count: int, core_count: int, tooling: dict
|
|
162
|
+
) -> list[str]:
|
|
163
|
+
if total == 0:
|
|
164
|
+
return ["没有发现源代码文件。"]
|
|
165
|
+
if test_file_count == 0:
|
|
166
|
+
return ["没有发现任何测试文件——这个项目没有自动化测试,每次改动都得靠人工验证。"]
|
|
167
|
+
notes = []
|
|
168
|
+
if pct >= 70:
|
|
169
|
+
notes.append(f"{pct}% 的代码文件有对应测试,改动相对有保障。")
|
|
170
|
+
elif pct >= 30:
|
|
171
|
+
notes.append(f"只有 {pct}% 的代码文件有测试,不少改动缺少自动验证、更容易留隐患。")
|
|
172
|
+
else:
|
|
173
|
+
notes.append(f"只有 {pct}% 的代码文件有测试,测试覆盖很薄,改动风险偏高。")
|
|
174
|
+
if core_count:
|
|
175
|
+
notes.append(
|
|
176
|
+
f"其中 {core_count} 个被其它文件依赖的核心文件没有测试,改它们风险最高,建议优先补。"
|
|
177
|
+
)
|
|
178
|
+
run_command = tooling.get("run_command")
|
|
179
|
+
if run_command:
|
|
180
|
+
fw = "、".join(tooling.get("frameworks") or []) or "自动化测试"
|
|
181
|
+
notes.append(f"用的是 {fw}:在项目根目录运行 `{run_command}` 就能跑测试。")
|
|
182
|
+
return notes
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def assess_test_coverage(files: list[dict], *, limit: int = 12) -> dict:
|
|
186
|
+
"""Map source files to whether they have a test, and rank the untested core.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
files: the scanner's file list — dicts with ``path``, ``language`` and
|
|
190
|
+
``preview`` (used to resolve imports).
|
|
191
|
+
limit: how many untested files to surface in ``untested_core``.
|
|
192
|
+
|
|
193
|
+
Returns a dict shaped for ``TestCoverageSummary``: file counts, a 0-100
|
|
194
|
+
``coverage_percent`` (tested source files / total source files), the
|
|
195
|
+
untested files ranked by import fan-in, and plain-language ``notes``.
|
|
196
|
+
"""
|
|
197
|
+
_, imports, fan_in = _build_import_graph(files)
|
|
198
|
+
|
|
199
|
+
test_files: set[str] = set()
|
|
200
|
+
source_files: set[str] = set()
|
|
201
|
+
lang_of: dict[str, str] = {}
|
|
202
|
+
for f in files:
|
|
203
|
+
path = _posix(f["path"])
|
|
204
|
+
lang = f.get("language", "unknown")
|
|
205
|
+
if lang not in _PY_LANGS and lang not in _JS_LANGS:
|
|
206
|
+
continue
|
|
207
|
+
lang_of[path] = lang
|
|
208
|
+
if _is_test_file(path, lang):
|
|
209
|
+
test_files.add(path)
|
|
210
|
+
elif _segments(path)[-1] not in _NON_SOURCE_NAMES:
|
|
211
|
+
source_files.add(path)
|
|
212
|
+
|
|
213
|
+
covered: set[str] = set()
|
|
214
|
+
# 1) import-based: a test that imports a source file exercises it
|
|
215
|
+
for t in test_files:
|
|
216
|
+
for dep in imports.get(t, ()):
|
|
217
|
+
if dep in source_files:
|
|
218
|
+
covered.add(dep)
|
|
219
|
+
# 2) name-based: test_scanner.py <-> scanner.py, scanner.test.ts <-> scanner.ts
|
|
220
|
+
tested_stems = {s for t in test_files if (s := _tested_stem(t, lang_of[t])) is not None}
|
|
221
|
+
tested_stems = {s.lower() for s in tested_stems}
|
|
222
|
+
if tested_stems:
|
|
223
|
+
for s in source_files:
|
|
224
|
+
if _stem(_segments(s)[-1]).lower() in tested_stems:
|
|
225
|
+
covered.add(s)
|
|
226
|
+
|
|
227
|
+
untested = sorted(
|
|
228
|
+
source_files - covered,
|
|
229
|
+
key=lambda p: (-len(fan_in.get(p, ())), p),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
total = len(source_files)
|
|
233
|
+
tested = len(covered)
|
|
234
|
+
pct = round(tested / total * 100) if total else 0
|
|
235
|
+
|
|
236
|
+
untested_core = [
|
|
237
|
+
{
|
|
238
|
+
"path": p,
|
|
239
|
+
"language": lang_of.get(p, "unknown"),
|
|
240
|
+
"fan_in": len(fan_in.get(p, ())),
|
|
241
|
+
"reason": _untested_reason(len(fan_in.get(p, ()))),
|
|
242
|
+
}
|
|
243
|
+
for p in untested[:limit]
|
|
244
|
+
]
|
|
245
|
+
core_count = sum(1 for u in untested_core if u["fan_in"] >= 1)
|
|
246
|
+
tooling = detect_test_tooling(files, test_files, lang_of)
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
"total_source_files": total,
|
|
250
|
+
"tested_files": tested,
|
|
251
|
+
"untested_files": total - tested,
|
|
252
|
+
"test_files": len(test_files),
|
|
253
|
+
"coverage_percent": pct,
|
|
254
|
+
"test_frameworks": tooling["frameworks"],
|
|
255
|
+
"run_command": tooling["run_command"],
|
|
256
|
+
"untested_core": untested_core,
|
|
257
|
+
"notes": _coverage_notes(total, pct, len(test_files), core_count, tooling),
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def render_coverage_markdown(name: str, coverage: dict | None) -> str:
|
|
262
|
+
"""Render the test-coverage summary as a Markdown section, or ``""``."""
|
|
263
|
+
if not coverage or not coverage.get("total_source_files"):
|
|
264
|
+
return ""
|
|
265
|
+
pct = coverage.get("coverage_percent", 0)
|
|
266
|
+
lines = [
|
|
267
|
+
f"# 测试覆盖({name})",
|
|
268
|
+
"",
|
|
269
|
+
f"> {coverage.get('tested_files', 0)}/{coverage.get('total_source_files', 0)} "
|
|
270
|
+
f"个代码文件有测试({pct}%),共发现 {coverage.get('test_files', 0)} 个测试文件。",
|
|
271
|
+
"",
|
|
272
|
+
]
|
|
273
|
+
run_command = coverage.get("run_command")
|
|
274
|
+
if run_command:
|
|
275
|
+
fw = "、".join(coverage.get("test_frameworks") or []) or "自动化测试"
|
|
276
|
+
lines.append(f"> 测试框架:{fw}。运行:`{run_command}`")
|
|
277
|
+
lines.append("")
|
|
278
|
+
lines.extend(f"- {note}" for note in coverage.get("notes", []))
|
|
279
|
+
untested = coverage.get("untested_core", [])
|
|
280
|
+
if untested:
|
|
281
|
+
lines.extend(["", "## 没有测试、最该补的文件", ""])
|
|
282
|
+
lines.extend(f"- `{u['path']}` — {u['reason']}" for u in untested)
|
|
283
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Data-model map — what shape the data this project moves around actually has.
|
|
2
|
+
|
|
3
|
+
The symbol index tells you *where* a class named ``User`` is declared. It does
|
|
4
|
+
not tell you what a ``User`` *is*: which fields it carries, and of what type.
|
|
5
|
+
For someone trying to understand an unfamiliar codebase without reading every
|
|
6
|
+
file, that second question is often the important one — "an Order has a total, a
|
|
7
|
+
list of items and a status" says more about the program than any call graph.
|
|
8
|
+
|
|
9
|
+
This map reads those shapes straight from the source CodeABC already loaded — no
|
|
10
|
+
LLM, nothing to install or run — by recognising the four ways Python code
|
|
11
|
+
declares a data record explicitly:
|
|
12
|
+
|
|
13
|
+
dataclass ``@dataclass class User: id: int; name: str = ""``
|
|
14
|
+
pydantic ``class Order(BaseModel): total: float``
|
|
15
|
+
TypedDict ``class Movie(TypedDict): title: str``
|
|
16
|
+
NamedTuple ``class Coord(NamedTuple): lat: float``
|
|
17
|
+
|
|
18
|
+
Each model carries its name, which of the four kinds it is, the fields it
|
|
19
|
+
declares (name, type annotation, and whether it has a default), and the file and
|
|
20
|
+
line where it is defined.
|
|
21
|
+
|
|
22
|
+
:func:`find_data_models` is pure over the file contents, so it is unit-testable
|
|
23
|
+
with plain strings and needs no repository.
|
|
24
|
+
|
|
25
|
+
Limitations (kept honest on purpose):
|
|
26
|
+
|
|
27
|
+
* Only the four class-based declaration styles above. The functional forms
|
|
28
|
+
(``TypedDict("Movie", {...})``, ``namedtuple("Coord", [...])``) and ORM
|
|
29
|
+
bases (SQLAlchemy ``Base``, Django ``models.Model``) are out of scope — the
|
|
30
|
+
latter are too easy to confuse with ordinary classes to flag safely.
|
|
31
|
+
* Only statically-declared, annotated fields. Attributes assigned in
|
|
32
|
+
``__init__`` or built dynamically are not counted.
|
|
33
|
+
* Inherited fields are not flattened in; each model lists only what it
|
|
34
|
+
declares itself.
|
|
35
|
+
* A file that does not parse as Python is skipped, not guessed at.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import ast
|
|
41
|
+
|
|
42
|
+
# Base-class names that mark a class as a typed data record, mapped to the kind
|
|
43
|
+
# label we report for them.
|
|
44
|
+
_BASE_KINDS = {
|
|
45
|
+
"BaseModel": "pydantic",
|
|
46
|
+
"TypedDict": "typeddict",
|
|
47
|
+
"NamedTuple": "namedtuple",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _base_name(node: ast.expr) -> str | None:
|
|
52
|
+
"""Return the right-most attribute name of a base/decorator expression.
|
|
53
|
+
|
|
54
|
+
``BaseModel`` -> ``BaseModel``; ``pydantic.BaseModel`` -> ``BaseModel``.
|
|
55
|
+
"""
|
|
56
|
+
if isinstance(node, ast.Name):
|
|
57
|
+
return node.id
|
|
58
|
+
if isinstance(node, ast.Attribute):
|
|
59
|
+
return node.attr
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_dataclass(cls: ast.ClassDef) -> bool:
|
|
64
|
+
"""True when the class carries a ``@dataclass`` decorator in any form."""
|
|
65
|
+
for dec in cls.decorator_list:
|
|
66
|
+
target = dec.func if isinstance(dec, ast.Call) else dec
|
|
67
|
+
if _base_name(target) == "dataclass":
|
|
68
|
+
return True
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _classify(cls: ast.ClassDef) -> str | None:
|
|
73
|
+
"""Resolve which data-model kind a class is, or ``None`` if it is not one."""
|
|
74
|
+
if _is_dataclass(cls):
|
|
75
|
+
return "dataclass"
|
|
76
|
+
for base in cls.bases:
|
|
77
|
+
name = _base_name(base)
|
|
78
|
+
if name and name in _BASE_KINDS:
|
|
79
|
+
return _BASE_KINDS[name]
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _annotation_str(node: ast.expr | None) -> str:
|
|
84
|
+
if node is None:
|
|
85
|
+
return ""
|
|
86
|
+
try:
|
|
87
|
+
return ast.unparse(node)
|
|
88
|
+
except Exception:
|
|
89
|
+
return ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _fields(cls: ast.ClassDef) -> list[dict]:
|
|
93
|
+
"""Collect the annotated fields declared directly in the class body."""
|
|
94
|
+
fields: list[dict] = []
|
|
95
|
+
for stmt in cls.body:
|
|
96
|
+
if not isinstance(stmt, ast.AnnAssign) or not isinstance(stmt.target, ast.Name):
|
|
97
|
+
continue
|
|
98
|
+
name = stmt.target.id
|
|
99
|
+
# Skip private (_x) and dunder (__x__) names: ORM table hints like
|
|
100
|
+
# __tablename__ and internal bookkeeping are not part of the data shape.
|
|
101
|
+
if name.startswith("_"):
|
|
102
|
+
continue
|
|
103
|
+
fields.append(
|
|
104
|
+
{
|
|
105
|
+
"name": name,
|
|
106
|
+
"type": _annotation_str(stmt.annotation),
|
|
107
|
+
"has_default": stmt.value is not None,
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
return fields
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def find_data_models(file_contents: dict[str, str], *, limit: int = 60) -> dict:
|
|
114
|
+
"""Collect the explicitly-declared data models a project defines.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
file_contents: mapping of path to file text (as CodeABC already read it).
|
|
118
|
+
limit: how many models to return in the sorted list.
|
|
119
|
+
|
|
120
|
+
Returns ``{"total", "kinds", "models"}`` where each model is
|
|
121
|
+
``{"name", "kind", "fields", "path", "line"}``, ``kind`` is one of
|
|
122
|
+
``dataclass`` / ``pydantic`` / ``typeddict`` / ``namedtuple``, and each field
|
|
123
|
+
is ``{"name", "type", "has_default"}``.
|
|
124
|
+
"""
|
|
125
|
+
seen: set[tuple[str, str, int]] = set()
|
|
126
|
+
models: list[dict] = []
|
|
127
|
+
|
|
128
|
+
for path, content in file_contents.items():
|
|
129
|
+
if not content or not path.endswith(".py"):
|
|
130
|
+
continue
|
|
131
|
+
try:
|
|
132
|
+
tree = ast.parse(content)
|
|
133
|
+
except (SyntaxError, ValueError):
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
for node in ast.walk(tree):
|
|
137
|
+
if not isinstance(node, ast.ClassDef):
|
|
138
|
+
continue
|
|
139
|
+
kind = _classify(node)
|
|
140
|
+
if kind is None:
|
|
141
|
+
continue
|
|
142
|
+
key = (path, node.name, node.lineno)
|
|
143
|
+
if key in seen:
|
|
144
|
+
continue
|
|
145
|
+
seen.add(key)
|
|
146
|
+
models.append(
|
|
147
|
+
{
|
|
148
|
+
"name": node.name,
|
|
149
|
+
"kind": kind,
|
|
150
|
+
"fields": _fields(node),
|
|
151
|
+
"path": path,
|
|
152
|
+
"line": node.lineno,
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
models.sort(key=lambda m: (m["path"], m["line"]))
|
|
157
|
+
kinds = sorted({m["kind"] for m in models})
|
|
158
|
+
return {"total": len(models), "kinds": kinds, "models": models[:limit]}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def render_data_models_markdown(project_name: str, data: dict | None) -> str:
|
|
162
|
+
"""Render the data-model map as Markdown, or ``""`` if none were found."""
|
|
163
|
+
models = (data or {}).get("models") or []
|
|
164
|
+
if not models:
|
|
165
|
+
return ""
|
|
166
|
+
|
|
167
|
+
lines = [
|
|
168
|
+
f"# {project_name} — 数据长什么样(数据模型)",
|
|
169
|
+
"",
|
|
170
|
+
"> 符号索引告诉你一个类“在哪声明”,这里告诉你它“装了什么”——"
|
|
171
|
+
"项目用 dataclass / pydantic / TypedDict / NamedTuple 显式声明的数据记录,"
|
|
172
|
+
"以及每条记录有哪些字段、各是什么类型。",
|
|
173
|
+
"",
|
|
174
|
+
]
|
|
175
|
+
for model in models:
|
|
176
|
+
lines.append(f"## `{model['name']}` ({model['kind']},{model['path']}:{model['line']})")
|
|
177
|
+
if not model["fields"]:
|
|
178
|
+
lines.append("- (未声明带类型标注的字段)")
|
|
179
|
+
lines.append("")
|
|
180
|
+
continue
|
|
181
|
+
for field in model["fields"]:
|
|
182
|
+
entry = f"- `{field['name']}`"
|
|
183
|
+
if field["type"]:
|
|
184
|
+
entry += f":{field['type']}"
|
|
185
|
+
if field["has_default"]:
|
|
186
|
+
entry += "(有默认值)"
|
|
187
|
+
lines.append(entry)
|
|
188
|
+
lines.append("")
|
|
189
|
+
|
|
190
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Flag deeply-nested code — functions whose control flow is indented many levels deep.
|
|
2
|
+
|
|
3
|
+
Deep nesting (an ``if`` inside a ``for`` inside a ``while`` inside a ``try`` ...) is
|
|
4
|
+
hard to follow even when each individual branch is simple, so it is a different
|
|
5
|
+
signal from :mod:`complexity` (which counts *how many* branches a file has): a
|
|
6
|
+
function can have only a few branches yet bury them several levels deep. For a
|
|
7
|
+
beginner reading unfamiliar code this is exactly the shape that is hard to hold in
|
|
8
|
+
your head, and it points at the functions most worth flattening with early returns,
|
|
9
|
+
guard clauses, or an extracted helper.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ast
|
|
15
|
+
|
|
16
|
+
# Control-flow blocks that add a level of indentation.
|
|
17
|
+
_NESTING_NODES = (
|
|
18
|
+
ast.If,
|
|
19
|
+
ast.For,
|
|
20
|
+
ast.AsyncFor,
|
|
21
|
+
ast.While,
|
|
22
|
+
ast.With,
|
|
23
|
+
ast.AsyncWith,
|
|
24
|
+
ast.Try,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Functions nested this deep or more are surfaced.
|
|
28
|
+
_DEPTH_THRESHOLD = 4
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _max_control_depth(node: ast.AST, current: int = 0) -> int:
|
|
32
|
+
"""Deepest chain of nested control-flow blocks under ``node``.
|
|
33
|
+
|
|
34
|
+
Nested functions and classes are *not* descended into: they are separate
|
|
35
|
+
scopes and are measured on their own pass, so a helper defined inside a
|
|
36
|
+
function does not inflate the outer function's depth.
|
|
37
|
+
"""
|
|
38
|
+
best = current
|
|
39
|
+
for child in ast.iter_child_nodes(node):
|
|
40
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
41
|
+
continue
|
|
42
|
+
if isinstance(child, _NESTING_NODES):
|
|
43
|
+
best = max(best, _max_control_depth(child, current + 1))
|
|
44
|
+
else:
|
|
45
|
+
best = max(best, _max_control_depth(child, current))
|
|
46
|
+
return best
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def scan_deep_nesting(
|
|
50
|
+
file_contents: dict[str, str], *, threshold: int = _DEPTH_THRESHOLD, limit: int = 15
|
|
51
|
+
) -> dict:
|
|
52
|
+
"""Find functions whose control flow nests ``threshold`` levels deep or more.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
file_contents: mapping of path to file text (as CodeABC already read it).
|
|
56
|
+
threshold: minimum nesting depth to flag a function.
|
|
57
|
+
limit: how many of the deepest functions to return.
|
|
58
|
+
|
|
59
|
+
Returns ``{"total", "max_depth", "threshold", "files"}`` where ``files`` lists
|
|
60
|
+
the deepest functions (descending), each ``{"path", "function", "depth"}``.
|
|
61
|
+
Test files and non-Python files are skipped.
|
|
62
|
+
"""
|
|
63
|
+
flagged: list[dict] = []
|
|
64
|
+
overall_max = 0
|
|
65
|
+
for path, content in file_contents.items():
|
|
66
|
+
if not content or not path.endswith(".py"):
|
|
67
|
+
continue
|
|
68
|
+
base = path.rsplit("/", 1)[-1]
|
|
69
|
+
if base.startswith("test_") or base.endswith("_test.py") or "/tests/" in path:
|
|
70
|
+
continue
|
|
71
|
+
try:
|
|
72
|
+
tree = ast.parse(content)
|
|
73
|
+
except (SyntaxError, ValueError):
|
|
74
|
+
continue
|
|
75
|
+
for node in ast.walk(tree):
|
|
76
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
77
|
+
depth = _max_control_depth(node)
|
|
78
|
+
overall_max = max(overall_max, depth)
|
|
79
|
+
if depth >= threshold:
|
|
80
|
+
flagged.append({"path": path, "function": node.name, "depth": depth})
|
|
81
|
+
|
|
82
|
+
flagged.sort(key=lambda f: (-f["depth"], f["path"], f["function"]))
|
|
83
|
+
return {
|
|
84
|
+
"total": len(flagged),
|
|
85
|
+
"max_depth": overall_max,
|
|
86
|
+
"threshold": threshold,
|
|
87
|
+
"files": flagged[:limit],
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def render_deep_nesting_markdown(project_name: str, data: dict | None) -> str:
|
|
92
|
+
"""Render the deep-nesting section, or an empty string when nothing is flagged."""
|
|
93
|
+
if not data or not data.get("files"):
|
|
94
|
+
return ""
|
|
95
|
+
|
|
96
|
+
threshold = data.get("threshold", _DEPTH_THRESHOLD)
|
|
97
|
+
lines = [
|
|
98
|
+
f"## 嵌套过深的函数({project_name})",
|
|
99
|
+
"",
|
|
100
|
+
"下面这些函数把 `if / for / while / try` 一层套一层,缩进很深。就算每一层"
|
|
101
|
+
"本身不复杂,层数一多也很难读——你得同时记住「现在在第几层、每一层的条件是"
|
|
102
|
+
f"什么」。这里列出嵌套达到 **{threshold} 层或更深**的函数,它们通常最值得用"
|
|
103
|
+
"「提前 return / 卫语句 / 把里层抽成一个小函数」拍平。",
|
|
104
|
+
"",
|
|
105
|
+
"| 文件 | 函数 | 最深嵌套层数 |",
|
|
106
|
+
"| --- | --- | --- |",
|
|
107
|
+
]
|
|
108
|
+
for f in data["files"]:
|
|
109
|
+
lines.append(f"| `{f['path']}` | `{f['function']}` | {f['depth']} |")
|
|
110
|
+
return "\n".join(lines)
|