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,244 @@
|
|
|
1
|
+
"""Git-history churn analysis: change hotspots and co-change coupling.
|
|
2
|
+
|
|
3
|
+
This is a *dynamic* signal — how the code actually evolved over time — and
|
|
4
|
+
is deliberately independent of the static import graph in ``importgraph``.
|
|
5
|
+
A file that changes in many commits, or that almost always changes together
|
|
6
|
+
with another file, is where review attention and refactoring tend to pay
|
|
7
|
+
off, regardless of how the modules import one another.
|
|
8
|
+
|
|
9
|
+
The parsing core (:func:`analyze_churn`) is pure: it operates on the text of
|
|
10
|
+
``git log --numstat`` so it can be unit-tested with fixture strings and no
|
|
11
|
+
real repository. :func:`collect_git_log` is the thin, side-effecting wrapper
|
|
12
|
+
that actually shells out to git.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import subprocess
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
from itertools import combinations
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
_COMMIT_MARKER = "::C::"
|
|
23
|
+
|
|
24
|
+
# Commits touching more than this many files are almost always bulk events
|
|
25
|
+
# (mass renames, auto-formatting, vendored drops). They inflate every file's
|
|
26
|
+
# co-change count without expressing real coupling, so we still count each
|
|
27
|
+
# file's change frequency but skip generating co-change pairs for them.
|
|
28
|
+
_MAX_FILES_FOR_COUPLING = 25
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def collect_git_log(repo_path: Path, *, max_commits: int = 2000) -> str | None:
|
|
32
|
+
"""Return ``git log --numstat`` text for *repo_path*, or ``None``.
|
|
33
|
+
|
|
34
|
+
``None`` means "no usable history" — the path is not a git work tree, git
|
|
35
|
+
is unavailable, or the command failed. Callers treat that as "churn
|
|
36
|
+
analysis unavailable" rather than an error, so an uploaded (non-git)
|
|
37
|
+
project simply gets empty churn results.
|
|
38
|
+
"""
|
|
39
|
+
if not (repo_path / ".git").exists():
|
|
40
|
+
return None
|
|
41
|
+
try:
|
|
42
|
+
result = subprocess.run(
|
|
43
|
+
[
|
|
44
|
+
"git",
|
|
45
|
+
"-C",
|
|
46
|
+
str(repo_path),
|
|
47
|
+
"log",
|
|
48
|
+
"--no-merges",
|
|
49
|
+
f"--max-count={max_commits}",
|
|
50
|
+
"--numstat",
|
|
51
|
+
f"--format={_COMMIT_MARKER}%H::%at::%an",
|
|
52
|
+
],
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
timeout=60,
|
|
56
|
+
errors="replace",
|
|
57
|
+
)
|
|
58
|
+
except (OSError, subprocess.SubprocessError):
|
|
59
|
+
return None
|
|
60
|
+
if result.returncode != 0:
|
|
61
|
+
return None
|
|
62
|
+
return result.stdout
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _normalize_path(raw: str) -> str:
|
|
66
|
+
"""Resolve git rename syntax to the post-rename path.
|
|
67
|
+
|
|
68
|
+
Git renders renames in ``--numstat`` as ``dir/{old => new}/file.py`` or as
|
|
69
|
+
the plain ``old.py => new.py``. We fold history onto the *current* path so
|
|
70
|
+
a file's churn survives a rename.
|
|
71
|
+
"""
|
|
72
|
+
raw = raw.strip()
|
|
73
|
+
if "=>" not in raw:
|
|
74
|
+
return raw
|
|
75
|
+
if "{" in raw and "}" in raw:
|
|
76
|
+
head, _, rest = raw.partition("{")
|
|
77
|
+
inner, _, tail = rest.partition("}")
|
|
78
|
+
_, _, new = inner.partition("=>")
|
|
79
|
+
combined = (head + new.strip() + tail).replace("//", "/")
|
|
80
|
+
return combined.strip("/") or combined.strip()
|
|
81
|
+
_, _, new = raw.partition("=>")
|
|
82
|
+
return new.strip()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _hotspot_reason(commits: int, authors: int) -> str:
|
|
86
|
+
if commits >= 10 and authors >= 3:
|
|
87
|
+
return (
|
|
88
|
+
f"改了 {commits} 次、{authors} 个人动过,是最活跃也最容易出 bug 的地方,"
|
|
89
|
+
"回归测试要重点盖。"
|
|
90
|
+
)
|
|
91
|
+
if commits >= 10:
|
|
92
|
+
return f"改了 {commits} 次但基本一个人在维护,逻辑集中、值得补文档和测试。"
|
|
93
|
+
return f"改了 {commits} 次,是相对活跃的文件,读代码时优先关注。"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _coupling_reason(ratio: int) -> str:
|
|
97
|
+
if ratio >= 70:
|
|
98
|
+
return f"{ratio}% 的改动里两者一起变,耦合很强,多半该一起读、考虑合并或抽公共层。"
|
|
99
|
+
return f"约 {ratio}% 的改动里两者一起变,存在隐性耦合,改一个时别忘了看另一个。"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def analyze_churn(
|
|
103
|
+
git_log_text: str | None,
|
|
104
|
+
*,
|
|
105
|
+
scanned_paths: set[str] | list[str] | None = None,
|
|
106
|
+
limit: int = 8,
|
|
107
|
+
min_coupling_support: int = 3,
|
|
108
|
+
) -> dict:
|
|
109
|
+
"""Parse ``git log --numstat`` text into change hotspots and coupling.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
git_log_text: output of :func:`collect_git_log` (``None`` ⇒ empty result).
|
|
113
|
+
scanned_paths: if given, only these paths are considered, so churn lines
|
|
114
|
+
up with the files CodeABC actually scanned (drops lockfiles, deleted
|
|
115
|
+
files, binaries the analysis never showed).
|
|
116
|
+
limit: how many hotspots / couplings to return.
|
|
117
|
+
min_coupling_support: a pair must co-change at least this many times to
|
|
118
|
+
count, filtering out one-off coincidences.
|
|
119
|
+
|
|
120
|
+
Returns ``{"hotspots", "couplings", "commits_analyzed"}`` where ``hotspots``
|
|
121
|
+
is sorted by commit count and ``couplings`` by co-change count.
|
|
122
|
+
"""
|
|
123
|
+
allowed: set[str] | None = set(scanned_paths) if scanned_paths is not None else None
|
|
124
|
+
|
|
125
|
+
commit_count: dict[str, int] = defaultdict(int)
|
|
126
|
+
lines_changed: dict[str, int] = defaultdict(int)
|
|
127
|
+
authors: dict[str, set[str]] = defaultdict(set)
|
|
128
|
+
pair_count: dict[tuple[str, str], int] = defaultdict(int)
|
|
129
|
+
commits_analyzed = 0
|
|
130
|
+
|
|
131
|
+
current_author = ""
|
|
132
|
+
touched: set[str] = set()
|
|
133
|
+
|
|
134
|
+
def _flush(files: set[str]) -> None:
|
|
135
|
+
if not files:
|
|
136
|
+
return
|
|
137
|
+
nonlocal commits_analyzed
|
|
138
|
+
commits_analyzed += 1
|
|
139
|
+
if len(files) <= _MAX_FILES_FOR_COUPLING:
|
|
140
|
+
for a, b in combinations(sorted(files), 2):
|
|
141
|
+
pair_count[(a, b)] += 1
|
|
142
|
+
|
|
143
|
+
for line in (git_log_text or "").splitlines():
|
|
144
|
+
if line.startswith(_COMMIT_MARKER):
|
|
145
|
+
_flush(touched)
|
|
146
|
+
touched = set()
|
|
147
|
+
parts = line[len(_COMMIT_MARKER) :].split("::")
|
|
148
|
+
current_author = parts[2] if len(parts) >= 3 else ""
|
|
149
|
+
continue
|
|
150
|
+
if not line.strip():
|
|
151
|
+
continue
|
|
152
|
+
cols = line.split("\t")
|
|
153
|
+
if len(cols) != 3:
|
|
154
|
+
continue
|
|
155
|
+
added, deleted, raw_path = cols
|
|
156
|
+
path = _normalize_path(raw_path)
|
|
157
|
+
if not path or (allowed is not None and path not in allowed):
|
|
158
|
+
continue
|
|
159
|
+
if path not in touched:
|
|
160
|
+
touched.add(path)
|
|
161
|
+
commit_count[path] += 1
|
|
162
|
+
authors[path].add(current_author)
|
|
163
|
+
for value in (added, deleted):
|
|
164
|
+
if value.isdigit():
|
|
165
|
+
lines_changed[path] += int(value)
|
|
166
|
+
_flush(touched)
|
|
167
|
+
|
|
168
|
+
hotspots = [
|
|
169
|
+
{
|
|
170
|
+
"path": path,
|
|
171
|
+
"commits": commits,
|
|
172
|
+
"lines_changed": lines_changed[path],
|
|
173
|
+
"authors": len(authors[path]),
|
|
174
|
+
"reason": _hotspot_reason(commits, len(authors[path])),
|
|
175
|
+
}
|
|
176
|
+
for path, commits in commit_count.items()
|
|
177
|
+
if commits >= 2
|
|
178
|
+
]
|
|
179
|
+
hotspots.sort(key=lambda h: (-h["commits"], -h["lines_changed"], h["path"]))
|
|
180
|
+
|
|
181
|
+
couplings = []
|
|
182
|
+
for (a, b), co in pair_count.items():
|
|
183
|
+
if co < min_coupling_support:
|
|
184
|
+
continue
|
|
185
|
+
base = min(commit_count[a], commit_count[b])
|
|
186
|
+
if base == 0:
|
|
187
|
+
continue
|
|
188
|
+
ratio = round(co / base * 100)
|
|
189
|
+
couplings.append(
|
|
190
|
+
{
|
|
191
|
+
"file_a": a,
|
|
192
|
+
"file_b": b,
|
|
193
|
+
"co_changes": co,
|
|
194
|
+
"coupling": ratio,
|
|
195
|
+
"reason": _coupling_reason(ratio),
|
|
196
|
+
}
|
|
197
|
+
)
|
|
198
|
+
couplings.sort(key=lambda c: (-c["co_changes"], -c["coupling"], c["file_a"]))
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
"hotspots": hotspots[:limit],
|
|
202
|
+
"couplings": couplings[:limit],
|
|
203
|
+
"commits_analyzed": commits_analyzed,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def render_churn_markdown(project_name: str, churn_data: dict | None) -> str:
|
|
208
|
+
"""Render the churn analysis as a Markdown section, or ``""`` if empty.
|
|
209
|
+
|
|
210
|
+
Designed to be appended to the static code map: it adds the *dynamic*
|
|
211
|
+
(git-history) view. Returns an empty string when there's no usable history
|
|
212
|
+
(e.g. an uploaded project) so callers can skip it cleanly.
|
|
213
|
+
"""
|
|
214
|
+
data = churn_data or {}
|
|
215
|
+
hotspots = data.get("hotspots") or []
|
|
216
|
+
couplings = data.get("couplings") or []
|
|
217
|
+
if not hotspots and not couplings:
|
|
218
|
+
return ""
|
|
219
|
+
|
|
220
|
+
analyzed = data.get("commits_analyzed", 0)
|
|
221
|
+
lines = [
|
|
222
|
+
f"# {project_name} — 变更历史",
|
|
223
|
+
"",
|
|
224
|
+
f"> 基于最近 {analyzed} 个提交的 git 历史算出,看的是代码怎么演化,和静态结构互补。",
|
|
225
|
+
"",
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
if hotspots:
|
|
229
|
+
lines.append("## 变更热点(改得最频繁)")
|
|
230
|
+
lines.append("")
|
|
231
|
+
lines.extend(
|
|
232
|
+
f"- `{h['path']}` — 改了 {h['commits']} 次"
|
|
233
|
+
f"({h['authors']} 人、{h['lines_changed']} 行):{h['reason']}"
|
|
234
|
+
for h in hotspots
|
|
235
|
+
)
|
|
236
|
+
lines.append("")
|
|
237
|
+
|
|
238
|
+
if couplings:
|
|
239
|
+
lines.append("## 变更耦合(总一起改的文件)")
|
|
240
|
+
lines.append("")
|
|
241
|
+
lines.extend(f"- `{c['file_a']}` ↔ `{c['file_b']}` — {c['reason']}" for c in couplings)
|
|
242
|
+
lines.append("")
|
|
243
|
+
|
|
244
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""CI quality-gate map — the automated checks your code must pass on push / PR.
|
|
2
|
+
|
|
3
|
+
Entry points answer "how do I *start* this", CLI commands answer "what can I
|
|
4
|
+
*type*", and the scheduled-tasks map answers "what runs on a *timer*". This answers
|
|
5
|
+
the question a newcomer feels most sharply the first time they open a pull
|
|
6
|
+
request: **when I hand my change in, what does the project check automatically —
|
|
7
|
+
and what has to pass before it is allowed in?** A continuous-integration pipeline
|
|
8
|
+
quietly runs a row of gates (lint, formatting, type-checks, tests, a security
|
|
9
|
+
scan, a build) on every push, and one red cross usually blocks the merge. Seeing
|
|
10
|
+
the gates up front turns an intimidating wall of red into a short, known checklist.
|
|
11
|
+
|
|
12
|
+
It reads the CI configuration CodeABC already loaded — no LLM, nothing to run —
|
|
13
|
+
recognising the pipelines people reach for most:
|
|
14
|
+
|
|
15
|
+
GitHub Actions ``.github/workflows/*.yml``
|
|
16
|
+
pre-commit ``.pre-commit-config.yaml``
|
|
17
|
+
GitLab CI ``.gitlab-ci.yml``
|
|
18
|
+
CircleCI ``.circleci/config.yml``
|
|
19
|
+
Azure Pipelines ``azure-pipelines.yml``
|
|
20
|
+
Travis CI ``.travis.yml``
|
|
21
|
+
Jenkins ``Jenkinsfile``
|
|
22
|
+
|
|
23
|
+
Each gate is classified — by the tool that runs it — into a plain-language bucket
|
|
24
|
+
(代码规范 / 代码格式 / 类型检查 / 自动化测试 / 测试覆盖率 / 安全扫描 / 构建打包 /
|
|
25
|
+
部署发布) so a non-programmer can read the row without knowing what ``ruff`` or
|
|
26
|
+
``tsc`` are. For GitHub Actions it also glosses the trigger (``on: [push,
|
|
27
|
+
pull_request]`` → ``每次推送代码、每次 PR``).
|
|
28
|
+
|
|
29
|
+
:func:`find_ci_checks` is pure over the file contents, so it is unit-testable
|
|
30
|
+
with plain strings and needs no repository.
|
|
31
|
+
|
|
32
|
+
Limitations (kept honest on purpose):
|
|
33
|
+
|
|
34
|
+
* Tool-signature based, not a pipeline parse. A gate run through an unusual
|
|
35
|
+
wrapper, a hand-rolled shell script, or a composite action this module does
|
|
36
|
+
not recognise will be missed; a tool name that only appears in a CI comment
|
|
37
|
+
may be over-counted. Restricting the scan to known CI files keeps both rare.
|
|
38
|
+
* Only the trigger of GitHub Actions workflows is glossed; the other systems
|
|
39
|
+
express their run rules too many ways to read reliably, so their trigger is
|
|
40
|
+
left blank rather than guessed at.
|
|
41
|
+
* Cron-*scheduled* runs are deliberately left to the scheduled-tasks map so the
|
|
42
|
+
two maps do not double-count; this one is about checks that gate a change.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import re
|
|
48
|
+
|
|
49
|
+
_YAML_SUFFIXES = (".yml", ".yaml")
|
|
50
|
+
|
|
51
|
+
# Plain-language label for each gate category, in display order.
|
|
52
|
+
_CATEGORY_LABEL = {
|
|
53
|
+
"lint": "代码规范检查",
|
|
54
|
+
"format": "代码格式检查",
|
|
55
|
+
"typecheck": "类型检查",
|
|
56
|
+
"test": "自动化测试",
|
|
57
|
+
"coverage": "测试覆盖率",
|
|
58
|
+
"security": "安全扫描",
|
|
59
|
+
"build": "构建打包",
|
|
60
|
+
"deploy": "部署 / 发布",
|
|
61
|
+
}
|
|
62
|
+
_CATEGORY_ORDER = list(_CATEGORY_LABEL)
|
|
63
|
+
|
|
64
|
+
_SYSTEM_LABEL = {
|
|
65
|
+
"github-actions": "GitHub Actions",
|
|
66
|
+
"pre-commit": "pre-commit",
|
|
67
|
+
"gitlab-ci": "GitLab CI",
|
|
68
|
+
"circleci": "CircleCI",
|
|
69
|
+
"azure-pipelines": "Azure Pipelines",
|
|
70
|
+
"travis": "Travis CI",
|
|
71
|
+
"jenkins": "Jenkins",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# (category, tool label, signature). Every signature is matched independently, so
|
|
75
|
+
# a single ``run:`` line that chains several tools is credited to each. The only
|
|
76
|
+
# ordering that matters is keeping ``ruff format`` distinguishable from a bare
|
|
77
|
+
# ``ruff`` lint pass, which the negative lookahead below handles regardless.
|
|
78
|
+
_SIGNATURES: list[tuple[str, str, str]] = [
|
|
79
|
+
# --- lint --------------------------------------------------------------
|
|
80
|
+
("lint", "ruff", r"\bruff\b(?![-\s]format\b)"),
|
|
81
|
+
("lint", "flake8", r"\bflake8\b"),
|
|
82
|
+
("lint", "pylint", r"\bpylint\b"),
|
|
83
|
+
("lint", "eslint", r"\beslint\b"),
|
|
84
|
+
("lint", "golangci-lint", r"\bgolangci-lint\b"),
|
|
85
|
+
("lint", "clippy", r"\bclippy\b"),
|
|
86
|
+
("lint", "rubocop", r"\brubocop\b"),
|
|
87
|
+
("lint", "shellcheck", r"\bshellcheck\b"),
|
|
88
|
+
("lint", "stylelint", r"\bstylelint\b"),
|
|
89
|
+
("lint", "markdownlint", r"\bmarkdownlint\b"),
|
|
90
|
+
# --- format ------------------------------------------------------------
|
|
91
|
+
("format", "ruff format", r"\bruff[-\s]format\b"),
|
|
92
|
+
("format", "black", r"\bblack\b"),
|
|
93
|
+
("format", "isort", r"\bisort\b"),
|
|
94
|
+
("format", "prettier", r"\bprettier\b"),
|
|
95
|
+
("format", "gofmt", r"\bgofmt\b|\bgo\s+fmt\b"),
|
|
96
|
+
("format", "rustfmt", r"\brustfmt\b|\bcargo\s+fmt\b"),
|
|
97
|
+
("format", "clang-format", r"\bclang-format\b"),
|
|
98
|
+
# --- typecheck ---------------------------------------------------------
|
|
99
|
+
("typecheck", "mypy", r"\bmypy\b"),
|
|
100
|
+
("typecheck", "pyright", r"\bpyright\b"),
|
|
101
|
+
("typecheck", "tsc", r"\btsc\b"),
|
|
102
|
+
("typecheck", "pytype", r"\bpytype\b"),
|
|
103
|
+
# --- test --------------------------------------------------------------
|
|
104
|
+
("test", "pytest", r"\bpytest\b"),
|
|
105
|
+
("test", "tox", r"\btox\b"),
|
|
106
|
+
("test", "nox", r"\bnox\b"),
|
|
107
|
+
("test", "unittest", r"\bunittest\b"),
|
|
108
|
+
("test", "jest", r"\bjest\b"),
|
|
109
|
+
("test", "vitest", r"\bvitest\b"),
|
|
110
|
+
("test", "mocha", r"\bmocha\b"),
|
|
111
|
+
("test", "npm test", r"\bnpm\s+(?:run\s+)?test\b"),
|
|
112
|
+
("test", "yarn test", r"\byarn\s+(?:run\s+)?test\b"),
|
|
113
|
+
("test", "pnpm test", r"\bpnpm\s+(?:run\s+)?test\b"),
|
|
114
|
+
("test", "go test", r"\bgo\s+test\b"),
|
|
115
|
+
("test", "cargo test", r"\bcargo\s+test\b"),
|
|
116
|
+
("test", "gradle test", r"\bgradle\s+test\b"),
|
|
117
|
+
("test", "mvn test", r"\bmvn\s+test\b"),
|
|
118
|
+
("test", "rspec", r"\brspec\b"),
|
|
119
|
+
# --- coverage ----------------------------------------------------------
|
|
120
|
+
("coverage", "codecov", r"\bcodecov\b"),
|
|
121
|
+
("coverage", "coveralls", r"\bcoveralls\b"),
|
|
122
|
+
("coverage", "coverage", r"\bcoverage\s+(?:run|report|xml|html)\b"),
|
|
123
|
+
("coverage", "pytest --cov", r"--cov\b"),
|
|
124
|
+
# --- security ----------------------------------------------------------
|
|
125
|
+
("security", "bandit", r"\bbandit\b"),
|
|
126
|
+
("security", "pip-audit", r"\bpip-audit\b"),
|
|
127
|
+
("security", "npm audit", r"\bnpm\s+audit\b"),
|
|
128
|
+
("security", "trivy", r"\btrivy\b"),
|
|
129
|
+
("security", "CodeQL", r"\bcodeql\b"),
|
|
130
|
+
("security", "semgrep", r"\bsemgrep\b"),
|
|
131
|
+
("security", "snyk", r"\bsnyk\b"),
|
|
132
|
+
("security", "gitleaks", r"\bgitleaks\b"),
|
|
133
|
+
("security", "safety", r"\bsafety\s+check\b"),
|
|
134
|
+
# --- build -------------------------------------------------------------
|
|
135
|
+
("build", "docker build", r"\bdocker\s+build\b"),
|
|
136
|
+
("build", "npm run build", r"\bnpm\s+run\s+build\b"),
|
|
137
|
+
("build", "yarn build", r"\byarn\s+(?:run\s+)?build\b"),
|
|
138
|
+
("build", "pnpm build", r"\bpnpm\s+(?:run\s+)?build\b"),
|
|
139
|
+
("build", "go build", r"\bgo\s+build\b"),
|
|
140
|
+
("build", "cargo build", r"\bcargo\s+build\b"),
|
|
141
|
+
("build", "python -m build", r"\bpython\s+-m\s+build\b"),
|
|
142
|
+
("build", "poetry build", r"\bpoetry\s+build\b"),
|
|
143
|
+
("build", "mvn package", r"\bmvn\s+package\b"),
|
|
144
|
+
("build", "gradle build", r"\bgradle\s+build\b"),
|
|
145
|
+
# --- deploy ------------------------------------------------------------
|
|
146
|
+
("deploy", "twine upload", r"\btwine\s+upload\b"),
|
|
147
|
+
("deploy", "npm publish", r"\bnpm\s+publish\b"),
|
|
148
|
+
("deploy", "docker push", r"\bdocker\s+push\b"),
|
|
149
|
+
("deploy", "gh release", r"\bgh\s+release\b"),
|
|
150
|
+
("deploy", "PyPI publish", r"pypa/gh-action-pypi-publish"),
|
|
151
|
+
("deploy", "GitHub Release", r"softprops/action-gh-release"),
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
_COMPILED = [(cat, tool, re.compile(pat, re.IGNORECASE)) for cat, tool, pat in _SIGNATURES]
|
|
155
|
+
|
|
156
|
+
# GitHub Actions ``on:`` events worth glossing, in display order. ``pull_request``
|
|
157
|
+
# and its ``_target`` variant collapse to one label, so the more specific name is
|
|
158
|
+
# listed first and the duplicate is dropped by the seen-set in :func:`_gh_trigger`.
|
|
159
|
+
_EVENT_LABELS = [
|
|
160
|
+
("push", "每次推送代码"),
|
|
161
|
+
("pull_request_target", "每次 PR"),
|
|
162
|
+
("pull_request", "每次 PR"),
|
|
163
|
+
("workflow_dispatch", "手动触发"),
|
|
164
|
+
("release", "发布时"),
|
|
165
|
+
("schedule", "定时触发"),
|
|
166
|
+
("workflow_call", "被其他流程调用"),
|
|
167
|
+
("merge_group", "合并队列"),
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _line_of(content: str, pos: int) -> int:
|
|
172
|
+
return content.count("\n", 0, pos) + 1
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _ci_system(path: str) -> str | None:
|
|
176
|
+
"""Classify a path as a known CI config, or ``None`` if it is not one."""
|
|
177
|
+
norm = "/" + path.replace("\\", "/").lstrip("/")
|
|
178
|
+
base = norm.rsplit("/", 1)[-1]
|
|
179
|
+
if "/.github/workflows/" in norm and base.endswith(_YAML_SUFFIXES):
|
|
180
|
+
return "github-actions"
|
|
181
|
+
if base == ".pre-commit-config.yaml":
|
|
182
|
+
return "pre-commit"
|
|
183
|
+
if base in (".gitlab-ci.yml", ".gitlab-ci.yaml"):
|
|
184
|
+
return "gitlab-ci"
|
|
185
|
+
if "/.circleci/" in norm and base.endswith(_YAML_SUFFIXES):
|
|
186
|
+
return "circleci"
|
|
187
|
+
if base in ("azure-pipelines.yml", "azure-pipelines.yaml"):
|
|
188
|
+
return "azure-pipelines"
|
|
189
|
+
if base in (".travis.yml", ".travis.yaml"):
|
|
190
|
+
return "travis"
|
|
191
|
+
if base == "Jenkinsfile":
|
|
192
|
+
return "jenkins"
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _gh_trigger(content: str) -> str:
|
|
197
|
+
"""Plain-language gloss of a GitHub Actions ``on:`` trigger, else ``""``.
|
|
198
|
+
|
|
199
|
+
Captures the ``on:`` value whether it is inline (``on: [push]`` / ``on: push``)
|
|
200
|
+
or a nested block, then names the events it recognises.
|
|
201
|
+
"""
|
|
202
|
+
m = re.search(
|
|
203
|
+
r"^on:(?P<inline>[^\n]*)\n(?P<body>(?:[ \t]+[^\n]*\n?)*)",
|
|
204
|
+
content,
|
|
205
|
+
re.MULTILINE,
|
|
206
|
+
)
|
|
207
|
+
if m:
|
|
208
|
+
scope = m.group("inline") + "\n" + m.group("body")
|
|
209
|
+
else:
|
|
210
|
+
inline = re.search(r"^on:(?P<inline>[^\n]*)$", content, re.MULTILINE)
|
|
211
|
+
scope = inline.group("inline") if inline else ""
|
|
212
|
+
if not scope.strip():
|
|
213
|
+
return ""
|
|
214
|
+
|
|
215
|
+
labels: list[str] = []
|
|
216
|
+
seen: set[str] = set()
|
|
217
|
+
for event, label in _EVENT_LABELS:
|
|
218
|
+
if label in seen:
|
|
219
|
+
continue
|
|
220
|
+
if re.search(rf"\b{event}\b", scope):
|
|
221
|
+
seen.add(label)
|
|
222
|
+
labels.append(label)
|
|
223
|
+
return "、".join(labels)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def find_ci_checks(file_contents: dict[str, str], *, limit: int = 60) -> dict:
|
|
227
|
+
"""Collect the automated quality gates a project runs in CI.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
file_contents: mapping of path to file text (as CodeABC already read it).
|
|
231
|
+
limit: how many checks to return in the sorted list.
|
|
232
|
+
|
|
233
|
+
Returns ``{"total", "systems", "categories", "checks"}`` where each check is
|
|
234
|
+
``{"tool", "category", "system", "trigger", "path", "line"}``.
|
|
235
|
+
"""
|
|
236
|
+
checks: list[dict] = []
|
|
237
|
+
seen: set[tuple] = set()
|
|
238
|
+
|
|
239
|
+
for path, content in file_contents.items():
|
|
240
|
+
if not content:
|
|
241
|
+
continue
|
|
242
|
+
system = _ci_system(path)
|
|
243
|
+
if system is None:
|
|
244
|
+
continue
|
|
245
|
+
trigger = _gh_trigger(content) if system == "github-actions" else ""
|
|
246
|
+
for category, tool, rx in _COMPILED:
|
|
247
|
+
match = rx.search(content)
|
|
248
|
+
if not match:
|
|
249
|
+
continue
|
|
250
|
+
key = (path, category, tool)
|
|
251
|
+
if key in seen:
|
|
252
|
+
continue
|
|
253
|
+
seen.add(key)
|
|
254
|
+
checks.append(
|
|
255
|
+
{
|
|
256
|
+
"tool": tool,
|
|
257
|
+
"category": category,
|
|
258
|
+
"system": system,
|
|
259
|
+
"trigger": trigger,
|
|
260
|
+
"path": path,
|
|
261
|
+
"line": _line_of(content, match.start()),
|
|
262
|
+
}
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
order = {category: i for i, category in enumerate(_CATEGORY_ORDER)}
|
|
266
|
+
checks.sort(key=lambda c: (c["path"], order.get(c["category"], 99), c["line"]))
|
|
267
|
+
systems = sorted({c["system"] for c in checks})
|
|
268
|
+
categories = [c for c in _CATEGORY_ORDER if any(ck["category"] == c for ck in checks)]
|
|
269
|
+
return {
|
|
270
|
+
"total": len(checks),
|
|
271
|
+
"systems": systems,
|
|
272
|
+
"categories": categories,
|
|
273
|
+
"checks": checks[:limit],
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def render_ci_checks_markdown(project_name: str, data: dict | None) -> str:
|
|
278
|
+
"""Render the CI quality-gate map as Markdown, or ``""`` if none were found."""
|
|
279
|
+
checks = (data or {}).get("checks") or []
|
|
280
|
+
if not checks:
|
|
281
|
+
return ""
|
|
282
|
+
|
|
283
|
+
lines = [
|
|
284
|
+
f"# {project_name} — 提交代码后会自动跑的检查(CI 质量门禁)",
|
|
285
|
+
"",
|
|
286
|
+
"> 你把改动交上去(push 或开 PR)之后,下面这些检查会自动跑一遍,"
|
|
287
|
+
"可以把它们当成“机器人审稿”——任何一项亮红叉,改动通常就进不去。"
|
|
288
|
+
"提前知道有哪几道关卡,红叉就不再吓人,照着改过去即可。"
|
|
289
|
+
"(会自己定时跑的任务另见“定时任务”地图,这里只看挡在改动前的检查。)",
|
|
290
|
+
"",
|
|
291
|
+
"## 这个项目会自动检查",
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
by_category: dict[str, list[str]] = {}
|
|
295
|
+
for check in checks:
|
|
296
|
+
tools = by_category.setdefault(check["category"], [])
|
|
297
|
+
if check["tool"] not in tools:
|
|
298
|
+
tools.append(check["tool"])
|
|
299
|
+
for category in _CATEGORY_ORDER:
|
|
300
|
+
if category in by_category:
|
|
301
|
+
tools = "、".join(f"`{tool}`" for tool in by_category[category])
|
|
302
|
+
lines.append(f"- **{_CATEGORY_LABEL[category]}**:{tools}")
|
|
303
|
+
lines.append("")
|
|
304
|
+
lines.append("## 按配置文件")
|
|
305
|
+
|
|
306
|
+
current_path = None
|
|
307
|
+
for check in checks:
|
|
308
|
+
if check["path"] != current_path:
|
|
309
|
+
current_path = check["path"]
|
|
310
|
+
system_label = _SYSTEM_LABEL.get(check["system"], check["system"])
|
|
311
|
+
head = f"### `{current_path}` · {system_label}"
|
|
312
|
+
if check["trigger"]:
|
|
313
|
+
head += f" — {check['trigger']}"
|
|
314
|
+
lines.append(head)
|
|
315
|
+
label = _CATEGORY_LABEL[check["category"]]
|
|
316
|
+
lines.append(f"- {label}:`{check['tool']}` 第 {check['line']} 行")
|
|
317
|
+
return "\n".join(lines).rstrip() + "\n"
|