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,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"
|
backend/services/docs.py
ADDED
|
@@ -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"
|