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,402 @@
|
|
|
1
|
+
"""Release & versioning map — how this project ships new versions, and how you
|
|
2
|
+
would know what changed.
|
|
3
|
+
|
|
4
|
+
The CI map answers "what gets checked before my change is let in"; the
|
|
5
|
+
scheduled-tasks map answers "what runs on a timer". This answers the question a
|
|
6
|
+
newcomer asks while sizing up an unfamiliar project: **is this thing alive, what
|
|
7
|
+
version am I looking at, and when the authors ship something new, where do I look
|
|
8
|
+
to see what changed and how do I get it?** Three plain facts settle most of that
|
|
9
|
+
worry — the current version number, whether there is a changelog, and how a new
|
|
10
|
+
release actually goes out — and all three are sitting in files CodeABC already
|
|
11
|
+
read.
|
|
12
|
+
|
|
13
|
+
It is pure over the file contents — no LLM, nothing run — and recognises the
|
|
14
|
+
version sources and release pipelines people reach for most:
|
|
15
|
+
|
|
16
|
+
current version pyproject.toml / package.json / Cargo.toml / setup.py /
|
|
17
|
+
setup.cfg / a VERSION file / ``__version__`` / pom.xml /
|
|
18
|
+
build.gradle
|
|
19
|
+
changelog CHANGELOG / HISTORY / NEWS / RELEASES (+ "Keep a Changelog")
|
|
20
|
+
release pipeline GitHub Actions / GitLab CI publishing to PyPI, npm, a GitHub
|
|
21
|
+
Release, crates.io, RubyGems, or a Docker image — on a tag
|
|
22
|
+
push or a published release
|
|
23
|
+
|
|
24
|
+
Every term is glossed in plain Chinese (语义化版本 / 日历版本 / 更新日志 /
|
|
25
|
+
推送 git tag 时自动发布) so a non-programmer can read the row without knowing what
|
|
26
|
+
semver or ``twine`` are.
|
|
27
|
+
|
|
28
|
+
:func:`find_release_info` is pure over the file contents, so it is unit-testable
|
|
29
|
+
with plain strings and needs no repository.
|
|
30
|
+
|
|
31
|
+
Limitations (kept honest on purpose):
|
|
32
|
+
|
|
33
|
+
* Signature based, not a build-system evaluation. A version computed at build
|
|
34
|
+
time (``setuptools-scm``, ``hatch-vcs``) is reported as "derived from git
|
|
35
|
+
tags" rather than resolved to a number; an exotic manifest or a hand-rolled
|
|
36
|
+
release shell script this module does not recognise is missed.
|
|
37
|
+
* Only the first, most authoritative version source is reported as *the*
|
|
38
|
+
version, so a repository that pins different numbers in different manifests
|
|
39
|
+
shows the highest-priority one, not all of them.
|
|
40
|
+
* Whether a changelog is actually kept current is not judged — only that one
|
|
41
|
+
exists and roughly what shape it takes.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import re
|
|
47
|
+
|
|
48
|
+
# --- versioning scheme ------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
_PRERELEASE_RE = re.compile(r"(?:\d|[-._])(rc|alpha|beta|preview|pre|dev|nightly|a|b|c)\d*\b", re.I)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _classify_scheme(version: str) -> tuple[str, str]:
|
|
54
|
+
"""Return ``(scheme, plain-Chinese label)`` for a version string."""
|
|
55
|
+
v = version.strip().lstrip("vV")
|
|
56
|
+
if not v:
|
|
57
|
+
return "", ""
|
|
58
|
+
if "-" in v or "+" in v or _PRERELEASE_RE.search(v):
|
|
59
|
+
return "prerelease", "预发布版本(带 rc/beta/dev 等后缀,还不是正式版)"
|
|
60
|
+
if re.match(r"^(19|20)\d\d[._-]\d", v):
|
|
61
|
+
return "calver", "日历版本(用年份/日期编号,而不是主次修订)"
|
|
62
|
+
if re.match(r"^\d+\.\d+\.\d+$", v):
|
|
63
|
+
if v.startswith("0."):
|
|
64
|
+
return "zerover", "0.x 早期版本(还没到 1.0,接口可能随时变动)"
|
|
65
|
+
return "semver", "语义化版本(主版本.次版本.修订号)"
|
|
66
|
+
if re.match(r"^\d+\.\d+$", v):
|
|
67
|
+
return "twopart", "两段版本号(主版本.次版本)"
|
|
68
|
+
if re.match(r"^\d+$", v):
|
|
69
|
+
return "single", "单一递增版本号"
|
|
70
|
+
return "other", "自定义版本号格式"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# --- current version --------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
_DYNAMIC_RE = re.compile(r"dynamic\s*=\s*\[[^\]]*[\"']version[\"']", re.I)
|
|
76
|
+
_SCM_RE = re.compile(r"setuptools[_-]scm|hatch-vcs|versioningit|dunamai", re.I)
|
|
77
|
+
|
|
78
|
+
_SOURCE_LABEL = {
|
|
79
|
+
"pyproject": "pyproject.toml",
|
|
80
|
+
"package-json": "package.json",
|
|
81
|
+
"cargo": "Cargo.toml",
|
|
82
|
+
"setup-py": "setup.py",
|
|
83
|
+
"setup-cfg": "setup.cfg",
|
|
84
|
+
"version-file": "VERSION 文件",
|
|
85
|
+
"dunder": "代码里的 __version__",
|
|
86
|
+
"pom": "pom.xml",
|
|
87
|
+
"gradle": "build.gradle",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Most authoritative manifest first.
|
|
91
|
+
_SOURCE_PRIORITY = (
|
|
92
|
+
"pyproject",
|
|
93
|
+
"package-json",
|
|
94
|
+
"cargo",
|
|
95
|
+
"setup-py",
|
|
96
|
+
"setup-cfg",
|
|
97
|
+
"version-file",
|
|
98
|
+
"dunder",
|
|
99
|
+
"pom",
|
|
100
|
+
"gradle",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _version_from_file(base: str, content: str) -> tuple[str, str] | None:
|
|
105
|
+
"""Return ``(kind, version)`` for a recognised manifest, else ``None``."""
|
|
106
|
+
if base == "pyproject.toml":
|
|
107
|
+
m = re.search(r"(?m)^\s*version\s*=\s*[\"']([0-9][^\"']*)[\"']", content)
|
|
108
|
+
return ("pyproject", m.group(1)) if m else None
|
|
109
|
+
if base == "package.json":
|
|
110
|
+
m = re.search(r"\"version\"\s*:\s*\"([0-9][^\"]*)\"", content)
|
|
111
|
+
return ("package-json", m.group(1)) if m else None
|
|
112
|
+
if base == "cargo.toml":
|
|
113
|
+
m = re.search(r"(?ms)^\[package\].*?^\s*version\s*=\s*\"([0-9][^\"]*)\"", content)
|
|
114
|
+
return ("cargo", m.group(1)) if m else None
|
|
115
|
+
if base == "setup.py":
|
|
116
|
+
m = re.search(r"version\s*=\s*[\"']([0-9][^\"']*)[\"']", content)
|
|
117
|
+
return ("setup-py", m.group(1)) if m else None
|
|
118
|
+
if base == "setup.cfg":
|
|
119
|
+
m = re.search(r"(?m)^\s*version\s*=\s*([0-9]\S*)\s*$", content)
|
|
120
|
+
return ("setup-cfg", m.group(1)) if m else None
|
|
121
|
+
if base in ("version", "version.txt"):
|
|
122
|
+
first = content.strip().splitlines()[0].strip() if content.strip() else ""
|
|
123
|
+
return ("version-file", first.lstrip("vV")) if re.match(r"^v?[0-9]", first) else None
|
|
124
|
+
if base == "pom.xml":
|
|
125
|
+
m = re.search(r"<version>\s*([0-9][^<]*)</version>", content)
|
|
126
|
+
return ("pom", m.group(1).strip()) if m else None
|
|
127
|
+
if base in ("build.gradle", "build.gradle.kts"):
|
|
128
|
+
m = re.search(r"(?m)^\s*version\s*=?\s*[\"']([0-9][^\"']*)[\"']", content)
|
|
129
|
+
return ("gradle", m.group(1)) if m else None
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _find_version(file_contents: dict[str, str]) -> tuple[str, str, str, bool]:
|
|
134
|
+
"""Return ``(version, source_path, source_kind, dynamic_from_vcs)``."""
|
|
135
|
+
dynamic = False
|
|
136
|
+
candidates: dict[str, tuple[str, str]] = {} # kind -> (version, path)
|
|
137
|
+
|
|
138
|
+
for path, content in file_contents.items():
|
|
139
|
+
if not content:
|
|
140
|
+
continue
|
|
141
|
+
base = path.rsplit("/", 1)[-1].lower()
|
|
142
|
+
|
|
143
|
+
if base == "pyproject.toml" and (_DYNAMIC_RE.search(content) or _SCM_RE.search(content)):
|
|
144
|
+
dynamic = True
|
|
145
|
+
|
|
146
|
+
hit = _version_from_file(base, content)
|
|
147
|
+
if hit and hit[0] not in candidates:
|
|
148
|
+
candidates[hit[0]] = (hit[1], path)
|
|
149
|
+
|
|
150
|
+
if base.endswith(".py") and "dunder" not in candidates:
|
|
151
|
+
m = re.search(r"(?m)^__version__\s*=\s*[\"']([0-9][^\"']*)[\"']", content)
|
|
152
|
+
if m:
|
|
153
|
+
candidates["dunder"] = (m.group(1), path)
|
|
154
|
+
|
|
155
|
+
for kind in _SOURCE_PRIORITY:
|
|
156
|
+
if kind in candidates:
|
|
157
|
+
version, path = candidates[kind]
|
|
158
|
+
return version, path, kind, dynamic
|
|
159
|
+
return "", "", "", dynamic
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# --- changelog --------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
_CHANGELOG_NAMES = frozenset(
|
|
165
|
+
{
|
|
166
|
+
"changelog",
|
|
167
|
+
"changelog.md",
|
|
168
|
+
"changelog.rst",
|
|
169
|
+
"changelog.txt",
|
|
170
|
+
"changes",
|
|
171
|
+
"changes.md",
|
|
172
|
+
"changes.rst",
|
|
173
|
+
"history.md",
|
|
174
|
+
"history.rst",
|
|
175
|
+
"history.txt",
|
|
176
|
+
"news",
|
|
177
|
+
"news.md",
|
|
178
|
+
"news.rst",
|
|
179
|
+
"releases.md",
|
|
180
|
+
"release-notes.md",
|
|
181
|
+
"release_notes.md",
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _find_changelog(file_contents: dict[str, str]) -> tuple[str, str, str]:
|
|
187
|
+
"""Return ``(path, style, plain-Chinese label)`` for the shallowest changelog."""
|
|
188
|
+
best: tuple[int, str, str] | None = None
|
|
189
|
+
for path, content in file_contents.items():
|
|
190
|
+
base = path.rsplit("/", 1)[-1].lower()
|
|
191
|
+
if base not in _CHANGELOG_NAMES:
|
|
192
|
+
continue
|
|
193
|
+
depth = path.count("/")
|
|
194
|
+
if best is None or depth < best[0]:
|
|
195
|
+
best = (depth, path, content or "")
|
|
196
|
+
if best is None:
|
|
197
|
+
return "", "none", ""
|
|
198
|
+
_, path, content = best
|
|
199
|
+
low = content.lower()
|
|
200
|
+
if "keep a changelog" in low or "keepachangelog.com" in low or "## [unreleased]" in low:
|
|
201
|
+
label = "遵循 Keep a Changelog 规范:按版本分节,最上面是还没发布的改动"
|
|
202
|
+
return path, "keepachangelog", label
|
|
203
|
+
if re.search(r"(?m)^#{1,3}\s*\[?v?\d+\.\d+", content):
|
|
204
|
+
return path, "versioned", "按版本号分节,记录每个版本改了什么"
|
|
205
|
+
return path, "freeform", "有更新日志文件,但不是标准的分节格式"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# --- release automation -----------------------------------------------------
|
|
209
|
+
|
|
210
|
+
_PUBLISH_SIGNS = (
|
|
211
|
+
(
|
|
212
|
+
re.compile(
|
|
213
|
+
r"gh-action-pypi-publish|twine\s+upload|poetry\s+publish|flit\s+publish|hatch\s+publish",
|
|
214
|
+
re.I,
|
|
215
|
+
),
|
|
216
|
+
"PyPI",
|
|
217
|
+
),
|
|
218
|
+
(re.compile(r"npm\s+publish|npm-publish|yarn\s+publish|pnpm\s+publish", re.I), "npm"),
|
|
219
|
+
(
|
|
220
|
+
re.compile(
|
|
221
|
+
r"action-gh-release|actions/create-release|gh\s+release\s+create|release-action",
|
|
222
|
+
re.I,
|
|
223
|
+
),
|
|
224
|
+
"GitHub Release",
|
|
225
|
+
),
|
|
226
|
+
(re.compile(r"cargo\s+publish", re.I), "crates.io"),
|
|
227
|
+
(re.compile(r"gem\s+push|setup-ruby.*gem|rubygems", re.I), "RubyGems"),
|
|
228
|
+
(re.compile(r"docker/build-push-action|docker\s+push", re.I), "Docker 镜像"),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
_SYSTEM_LABEL = {"github-actions": "GitHub Actions", "gitlab-ci": "GitLab CI"}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _release_system(path: str) -> str | None:
|
|
235
|
+
p = path.lower()
|
|
236
|
+
if p.startswith(".github/workflows/") and p.endswith((".yml", ".yaml")):
|
|
237
|
+
return "github-actions"
|
|
238
|
+
if p.endswith(".gitlab-ci.yml") or p == ".gitlab-ci.yml":
|
|
239
|
+
return "gitlab-ci"
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _release_trigger(content: str) -> tuple[str, str]:
|
|
244
|
+
"""Plain-Chinese gloss of what kicks off a release pipeline."""
|
|
245
|
+
if re.search(r"(?m)^\s*release\s*:", content) and re.search(r"types\s*:", content):
|
|
246
|
+
return "release", "发布一个 GitHub Release 时"
|
|
247
|
+
if re.search(r"(?m)^\s*tags\s*:", content) or re.search(r"push:\s*\n\s*tags", content):
|
|
248
|
+
return "tag-push", "推送 git tag 时"
|
|
249
|
+
if re.search(r"(?m)^\s*workflow_dispatch\s*:", content):
|
|
250
|
+
return "manual", "维护者手动触发"
|
|
251
|
+
return "", ""
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _line_of(content: str, pos: int) -> int:
|
|
255
|
+
return content.count("\n", 0, pos) + 1
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _build_notes(
|
|
259
|
+
version: str, vkind: str, dynamic: bool, cl_style: str, publish_targets: list[str]
|
|
260
|
+
) -> list[str]:
|
|
261
|
+
notes: list[str] = []
|
|
262
|
+
if version:
|
|
263
|
+
notes.append(f"当前版本号 {version},来自 {_SOURCE_LABEL.get(vkind, vkind)}。")
|
|
264
|
+
elif dynamic:
|
|
265
|
+
notes.append(
|
|
266
|
+
"版本号在打包时由 git tag 自动推导"
|
|
267
|
+
"(setuptools-scm / hatch-vcs 一类),仓库里没有写死的数字。"
|
|
268
|
+
)
|
|
269
|
+
else:
|
|
270
|
+
notes.append("没找到写明的版本号,可能还没开始正式做版本管理,或用了本工具不认识的方式。")
|
|
271
|
+
if cl_style == "none":
|
|
272
|
+
notes.append("没有更新日志文件,想知道两版之间改了什么,得去翻提交记录或 Release 页面。")
|
|
273
|
+
if publish_targets:
|
|
274
|
+
notes.append(
|
|
275
|
+
"发布渠道:"
|
|
276
|
+
+ "、".join(publish_targets)
|
|
277
|
+
+ "(由 CI 自动完成,维护者一般只需打个版本标签)。"
|
|
278
|
+
)
|
|
279
|
+
return notes
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def find_release_info(file_contents: dict[str, str]) -> dict:
|
|
283
|
+
"""Map how a project versions and ships itself.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
file_contents: mapping of path to file text (as CodeABC already read it).
|
|
287
|
+
|
|
288
|
+
Returns ``{"version", "version_source", "version_source_kind",
|
|
289
|
+
"dynamic_from_vcs", "scheme", "scheme_zh", "changelog_path",
|
|
290
|
+
"changelog_style", "changelog_style_zh", "automation", "publish_targets",
|
|
291
|
+
"notes"}`` where each automation entry is
|
|
292
|
+
``{"trigger", "trigger_zh", "target", "path", "line"}``.
|
|
293
|
+
"""
|
|
294
|
+
version, vsource, vkind, dynamic = _find_version(file_contents)
|
|
295
|
+
scheme, scheme_zh = _classify_scheme(version)
|
|
296
|
+
cl_path, cl_style, cl_zh = _find_changelog(file_contents)
|
|
297
|
+
|
|
298
|
+
automation: list[dict] = []
|
|
299
|
+
seen: set[tuple] = set()
|
|
300
|
+
for path, content in file_contents.items():
|
|
301
|
+
if not content or _release_system(path) is None:
|
|
302
|
+
continue
|
|
303
|
+
trigger, trigger_zh = _release_trigger(content)
|
|
304
|
+
for rx, target in _PUBLISH_SIGNS:
|
|
305
|
+
match = rx.search(content)
|
|
306
|
+
if not match:
|
|
307
|
+
continue
|
|
308
|
+
key = (path, target)
|
|
309
|
+
if key in seen:
|
|
310
|
+
continue
|
|
311
|
+
seen.add(key)
|
|
312
|
+
automation.append(
|
|
313
|
+
{
|
|
314
|
+
"trigger": trigger,
|
|
315
|
+
"trigger_zh": trigger_zh,
|
|
316
|
+
"target": target,
|
|
317
|
+
"path": path,
|
|
318
|
+
"line": _line_of(content, match.start()),
|
|
319
|
+
}
|
|
320
|
+
)
|
|
321
|
+
automation.sort(key=lambda a: (a["path"], a["line"]))
|
|
322
|
+
|
|
323
|
+
publish_targets: list[str] = []
|
|
324
|
+
for entry in automation:
|
|
325
|
+
if entry["target"] not in publish_targets:
|
|
326
|
+
publish_targets.append(entry["target"])
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
"version": version,
|
|
330
|
+
"version_source": vsource,
|
|
331
|
+
"version_source_kind": vkind,
|
|
332
|
+
"dynamic_from_vcs": dynamic,
|
|
333
|
+
"scheme": scheme,
|
|
334
|
+
"scheme_zh": scheme_zh,
|
|
335
|
+
"changelog_path": cl_path,
|
|
336
|
+
"changelog_style": cl_style,
|
|
337
|
+
"changelog_style_zh": cl_zh,
|
|
338
|
+
"automation": automation,
|
|
339
|
+
"publish_targets": publish_targets,
|
|
340
|
+
"notes": _build_notes(version, vkind, dynamic, cl_style, publish_targets),
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def render_release_markdown(project_name: str, data: dict | None) -> str:
|
|
345
|
+
"""Render the release & versioning map as Markdown, or ``""`` if empty."""
|
|
346
|
+
data = data or {}
|
|
347
|
+
version = data.get("version") or ""
|
|
348
|
+
dynamic = bool(data.get("dynamic_from_vcs"))
|
|
349
|
+
changelog_path = data.get("changelog_path") or ""
|
|
350
|
+
automation = data.get("automation") or []
|
|
351
|
+
if not version and not dynamic and not changelog_path and not automation:
|
|
352
|
+
return ""
|
|
353
|
+
|
|
354
|
+
lines = [
|
|
355
|
+
f"# {project_name} — 版本与发布地图",
|
|
356
|
+
"",
|
|
357
|
+
"> 想知道这个项目“现在是第几版、出新版本时去哪看改了什么、新版又是怎么发出去的”,"
|
|
358
|
+
"看这一页就够。下面三件事基本说清楚:当前版本号、有没有更新日志、发布是怎么自动跑的。",
|
|
359
|
+
"",
|
|
360
|
+
"## 当前版本",
|
|
361
|
+
]
|
|
362
|
+
if version:
|
|
363
|
+
src = _SOURCE_LABEL.get(data.get("version_source_kind", ""), data.get("version_source", ""))
|
|
364
|
+
lines.append(f"- **{version}**(写在 `{src}` 里)")
|
|
365
|
+
if data.get("scheme_zh"):
|
|
366
|
+
lines.append(f"- 版本号规则:{data['scheme_zh']}")
|
|
367
|
+
elif dynamic:
|
|
368
|
+
lines.append("- 版本号由 git tag 在打包时自动推导(仓库里没有写死的数字)")
|
|
369
|
+
else:
|
|
370
|
+
lines.append("- 没找到写明的版本号")
|
|
371
|
+
|
|
372
|
+
lines.append("")
|
|
373
|
+
lines.append("## 更新日志(去哪看每版改了什么)")
|
|
374
|
+
if changelog_path:
|
|
375
|
+
lines.append(f"- `{changelog_path}` — {data.get('changelog_style_zh', '')}")
|
|
376
|
+
else:
|
|
377
|
+
lines.append("- 没有更新日志文件;只能去翻提交记录或项目的 Release 页面")
|
|
378
|
+
|
|
379
|
+
if automation:
|
|
380
|
+
lines.append("")
|
|
381
|
+
lines.append("## 新版本怎么发出去(自动发布流水线)")
|
|
382
|
+
current_path = None
|
|
383
|
+
for entry in automation:
|
|
384
|
+
if entry["path"] != current_path:
|
|
385
|
+
current_path = entry["path"]
|
|
386
|
+
system = _SYSTEM_LABEL.get(_release_system(current_path) or "", "")
|
|
387
|
+
head = f"### `{current_path}`"
|
|
388
|
+
if system:
|
|
389
|
+
head += f" · {system}"
|
|
390
|
+
if entry["trigger_zh"]:
|
|
391
|
+
head += f" — {entry['trigger_zh']}"
|
|
392
|
+
lines.append(head)
|
|
393
|
+
lines.append(f"- 发布到 **{entry['target']}** 第 {entry['line']} 行")
|
|
394
|
+
|
|
395
|
+
notes = data.get("notes") or []
|
|
396
|
+
if notes:
|
|
397
|
+
lines.append("")
|
|
398
|
+
lines.append("## 一句话总结")
|
|
399
|
+
for note in notes:
|
|
400
|
+
lines.append(f"- {note}")
|
|
401
|
+
|
|
402
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Export the whole analysis as one self-contained HTML report.
|
|
2
|
+
|
|
3
|
+
The browser UI is great for reading a codebase, but it needs a running server.
|
|
4
|
+
Sometimes you just want to hand the result to someone — a non-technical
|
|
5
|
+
stakeholder, a teammate, your future self — as a single file they can open in
|
|
6
|
+
any browser with no install, no server, and no internet.
|
|
7
|
+
|
|
8
|
+
This module turns the same deterministic code map that powers ``codemap.md``
|
|
9
|
+
into one HTML document with the styling baked in. Everything lives inside the
|
|
10
|
+
file: there are no external stylesheets, scripts, fonts, or images to go
|
|
11
|
+
missing when you email it or drop it in a shared drive.
|
|
12
|
+
|
|
13
|
+
The Markdown is produced by :mod:`backend.services.codemap_export` (every
|
|
14
|
+
section there is owned by the service for that analysis). This module only
|
|
15
|
+
renders that Markdown to HTML and wraps it in a readable, offline page. The
|
|
16
|
+
renderer escapes the source text *before* it adds any formatting, so content
|
|
17
|
+
pulled from the analysed codebase (file paths, code snippets, TODO text) can
|
|
18
|
+
never inject markup into the report.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import html
|
|
24
|
+
import re
|
|
25
|
+
|
|
26
|
+
from backend.services import codemap_export
|
|
27
|
+
|
|
28
|
+
# Inline patterns are applied to text that has *already* been HTML-escaped, so
|
|
29
|
+
# the angle brackets in any real markup are long gone and only our own markers
|
|
30
|
+
# (backticks, asterisks, link syntax) remain to act on.
|
|
31
|
+
_INLINE_CODE = re.compile(r"`([^`]+)`")
|
|
32
|
+
_BOLD = re.compile(r"\*\*([^*]+)\*\*")
|
|
33
|
+
_LINK = re.compile(r"\[([^\]]+)\]\((https?://[^)\s]+)\)")
|
|
34
|
+
_TABLE_DIVIDER = re.compile(r"^\s*\|?\s*:?-{2,}.*$")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _render_inline(escaped: str) -> str:
|
|
38
|
+
"""Apply inline Markdown (code, bold, links) to an already-escaped line."""
|
|
39
|
+
escaped = _INLINE_CODE.sub(r"<code>\1</code>", escaped)
|
|
40
|
+
escaped = _BOLD.sub(r"<strong>\1</strong>", escaped)
|
|
41
|
+
escaped = _LINK.sub(r'<a href="\2" rel="noopener noreferrer">\1</a>', escaped)
|
|
42
|
+
return escaped
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _esc(text: str) -> str:
|
|
46
|
+
return _render_inline(html.escape(text, quote=False))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _split_row(line: str) -> list[str]:
|
|
50
|
+
"""Split a ``| a | b |`` table row into its trimmed cells."""
|
|
51
|
+
inner = line.strip()
|
|
52
|
+
if inner.startswith("|"):
|
|
53
|
+
inner = inner[1:]
|
|
54
|
+
if inner.endswith("|"):
|
|
55
|
+
inner = inner[:-1]
|
|
56
|
+
return [cell.strip() for cell in inner.split("|")]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _markdown_to_html(md: str) -> str:
|
|
60
|
+
"""Render the subset of Markdown the code-map services emit.
|
|
61
|
+
|
|
62
|
+
Handled blocks: ``#``/``##``/``###`` headings, ``---`` rules, ``>`` quotes,
|
|
63
|
+
``-`` bullet lists (one level of nesting), pipe tables, and paragraphs.
|
|
64
|
+
Unknown lines fall through as paragraph text, never as raw HTML.
|
|
65
|
+
"""
|
|
66
|
+
lines = md.split("\n")
|
|
67
|
+
out: list[str] = []
|
|
68
|
+
i = 0
|
|
69
|
+
n = len(lines)
|
|
70
|
+
|
|
71
|
+
def close_list(stack: list[str]) -> None:
|
|
72
|
+
while stack:
|
|
73
|
+
out.append("</ul>")
|
|
74
|
+
stack.pop()
|
|
75
|
+
|
|
76
|
+
list_stack: list[str] = []
|
|
77
|
+
|
|
78
|
+
while i < n:
|
|
79
|
+
line = lines[i]
|
|
80
|
+
stripped = line.strip()
|
|
81
|
+
|
|
82
|
+
# Blank line: end any open list, otherwise it is just spacing.
|
|
83
|
+
if not stripped:
|
|
84
|
+
close_list(list_stack)
|
|
85
|
+
i += 1
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
# Horizontal rule.
|
|
89
|
+
if stripped == "---":
|
|
90
|
+
close_list(list_stack)
|
|
91
|
+
out.append("<hr>")
|
|
92
|
+
i += 1
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
# Headings.
|
|
96
|
+
heading = re.match(r"^(#{1,3})\s+(.*)$", stripped)
|
|
97
|
+
if heading:
|
|
98
|
+
close_list(list_stack)
|
|
99
|
+
level = len(heading.group(1))
|
|
100
|
+
out.append(f"<h{level}>{_esc(heading.group(2))}</h{level}>")
|
|
101
|
+
i += 1
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
# Blockquote: gather consecutive ``>`` lines into one quote.
|
|
105
|
+
if stripped.startswith(">"):
|
|
106
|
+
close_list(list_stack)
|
|
107
|
+
quote: list[str] = []
|
|
108
|
+
while i < n and lines[i].strip().startswith(">"):
|
|
109
|
+
quote.append(_esc(lines[i].strip()[1:].strip()))
|
|
110
|
+
i += 1
|
|
111
|
+
out.append("<blockquote>" + "<br>".join(quote) + "</blockquote>")
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
# Table: a header row immediately followed by a divider row.
|
|
115
|
+
if stripped.startswith("|") and i + 1 < n and _TABLE_DIVIDER.match(lines[i + 1]):
|
|
116
|
+
header = _split_row(lines[i])
|
|
117
|
+
out.append("<table><thead><tr>")
|
|
118
|
+
out.extend(f"<th>{_esc(cell)}</th>" for cell in header)
|
|
119
|
+
out.append("</tr></thead><tbody>")
|
|
120
|
+
i += 2 # skip header + divider
|
|
121
|
+
while i < n and lines[i].strip().startswith("|"):
|
|
122
|
+
out.append("<tr>")
|
|
123
|
+
out.extend(f"<td>{_esc(cell)}</td>" for cell in _split_row(lines[i]))
|
|
124
|
+
out.append("</tr>")
|
|
125
|
+
i += 1
|
|
126
|
+
out.append("</tbody></table>")
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
# Bullet list, with a single level of two-space nesting.
|
|
130
|
+
bullet = re.match(r"^(\s*)-\s+(.*)$", line)
|
|
131
|
+
if bullet:
|
|
132
|
+
depth = 1 if len(bullet.group(1)) >= 2 else 0
|
|
133
|
+
while len(list_stack) <= depth:
|
|
134
|
+
out.append("<ul>")
|
|
135
|
+
list_stack.append("ul")
|
|
136
|
+
while len(list_stack) > depth + 1:
|
|
137
|
+
out.append("</ul>")
|
|
138
|
+
list_stack.pop()
|
|
139
|
+
out.append(f"<li>{_esc(bullet.group(2))}</li>")
|
|
140
|
+
i += 1
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
# Anything else is a paragraph.
|
|
144
|
+
close_list(list_stack)
|
|
145
|
+
out.append(f"<p>{_esc(stripped)}</p>")
|
|
146
|
+
i += 1
|
|
147
|
+
|
|
148
|
+
close_list(list_stack)
|
|
149
|
+
return "\n".join(out)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Inlined so the exported file needs no external stylesheet to render.
|
|
153
|
+
_STYLE = """\
|
|
154
|
+
:root { color-scheme: light dark; }
|
|
155
|
+
* { box-sizing: border-box; }
|
|
156
|
+
body {
|
|
157
|
+
margin: 0; padding: 0;
|
|
158
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
159
|
+
"Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
160
|
+
line-height: 1.65; color: #1f2328; background: #f6f8fa;
|
|
161
|
+
}
|
|
162
|
+
main { max-width: 860px; margin: 0 auto; padding: 2.5rem 1.5rem 4rem; }
|
|
163
|
+
header.report-head {
|
|
164
|
+
background: linear-gradient(135deg, #0d9488, #0f766e);
|
|
165
|
+
color: #fff; padding: 2rem 1.5rem;
|
|
166
|
+
}
|
|
167
|
+
header.report-head .inner { max-width: 860px; margin: 0 auto; }
|
|
168
|
+
header.report-head h1 { margin: 0; font-size: 1.6rem; }
|
|
169
|
+
header.report-head p { margin: .4rem 0 0; opacity: .9; font-size: .9rem; }
|
|
170
|
+
h1, h2, h3 { line-height: 1.3; margin-top: 2rem; }
|
|
171
|
+
h1 { font-size: 1.5rem; border-bottom: 1px solid #d0d7de; padding-bottom: .3rem; }
|
|
172
|
+
h2 { font-size: 1.2rem; }
|
|
173
|
+
h3 { font-size: 1.05rem; }
|
|
174
|
+
hr { border: none; border-top: 1px solid #d0d7de; margin: 2.5rem 0; }
|
|
175
|
+
blockquote {
|
|
176
|
+
margin: 1rem 0; padding: .6rem 1rem; color: #57606a;
|
|
177
|
+
background: #eef2f5; border-left: 3px solid #0d9488; border-radius: 4px;
|
|
178
|
+
}
|
|
179
|
+
code {
|
|
180
|
+
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
181
|
+
background: rgba(13, 148, 136, .1); padding: .1em .35em; border-radius: 4px;
|
|
182
|
+
font-size: .9em;
|
|
183
|
+
}
|
|
184
|
+
table { border-collapse: collapse; width: 100%; margin: 1rem 0; font-size: .92rem; }
|
|
185
|
+
th, td { border: 1px solid #d0d7de; padding: .45rem .7rem; text-align: left; }
|
|
186
|
+
th { background: #eef2f5; }
|
|
187
|
+
ul { padding-left: 1.4rem; }
|
|
188
|
+
li { margin: .2rem 0; }
|
|
189
|
+
footer { max-width: 860px; margin: 0 auto; padding: 1rem 1.5rem 3rem;
|
|
190
|
+
color: #8b949e; font-size: .8rem; }
|
|
191
|
+
@media (prefers-color-scheme: dark) {
|
|
192
|
+
body { color: #e6edf3; background: #0d1117; }
|
|
193
|
+
h1 { border-bottom-color: #30363d; }
|
|
194
|
+
hr { border-top-color: #30363d; }
|
|
195
|
+
blockquote { background: #161b22; color: #9da7b3; }
|
|
196
|
+
th, td { border-color: #30363d; }
|
|
197
|
+
th, blockquote ~ * th { background: #161b22; }
|
|
198
|
+
}
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def build_report_html(proj: dict) -> str:
|
|
203
|
+
"""Build the downloadable self-contained HTML report for a scanned project.
|
|
204
|
+
|
|
205
|
+
The body is the same deterministic code map as ``codemap.md``, rendered to
|
|
206
|
+
HTML and wrapped in an offline page with all styling inlined.
|
|
207
|
+
"""
|
|
208
|
+
name = proj.get("name", "project")
|
|
209
|
+
body = _markdown_to_html(codemap_export.build_codemap_markdown(proj))
|
|
210
|
+
title = html.escape(f"{name} — CodeABC report", quote=True)
|
|
211
|
+
head_name = html.escape(name, quote=False)
|
|
212
|
+
return f"""<!doctype html>
|
|
213
|
+
<html lang="en">
|
|
214
|
+
<head>
|
|
215
|
+
<meta charset="utf-8">
|
|
216
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
217
|
+
<title>{title}</title>
|
|
218
|
+
<style>{_STYLE}</style>
|
|
219
|
+
</head>
|
|
220
|
+
<body>
|
|
221
|
+
<header class="report-head"><div class="inner">
|
|
222
|
+
<h1>{head_name}</h1>
|
|
223
|
+
<p>Plain-language code report · generated by CodeABC · self-contained, works offline</p>
|
|
224
|
+
</div></header>
|
|
225
|
+
<main>
|
|
226
|
+
{body}
|
|
227
|
+
</main>
|
|
228
|
+
<footer>This report was generated by CodeABC. Every section is a deterministic \
|
|
229
|
+
analysis of the source — no model was asked to write it.</footer>
|
|
230
|
+
</body>
|
|
231
|
+
</html>
|
|
232
|
+
"""
|