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,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"