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,270 @@
1
+ """External dependencies: the third-party libraries a project pulls in.
2
+
3
+ The reading map and entry points tell a newcomer what to read and how to run the
4
+ code; this answers another practical first question — "what does this project
5
+ depend on, and what would I need to install?". It reads the dependency manifests
6
+ CodeABC already loaded (``requirements*.txt``, ``pyproject.toml``, ``setup.cfg``,
7
+ ``Pipfile``, ``package.json``) and lists the declared third-party packages,
8
+ marking each as runtime, development, or optional and noting which manifest it
9
+ came from.
10
+
11
+ This is deliberately distinct from the internal import/package graph: it is about
12
+ the *external* libraries the project installs, not how its own modules reference
13
+ each other. :func:`scan_dependencies` is pure over the file contents, so it is
14
+ unit-testable with plain strings and needs no repository or network.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import re
21
+
22
+ from backend.services import package_purposes
23
+
24
+ _RUNTIME = "runtime"
25
+ _DEV = "dev"
26
+ _OPTIONAL = "optional"
27
+
28
+ # Lower rank wins when the same package shows up in more than one role.
29
+ _KIND_RANK = {_RUNTIME: 0, _DEV: 1, _OPTIONAL: 2}
30
+ _KIND_LABEL = {_RUNTIME: "运行依赖", _DEV: "开发依赖", _OPTIONAL: "可选依赖"}
31
+
32
+ # A quoted string inside a TOML/JSON array.
33
+ _QUOTED_RE = re.compile(r"""['"]([^'"]+)['"]""")
34
+ # A `name = <value>` table entry (poetry / Pipfile style), one per line.
35
+ _TABLE_ENTRY_RE = re.compile(r"""(?m)^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*=\s*(.+?)\s*$""")
36
+ # A requirements filename: requirements.txt, requirements-dev.txt, ...
37
+ _REQUIREMENTS_RE = re.compile(r"requirements.*\.txt", re.I)
38
+
39
+
40
+ def _section(content: str, header: str) -> str:
41
+ """Text of a TOML/INI ``[header]`` section, until the next top-level header."""
42
+ start = content.find(f"[{header}]")
43
+ if start == -1:
44
+ return ""
45
+ rest = content[start + len(header) + 2 :]
46
+ end = re.search(r"(?m)^\s*\[", rest)
47
+ return rest[: end.start()] if end else rest
48
+
49
+
50
+ def _array_body(text: str, key: str) -> str:
51
+ """Raw body of a TOML ``key = [ ... ]`` array.
52
+
53
+ Walks to the matching ``]`` while tracking nesting and ignoring any brackets
54
+ that appear *inside* quoted strings — so an extras marker like
55
+ ``"requests[security]>=2"`` does not terminate the array early.
56
+ """
57
+ m = re.search(rf"(?m)^\s*{re.escape(key)}\s*=\s*\[", text)
58
+ if not m:
59
+ return ""
60
+ depth, quote, out, i = 1, "", [], m.end()
61
+ while i < len(text) and depth > 0:
62
+ c = text[i]
63
+ if quote:
64
+ if c == quote:
65
+ quote = ""
66
+ out.append(c)
67
+ elif c in "'\"":
68
+ quote = c
69
+ out.append(c)
70
+ elif c == "[":
71
+ depth += 1
72
+ out.append(c)
73
+ elif c == "]":
74
+ depth -= 1
75
+ if depth:
76
+ out.append(c)
77
+ else:
78
+ out.append(c)
79
+ i += 1
80
+ return "".join(out)
81
+
82
+
83
+ def _req_name_version(spec: str) -> tuple[str, str] | None:
84
+ """Parse a requirement string into ``(name, version_spec)``.
85
+
86
+ Returns ``None`` for anything that is not a package: blanks, comments, pip
87
+ options (``-r``, ``--index-url``), or a bare VCS/URL requirement with no
88
+ resolvable name.
89
+ """
90
+ spec = spec.split("#", 1)[0].strip()
91
+ if not spec or spec.startswith("-"):
92
+ return None
93
+ spec = spec.split(";", 1)[0].strip() # drop environment markers
94
+ m = re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*", spec)
95
+ if not m:
96
+ return None
97
+ name = m.group(0)
98
+ rest = re.sub(r"^\s*\[[^\]]*\]", "", spec[m.end() :]).strip() # drop extras
99
+ if " @ " in rest or "://" in rest: # "pkg @ git+https://..." has no clean version
100
+ rest = ""
101
+ return name, rest
102
+
103
+
104
+ def _from_requirements(content: str, kind: str) -> list[tuple[str, str, str]]:
105
+ deps = []
106
+ for line in content.splitlines():
107
+ parsed = _req_name_version(line)
108
+ if parsed:
109
+ deps.append((parsed[0], parsed[1], kind))
110
+ return deps
111
+
112
+
113
+ def _from_pyproject(content: str) -> list[tuple[str, str, str]]:
114
+ deps: list[tuple[str, str, str]] = []
115
+ # PEP 621: [project] dependencies = [...] and [project.optional-dependencies].
116
+ for spec in _QUOTED_RE.findall(_array_body(content, "dependencies")):
117
+ parsed = _req_name_version(spec)
118
+ if parsed:
119
+ deps.append((parsed[0], parsed[1], _RUNTIME))
120
+ for spec in _QUOTED_RE.findall(_section(content, "project.optional-dependencies")):
121
+ parsed = _req_name_version(spec)
122
+ if parsed:
123
+ deps.append((parsed[0], parsed[1], _OPTIONAL))
124
+ # Poetry: [tool.poetry.dependencies] uses `name = "^1.0"` table entries.
125
+ for header, kind in (
126
+ ("tool.poetry.dependencies", _RUNTIME),
127
+ ("tool.poetry.dev-dependencies", _DEV),
128
+ ("tool.poetry.group.dev.dependencies", _DEV),
129
+ ):
130
+ for name, raw in _TABLE_ENTRY_RE.findall(_section(content, header)):
131
+ if name == "python": # the interpreter constraint, not a dependency
132
+ continue
133
+ version = next(iter(_QUOTED_RE.findall(raw)), "")
134
+ deps.append((name, "" if version == "*" else version, kind))
135
+ return deps
136
+
137
+
138
+ def _from_setup_cfg(content: str) -> list[tuple[str, str, str]]:
139
+ block = _section(content, "options")
140
+ m = re.search(r"(?m)^install_requires\s*=(.*)$", block)
141
+ if not m:
142
+ return []
143
+ lines = block[m.start() :].splitlines()
144
+ items = [lines[0].split("=", 1)[1].strip()]
145
+ for line in lines[1:]:
146
+ if not line.strip():
147
+ continue
148
+ if line[0] in " \t": # indented continuation line
149
+ items.append(line.strip())
150
+ else: # a new dedented key ends the install_requires block
151
+ break
152
+ deps = []
153
+ for item in items:
154
+ parsed = _req_name_version(item)
155
+ if parsed:
156
+ deps.append((parsed[0], parsed[1], _RUNTIME))
157
+ return deps
158
+
159
+
160
+ def _from_pipfile(content: str) -> list[tuple[str, str, str]]:
161
+ deps = []
162
+ for header, kind in (("packages", _RUNTIME), ("dev-packages", _DEV)):
163
+ for name, raw in _TABLE_ENTRY_RE.findall(_section(content, header)):
164
+ version = next(iter(_QUOTED_RE.findall(raw)), "")
165
+ deps.append((name, "" if version == "*" else version, kind))
166
+ return deps
167
+
168
+
169
+ def _from_package_json(content: str) -> list[tuple[str, str, str]]:
170
+ try:
171
+ data = json.loads(content)
172
+ except (json.JSONDecodeError, ValueError):
173
+ return []
174
+ deps = []
175
+ for field, kind in (
176
+ ("dependencies", _RUNTIME),
177
+ ("devDependencies", _DEV),
178
+ ("optionalDependencies", _OPTIONAL),
179
+ ("peerDependencies", _OPTIONAL),
180
+ ):
181
+ block = data.get(field)
182
+ if isinstance(block, dict):
183
+ deps.extend((str(name), str(ver), kind) for name, ver in block.items())
184
+ return deps
185
+
186
+
187
+ def scan_dependencies(file_contents: dict[str, str], *, limit: int = 200) -> dict:
188
+ """Collect the project's declared third-party dependencies from its manifests.
189
+
190
+ Args:
191
+ file_contents: mapping of path to file text (as CodeABC already read it).
192
+ limit: how many dependencies to return in the ranked list.
193
+
194
+ Returns ``{"total", "dependencies", "manifests"}`` where each dependency is
195
+ ``{"name", "version", "kind", "manifest", "purpose"}`` and ``kind`` is one of
196
+ ``runtime``, ``dev`` or ``optional``. ``purpose`` is a plain-language note for
197
+ well-known packages (``None`` otherwise). A package seen in several roles is
198
+ kept once at its strongest role (runtime over dev over optional).
199
+ """
200
+ best: dict[str, dict] = {}
201
+ manifests: list[str] = []
202
+
203
+ def _add(name: str, version: str, kind: str, manifest: str) -> None:
204
+ key = name.lower()
205
+ existing = best.get(key)
206
+ if existing is None or _KIND_RANK[kind] < _KIND_RANK[existing["kind"]]:
207
+ best[key] = {
208
+ "name": name,
209
+ "version": version.strip(),
210
+ "kind": kind,
211
+ "manifest": manifest,
212
+ "purpose": package_purposes.explain_package(name),
213
+ }
214
+
215
+ for path, content in file_contents.items():
216
+ if not content:
217
+ continue
218
+ name = path.replace("\\", "/").rsplit("/", 1)[-1]
219
+ if _REQUIREMENTS_RE.fullmatch(name):
220
+ kind = _DEV if re.search(r"dev|test", name, re.I) else _RUNTIME
221
+ collected = _from_requirements(content, kind)
222
+ elif name == "pyproject.toml":
223
+ collected = _from_pyproject(content)
224
+ elif name == "setup.cfg":
225
+ collected = _from_setup_cfg(content)
226
+ elif name == "Pipfile":
227
+ collected = _from_pipfile(content)
228
+ elif name == "package.json":
229
+ collected = _from_package_json(content)
230
+ else:
231
+ continue
232
+ if collected:
233
+ manifests.append(name)
234
+ for dep_name, version, kind in collected:
235
+ _add(dep_name, version, kind, name)
236
+
237
+ deps = sorted(best.values(), key=lambda d: (_KIND_RANK[d["kind"]], d["name"].lower()))
238
+ return {
239
+ "total": len(deps),
240
+ "dependencies": deps[:limit],
241
+ "manifests": sorted(set(manifests)),
242
+ }
243
+
244
+
245
+ def render_dependencies_markdown(project_name: str, data: dict | None) -> str:
246
+ """Render the external dependencies as Markdown, or ``""`` if none were found."""
247
+ deps = (data or {}).get("dependencies") or []
248
+ if not deps:
249
+ return ""
250
+ manifests = (data or {}).get("manifests") or []
251
+ source = "、".join(f"`{m}`" for m in manifests)
252
+ lines = [
253
+ f"# {project_name} — 外部依赖清单(第三方库)",
254
+ "",
255
+ "> 这个项目要装哪些第三方库才能跑。运行依赖是跑起来必须的,开发依赖只在改代码/跑测试时用,"
256
+ f"可选依赖按需安装。来源:{source}。",
257
+ "",
258
+ ]
259
+ for kind in (_RUNTIME, _DEV, _OPTIONAL):
260
+ group = [d for d in deps if d["kind"] == kind]
261
+ if not group:
262
+ continue
263
+ lines.append(f"## {_KIND_LABEL[kind]}({len(group)})")
264
+ lines.append("")
265
+ for d in group:
266
+ version = f" `{d['version']}`" if d["version"] else ""
267
+ lines.append(f"- **{d['name']}**{version} — 来自 `{d['manifest']}`")
268
+ lines.append("")
269
+
270
+ return "\n".join(lines).rstrip() + "\n"
@@ -0,0 +1,255 @@
1
+ """Documentation coverage: which source files are written down, and which aren't.
2
+
3
+ Tests answer "is this code verified?"; this answers the sibling question a
4
+ non-programmer asks when inheriting a codebase: "is this code *explained*?".
5
+ A large source file with almost no comments or docstrings is the hardest to
6
+ read and the riskiest to change blind — and it's exactly the kind of thing that
7
+ doesn't show up in a test-coverage or churn report.
8
+
9
+ :func:`assess_doc_coverage` walks the already-read file contents, counts code
10
+ lines vs. comment/docstring lines per source file, and flags the ones that
11
+ carry real logic yet are effectively undocumented. The bigger a bare file is,
12
+ the higher it ranks: a 300-line module with no comments hurts more than a
13
+ 10-line helper.
14
+
15
+ Pure functions over the file-content map — no repo or git history needed, so it
16
+ is unit-testable with plain strings. The comment detection is a deterministic
17
+ heuristic (line-comment prefixes, C-style block comments, Python docstrings),
18
+ not a full parser; it aims to be honest about density, not byte-perfect.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ # Extension -> (line-comment prefix, has C-style /* */ blocks).
24
+ # Python is handled specially because triple-quoted docstrings count as docs.
25
+ _LINE_COMMENT: dict[str, str] = {
26
+ "py": "#",
27
+ "pyi": "#",
28
+ "js": "//",
29
+ "jsx": "//",
30
+ "ts": "//",
31
+ "tsx": "//",
32
+ "go": "//",
33
+ "java": "//",
34
+ "rs": "//",
35
+ "c": "//",
36
+ "h": "//",
37
+ "cpp": "//",
38
+ "hpp": "//",
39
+ "cc": "//",
40
+ "cs": "//",
41
+ "kt": "//",
42
+ "swift": "//",
43
+ "scala": "//",
44
+ }
45
+ _C_STYLE = {
46
+ "js",
47
+ "jsx",
48
+ "ts",
49
+ "tsx",
50
+ "go",
51
+ "java",
52
+ "rs",
53
+ "c",
54
+ "h",
55
+ "cpp",
56
+ "hpp",
57
+ "cc",
58
+ "cs",
59
+ "kt",
60
+ "swift",
61
+ "scala",
62
+ }
63
+ _PY = {"py", "pyi"}
64
+
65
+ # A file needs at least this many code lines before "no docs" is worth flagging;
66
+ # tiny files don't need a comment to be understood.
67
+ _MIN_CODE_LINES = 15
68
+ # At or below this comment-to-code ratio a file counts as "under-documented".
69
+ _UNDER_DOCUMENTED_RATIO = 0.05
70
+
71
+ _TEST_SEGMENTS = {"test", "tests", "__tests__", "spec", "specs"}
72
+ _SKIP_NAMES = {"__init__.py", "conftest.py", "setup.py"}
73
+
74
+
75
+ def _ext(path: str) -> str:
76
+ name = path.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
77
+ return name.rsplit(".", 1)[-1].lower() if "." in name else ""
78
+
79
+
80
+ def _is_test_or_skip(path: str) -> bool:
81
+ norm = path.replace("\\", "/")
82
+ segments = [s for s in norm.split("/") if s]
83
+ if any(s in _TEST_SEGMENTS for s in segments):
84
+ return True
85
+ name = segments[-1] if segments else ""
86
+ if name in _SKIP_NAMES:
87
+ return True
88
+ stem = name.rsplit(".", 1)[0] if "." in name else name
89
+ # test_x.py / x_test.go / x.test.ts / x.spec.ts naming conventions
90
+ return stem.startswith("test_") or stem.endswith("_test") or ".test" in name or ".spec" in name
91
+
92
+
93
+ def _count_lines(content: str, ext: str) -> tuple[int, int]:
94
+ """Return (code_lines, comment_lines) for one source file."""
95
+ line_token = _LINE_COMMENT.get(ext, "")
96
+ c_style = ext in _C_STYLE
97
+ py = ext in _PY
98
+
99
+ code = 0
100
+ comment = 0
101
+ in_block = False # C-style /* */
102
+ in_doc = False # Python triple-quote docstring
103
+ doc_delim = ""
104
+
105
+ for raw in content.splitlines():
106
+ line = raw.strip()
107
+ if not line:
108
+ continue
109
+
110
+ if in_block:
111
+ comment += 1
112
+ if "*/" in line:
113
+ in_block = False
114
+ continue
115
+
116
+ if in_doc:
117
+ comment += 1
118
+ if doc_delim in line:
119
+ in_doc = False
120
+ continue
121
+
122
+ # Python docstring / triple-quoted block.
123
+ if py and (line.startswith('"""') or line.startswith("'''")):
124
+ doc_delim = line[:3]
125
+ comment += 1
126
+ # A one-line """doc""" opens and closes on the same line.
127
+ if line.count(doc_delim) < 2:
128
+ in_doc = True
129
+ continue
130
+
131
+ # C-style block comment start.
132
+ if c_style and line.startswith("/*"):
133
+ comment += 1
134
+ if "*/" not in line:
135
+ in_block = True
136
+ continue
137
+
138
+ # Whole-line // or # comment.
139
+ if line_token and line.startswith(line_token):
140
+ comment += 1
141
+ continue
142
+
143
+ code += 1
144
+
145
+ return code, comment
146
+
147
+
148
+ def _reason(code: int, ratio: int) -> str:
149
+ if ratio == 0:
150
+ return f"{code} 行代码、几乎没有注释或文档,读懂全靠猜,改动前最该补一句说明它在做什么。"
151
+ return f"{code} 行代码只有约 {ratio}% 的注释/文档,核心逻辑缺解释,新人上手成本高。"
152
+
153
+
154
+ def assess_doc_coverage(file_contents: dict[str, str], *, limit: int = 12) -> dict:
155
+ """Assess documentation density across source files.
156
+
157
+ Args:
158
+ file_contents: mapping of path to file text (as CodeABC already read it).
159
+ limit: how many under-documented files to return in the ranked list.
160
+
161
+ Returns::
162
+
163
+ {
164
+ "total_source_files": int,
165
+ "documented_files": int,
166
+ "undocumented_files": int,
167
+ "doc_percent": int, # 0-100, documented / total source files
168
+ "under_documented": [ # ranked by code size (biggest first)
169
+ {"path", "code_lines", "comment_lines", "ratio", "reason"}, ...
170
+ ],
171
+ "notes": [str, ...],
172
+ }
173
+ """
174
+ total = 0
175
+ documented = 0
176
+ under: list[dict] = []
177
+
178
+ for path, content in file_contents.items():
179
+ if not content:
180
+ continue
181
+ ext = _ext(path)
182
+ if ext not in _LINE_COMMENT or _is_test_or_skip(path):
183
+ continue
184
+
185
+ code, comment = _count_lines(content, ext)
186
+ if code == 0:
187
+ continue
188
+ total += 1
189
+ ratio = comment / (code + comment) if (code + comment) else 0.0
190
+ if ratio > _UNDER_DOCUMENTED_RATIO:
191
+ documented += 1
192
+ if code >= _MIN_CODE_LINES and ratio <= _UNDER_DOCUMENTED_RATIO:
193
+ pct = int(round(ratio * 100))
194
+ under.append(
195
+ {
196
+ "path": path,
197
+ "code_lines": code,
198
+ "comment_lines": comment,
199
+ "ratio": pct,
200
+ "reason": _reason(code, pct),
201
+ }
202
+ )
203
+
204
+ under.sort(key=lambda f: (-f["code_lines"], f["path"]))
205
+ undocumented = total - documented
206
+ doc_percent = int(round(documented / total * 100)) if total else 0
207
+
208
+ notes: list[str] = []
209
+ if total == 0:
210
+ notes.append("没有发现可统计的源代码文件。")
211
+ elif not under:
212
+ notes.append("核心源文件都带了一定注释/文档,文档基线不错。")
213
+
214
+ return {
215
+ "total_source_files": total,
216
+ "documented_files": documented,
217
+ "undocumented_files": undocumented,
218
+ "doc_percent": doc_percent,
219
+ "under_documented": under[:limit],
220
+ "notes": notes,
221
+ }
222
+
223
+
224
+ def render_doc_coverage_markdown(project_name: str, data: dict | None) -> str:
225
+ """Render the documentation-coverage map as a Markdown section, or ``""``."""
226
+ d = data or {}
227
+ total = d.get("total_source_files", 0)
228
+ if not total:
229
+ return ""
230
+
231
+ lines = [
232
+ f"# {project_name} — 文档覆盖",
233
+ "",
234
+ f"> {total} 个源文件里约 {d.get('doc_percent', 0)}% 带有注释/文档。"
235
+ "下面这些是「代码不少、却几乎没解释」的文件,对刚接手的人最难读、改起来最容易踩坑。",
236
+ "",
237
+ ]
238
+
239
+ under = d.get("under_documented") or []
240
+ if not under:
241
+ lines.append("核心源文件都带了一定注释,没有明显「裸奔」的大文件。")
242
+ return "\n".join(lines).rstrip() + "\n"
243
+
244
+ lines.append("## 最该补文档的文件")
245
+ lines.append("")
246
+ for entry in under:
247
+ lines.append(f"- `{entry['path']}` — {entry['reason']}")
248
+
249
+ notes = d.get("notes") or []
250
+ if notes:
251
+ lines += ["", "## 注意事项", ""]
252
+ for n in notes:
253
+ lines.append(f"- {n}")
254
+
255
+ return "\n".join(lines).rstrip() + "\n"
@@ -0,0 +1,163 @@
1
+ """API documentation coverage: which public functions and classes have no docstring.
2
+
3
+ ``docs`` measures comment *density* — how many comment/docstring lines a file
4
+ carries relative to its code. This asks a different, sharper question: of the
5
+ things a reader actually calls — the public functions, classes and methods that
6
+ make up a module's surface — how many explain themselves with a docstring?
7
+
8
+ For someone learning a codebase, an undocumented public API is the wall they hit
9
+ first: they can see ``def parse_manifest(path)`` but not what it expects or
10
+ returns. This ranks the files whose public surface is least documented, so the
11
+ gaps a newcomer will trip over are visible at a glance. It approximates the
12
+ ``interrogate`` metric from the Python AST: a symbol is "documented" when
13
+ ``ast.get_docstring`` finds a string, and only public names (no leading
14
+ underscore) count, since dunders and private helpers are rarely self-documented
15
+ by contract. ``scan_docstring_coverage`` is pure over the file contents and
16
+ parses with the standard library, so it needs no repository; non-Python files,
17
+ test files, and files that don't parse are skipped.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import ast
23
+
24
+ _MISSING_SAMPLE = 5 # how many undocumented symbol names to surface per file
25
+
26
+
27
+ def _is_public(name: str) -> bool:
28
+ """Public API names carry no leading underscore (skips dunders and privates)."""
29
+ return not name.startswith("_")
30
+
31
+
32
+ def _is_test_path(path: str) -> bool:
33
+ """Docstrings on test functions are noise, so tests are left out of the metric."""
34
+ lower = path.lower()
35
+ base = lower.rsplit("/", 1)[-1]
36
+ return (
37
+ "/tests/" in lower
38
+ or "/test/" in lower
39
+ or base.startswith("test_")
40
+ or base.endswith("_test.py")
41
+ or base == "conftest.py"
42
+ )
43
+
44
+
45
+ def _scan_module(tree: ast.Module) -> tuple[int, int, list[str]]:
46
+ """Return (documentable, documented, undocumented_names) for a parsed module.
47
+
48
+ Counts public top-level functions and classes plus public methods of public
49
+ classes. Walks only the module and class bodies rather than ``ast.walk`` so
50
+ nested/local helpers — which are not part of the callable surface — are not
51
+ counted.
52
+ """
53
+ documentable = 0
54
+ documented = 0
55
+ missing: list[str] = []
56
+
57
+ def _record(has_doc: bool, label: str) -> None:
58
+ nonlocal documentable, documented
59
+ documentable += 1
60
+ if has_doc:
61
+ documented += 1
62
+ else:
63
+ missing.append(label)
64
+
65
+ for node in tree.body:
66
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) and _is_public(node.name):
67
+ _record(ast.get_docstring(node) is not None, node.name)
68
+ elif isinstance(node, ast.ClassDef) and _is_public(node.name):
69
+ _record(ast.get_docstring(node) is not None, node.name)
70
+ for sub in node.body:
71
+ if isinstance(sub, ast.FunctionDef | ast.AsyncFunctionDef) and _is_public(sub.name):
72
+ _record(ast.get_docstring(sub) is not None, f"{node.name}.{sub.name}")
73
+
74
+ return documentable, documented, missing
75
+
76
+
77
+ def scan_docstring_coverage(file_contents: dict[str, str], *, limit: int = 15) -> dict:
78
+ """Rank the project's Python files by how much of their public API lacks docstrings.
79
+
80
+ Args:
81
+ file_contents: mapping of path to file text (as CodeABC already read it).
82
+ limit: how many of the least-documented files to return.
83
+
84
+ Returns ``{"total_symbols", "documented", "coverage", "files"}`` where
85
+ ``coverage`` is the repo-wide documented fraction (0.0-1.0, and 1.0 when there
86
+ is no public API to document) and ``files`` are the files with the most
87
+ undocumented public symbols (descending), each ``{"path", "symbols",
88
+ "documented", "coverage", "missing"}``.
89
+ """
90
+ total_symbols = 0
91
+ total_documented = 0
92
+ scored: list[dict] = []
93
+
94
+ for path, content in file_contents.items():
95
+ if not content or not path.endswith(".py") or _is_test_path(path):
96
+ continue
97
+ try:
98
+ tree = ast.parse(content)
99
+ except (SyntaxError, ValueError):
100
+ continue
101
+
102
+ documentable, documented, missing = _scan_module(tree)
103
+ if documentable == 0:
104
+ continue # no public surface to document
105
+
106
+ total_symbols += documentable
107
+ total_documented += documented
108
+ if documented >= documentable:
109
+ continue # fully documented, nothing to flag
110
+
111
+ scored.append(
112
+ {
113
+ "path": path,
114
+ "symbols": documentable,
115
+ "documented": documented,
116
+ "coverage": round(documented / documentable, 3),
117
+ "missing": missing[:_MISSING_SAMPLE],
118
+ }
119
+ )
120
+
121
+ coverage = round(total_documented / total_symbols, 3) if total_symbols else 1.0
122
+ # Worst first: most undocumented symbols, then lowest coverage, then path.
123
+ scored.sort(key=lambda f: (-(f["symbols"] - f["documented"]), f["coverage"], f["path"]))
124
+ return {
125
+ "total_symbols": total_symbols,
126
+ "documented": total_documented,
127
+ "coverage": coverage,
128
+ "files": scored[:limit],
129
+ }
130
+
131
+
132
+ def render_docstring_coverage_markdown(project_name: str, data: dict | None) -> str:
133
+ """Render the docstring-coverage ranking as Markdown, or ``""`` if nothing to report."""
134
+ data = data or {}
135
+ total = data.get("total_symbols") or 0
136
+ if not total:
137
+ return ""
138
+
139
+ coverage_pct = round((data.get("coverage") or 0.0) * 100)
140
+ documented = data.get("documented") or 0
141
+ files = data.get("files") or []
142
+
143
+ lines = [
144
+ f"# {project_name} — API 文档覆盖率(哪些公开接口没写文档字符串)",
145
+ "",
146
+ "> 统计每个文件里「别人会调用」的公开函数、类、方法中有多少写了 docstring。"
147
+ "这和「注释密度」不同——密度看整体注释多少,这里只看**对外接口**是否自解释,"
148
+ "没文档的公开接口正是新人最先撞墙的地方。私有成员和 dunder(下划线开头)不计。",
149
+ "",
150
+ f"**整体覆盖率:{coverage_pct}%**({documented}/{total} 个公开接口有文档字符串)。",
151
+ "",
152
+ ]
153
+ if files:
154
+ lines.append("公开接口文档最缺的文件:")
155
+ lines.append("")
156
+ for f in files:
157
+ missing = "、".join(f["missing"])
158
+ more = "…" if f["symbols"] - f["documented"] > len(f["missing"]) else ""
159
+ lines.append(
160
+ f"- `{f['path']}` — {f['documented']}/{f['symbols']} 有文档"
161
+ f"({round(f['coverage'] * 100)}%),缺:{missing}{more}"
162
+ )
163
+ return "\n".join(lines).rstrip() + "\n"