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.
Files changed (62) hide show
  1. backend/__init__.py +0 -0
  2. backend/app.py +95 -0
  3. backend/desktop_server.py +46 -0
  4. backend/models.py +629 -0
  5. backend/prompts/__init__.py +0 -0
  6. backend/prompts/annotate.py +44 -0
  7. backend/prompts/edit.py +62 -0
  8. backend/prompts/overview.py +57 -0
  9. backend/prompts/qa.py +45 -0
  10. backend/routers/__init__.py +0 -0
  11. backend/routers/analyze.py +271 -0
  12. backend/routers/project.py +872 -0
  13. backend/services/__init__.py +0 -0
  14. backend/services/action_plan.py +366 -0
  15. backend/services/activity.py +313 -0
  16. backend/services/apimap.py +391 -0
  17. backend/services/async_surface.py +123 -0
  18. backend/services/cache.py +183 -0
  19. backend/services/churn.py +244 -0
  20. backend/services/ci_checks.py +317 -0
  21. backend/services/codemap_export.py +136 -0
  22. backend/services/commands.py +276 -0
  23. backend/services/complexity.py +111 -0
  24. backend/services/config_files.py +190 -0
  25. backend/services/contributing.py +308 -0
  26. backend/services/coverage.py +283 -0
  27. backend/services/datamodels.py +190 -0
  28. backend/services/deep_nesting.py +110 -0
  29. backend/services/dependencies.py +270 -0
  30. backend/services/docs.py +255 -0
  31. backend/services/docstrings.py +163 -0
  32. backend/services/duplicate_code.py +165 -0
  33. backend/services/entrypoints.py +152 -0
  34. backend/services/envscan.py +167 -0
  35. backend/services/error_handling.py +239 -0
  36. backend/services/filenames.py +261 -0
  37. backend/services/github_clone.py +129 -0
  38. backend/services/glossary.py +96 -0
  39. backend/services/health_score.py +381 -0
  40. backend/services/importgraph.py +986 -0
  41. backend/services/integrations.py +263 -0
  42. backend/services/licenses.py +505 -0
  43. backend/services/llm.py +117 -0
  44. backend/services/long_functions.py +101 -0
  45. backend/services/ownership.py +194 -0
  46. backend/services/package_purposes.py +112 -0
  47. backend/services/pr_reading.py +181 -0
  48. backend/services/release_map.py +402 -0
  49. backend/services/report_export.py +232 -0
  50. backend/services/risk.py +88 -0
  51. backend/services/scanner.py +546 -0
  52. backend/services/schedules.py +369 -0
  53. backend/services/security.py +445 -0
  54. backend/services/settings_map.py +234 -0
  55. backend/services/symbols.py +605 -0
  56. backend/services/techdebt.py +124 -0
  57. backend/services/too_many_params.py +102 -0
  58. backend/services/typing_coverage.py +184 -0
  59. codeabc-0.1.0.dist-info/METADATA +283 -0
  60. codeabc-0.1.0.dist-info/RECORD +62 -0
  61. codeabc-0.1.0.dist-info/WHEEL +4 -0
  62. 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)