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
File without changes
@@ -0,0 +1,366 @@
1
+ """Priority action plan — turn the analyses into an ordered "what do I fix first?" list.
2
+
3
+ The health score tells a non-programmer *how* a codebase is doing and *which
4
+ dimensions* drag it down. The obvious next question is "OK, so what do I actually
5
+ do?" — and a category score doesn't answer that. This module does.
6
+
7
+ It reads the same analysis results (security findings, untested core files,
8
+ complexity, import cycles, tech debt) and emits a single ranked list of concrete
9
+ actions, each pinned to a real file where possible and written in plain language:
10
+ what's wrong, why it matters, and roughly how big the fix is.
11
+
12
+ Ranking favours impact-over-effort: a hardcoded secret outranks a long function,
13
+ and an untested file that ten others depend on outranks one nothing imports.
14
+
15
+ Pure function over already-computed analysis dicts — no repo access required.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ # Severity weights drive the ordering: higher sorts first.
21
+ _PRIORITY_RANK = {"high": 0, "medium": 1, "low": 2}
22
+
23
+ # Caps so the plan stays a short, actionable list rather than a dump.
24
+ _MAX_ITEMS = 12
25
+ _MAX_PER_CATEGORY = 4
26
+
27
+
28
+ def _security_actions(security: dict | None) -> list[dict]:
29
+ if not security or not security.get("findings"):
30
+ return []
31
+ actions: list[dict] = []
32
+ # Critical findings (secrets / dangerous calls) first, then the rest.
33
+ findings = sorted(
34
+ security["findings"],
35
+ key=lambda f: 0 if f.get("category") in ("hardcoded_secret", "dangerous_call") else 1,
36
+ )
37
+ for f in findings[:_MAX_PER_CATEGORY]:
38
+ critical = f.get("category") in ("hardcoded_secret", "dangerous_call")
39
+ actions.append(
40
+ {
41
+ "priority": "high" if critical else "medium",
42
+ "category": "security",
43
+ "title": f"处理安全隐患:{f.get('file', '?')}:{f.get('line', '?')}",
44
+ "target": f.get("file", ""),
45
+ "detail": f.get("reason", "存在潜在安全问题,建议人工核查。"),
46
+ "effort": "small",
47
+ }
48
+ )
49
+ return actions
50
+
51
+
52
+ def _coverage_actions(test_coverage: dict | None) -> list[dict]:
53
+ if not test_coverage:
54
+ return []
55
+ actions: list[dict] = []
56
+ # Untested core files are already ranked by fan-in upstream.
57
+ for f in (test_coverage.get("untested_core") or [])[:_MAX_PER_CATEGORY]:
58
+ fan_in = f.get("fan_in", 0)
59
+ actions.append(
60
+ {
61
+ "priority": "high" if fan_in >= 5 else "medium",
62
+ "category": "test_coverage",
63
+ "title": f"给核心文件补测试:{f.get('path', '?')}",
64
+ "target": f.get("path", ""),
65
+ "detail": f.get(
66
+ "reason",
67
+ f"这个文件没有测试,却有 {fan_in} 个文件依赖它,改动缺少自动验证。",
68
+ ),
69
+ "effort": "medium",
70
+ }
71
+ )
72
+ return actions
73
+
74
+
75
+ def _complexity_actions(complexity_files: list[dict] | None) -> list[dict]:
76
+ if not complexity_files:
77
+ return []
78
+ actions: list[dict] = []
79
+ # Only flag genuinely gnarly files (cyclomatic complexity > 20).
80
+ gnarly = sorted(
81
+ (f for f in complexity_files if f.get("complexity", 0) > 20),
82
+ key=lambda f: f.get("complexity", 0),
83
+ reverse=True,
84
+ )
85
+ for f in gnarly[:_MAX_PER_CATEGORY]:
86
+ cx = f.get("complexity", 0)
87
+ actions.append(
88
+ {
89
+ "priority": "medium" if cx > 40 else "low",
90
+ "category": "complexity",
91
+ "title": f"拆分复杂逻辑:{f.get('path', '?')}",
92
+ "target": f.get("path", ""),
93
+ "detail": (
94
+ f"圈复杂度约 {cx},分支太多、读起来和改起来都费劲,建议拆成更小的函数。"
95
+ ),
96
+ "effort": "large",
97
+ }
98
+ )
99
+ return actions
100
+
101
+
102
+ def _deep_nesting_actions(deep_nesting_files: list[dict] | None) -> list[dict]:
103
+ if not deep_nesting_files:
104
+ return []
105
+ actions: list[dict] = []
106
+ # Deepest first; the analyzer already filtered to the flagged threshold.
107
+ deepest = sorted(deep_nesting_files, key=lambda f: f.get("depth", 0), reverse=True)
108
+ for f in deepest[:_MAX_PER_CATEGORY]:
109
+ depth = f.get("depth", 0)
110
+ func = f.get("function", "?")
111
+ actions.append(
112
+ {
113
+ "priority": "medium" if depth >= 6 else "low",
114
+ "category": "deep_nesting",
115
+ "title": f"拍平深层嵌套:{f.get('path', '?')} 的 {func}",
116
+ "target": f.get("path", ""),
117
+ "detail": (
118
+ f"函数 {func} 的控制流嵌套了 {depth} 层,读的时候要同时记住每一层的条件,"
119
+ "很容易看晕。建议用提前 return / 卫语句 / 把里层抽成一个小函数来拍平。"
120
+ ),
121
+ "effort": "medium",
122
+ }
123
+ )
124
+ return actions
125
+
126
+
127
+ def _long_functions_actions(long_functions_files: list[dict] | None) -> list[dict]:
128
+ if not long_functions_files:
129
+ return []
130
+ actions: list[dict] = []
131
+ # Longest first; the analyzer already filtered to the flagged threshold.
132
+ longest = sorted(long_functions_files, key=lambda f: f.get("length", 0), reverse=True)
133
+ for f in longest[:_MAX_PER_CATEGORY]:
134
+ length = f.get("length", 0)
135
+ func = f.get("function", "?")
136
+ actions.append(
137
+ {
138
+ "priority": "medium" if length >= 120 else "low",
139
+ "category": "long_function",
140
+ "title": f"拆分过长的函数:{f.get('path', '?')} 的 {func}",
141
+ "target": f.get("path", ""),
142
+ "detail": (
143
+ f"函数 {func} 有 {length} 行,一口气读完很吃力、中间没有自然的停顿点。"
144
+ "建议按职责拆成几个各做一件事、名字取好的小函数。"
145
+ ),
146
+ "effort": "medium",
147
+ }
148
+ )
149
+ return actions
150
+
151
+
152
+ def _duplicate_code_actions(duplicate_code_clusters: list[list[dict]] | None) -> list[dict]:
153
+ if not duplicate_code_clusters:
154
+ return []
155
+ actions: list[dict] = []
156
+ # Largest clusters first; the analyzer already filtered to real duplicates.
157
+ biggest = sorted(duplicate_code_clusters, key=len, reverse=True)
158
+ for cluster in biggest[:_MAX_PER_CATEGORY]:
159
+ spots = "、".join(f"{o.get('path', '?')}:{o.get('line', '?')}" for o in cluster)
160
+ actions.append(
161
+ {
162
+ "priority": "medium" if len(cluster) >= 3 else "low",
163
+ "category": "duplicate_code",
164
+ "title": f"抽取重复代码:{cluster[0].get('path', '?')} 等 {len(cluster)} 处",
165
+ "target": cluster[0].get("path", ""),
166
+ "detail": (
167
+ f"同一段代码出现在 {len(cluster)} 个地方({spots})。"
168
+ "改一处时其余几处不会跟着变,建议抽成公共函数,以后只改一处。"
169
+ ),
170
+ "effort": "medium",
171
+ }
172
+ )
173
+ return actions
174
+
175
+
176
+ def _too_many_params_actions(too_many_params_files: list[dict] | None) -> list[dict]:
177
+ if not too_many_params_files:
178
+ return []
179
+ actions: list[dict] = []
180
+ # Widest first; the analyzer already filtered to the flagged threshold.
181
+ widest = sorted(too_many_params_files, key=lambda f: f.get("params", 0), reverse=True)
182
+ for f in widest[:_MAX_PER_CATEGORY]:
183
+ params = f.get("params", 0)
184
+ func = f.get("function", "?")
185
+ actions.append(
186
+ {
187
+ "priority": "medium" if params >= 8 else "low",
188
+ "category": "too_many_params",
189
+ "title": f"减少参数个数:{f.get('path', '?')} 的 {func}",
190
+ "target": f.get("path", ""),
191
+ "detail": (
192
+ f"函数 {func} 有 {params} 个参数,调用时要记住每个位置传什么、很容易传错。"
193
+ "建议把关系紧密的参数打包成一个对象(或 dataclass),或拆成更小的函数。"
194
+ ),
195
+ "effort": "medium",
196
+ }
197
+ )
198
+ return actions
199
+
200
+
201
+ def _typing_actions(typing_files: list[dict] | None) -> list[dict]:
202
+ if not typing_files:
203
+ return []
204
+ actions: list[dict] = []
205
+ # Files with the most unannotated public symbols and low coverage first;
206
+ # only surface genuinely under-annotated files (not a stray missing hint).
207
+ weak = sorted(
208
+ (f for f in typing_files if f.get("missing", 0) >= 3 and f.get("coverage", 1) < 0.6),
209
+ key=lambda f: (-f.get("missing", 0), f.get("coverage", 1)),
210
+ )
211
+ for f in weak[:_MAX_PER_CATEGORY]:
212
+ missing = f.get("missing", 0)
213
+ pct = round(f.get("coverage", 0) * 100)
214
+ actions.append(
215
+ {
216
+ "priority": "low",
217
+ "category": "typing",
218
+ "title": f"补类型注解:{f.get('path', '?')}",
219
+ "target": f.get("path", ""),
220
+ "detail": (
221
+ f"这个文件里有 {missing} 个对外函数没写类型注解(类型覆盖率约 {pct}%),"
222
+ "补上参数和返回值的类型能让编辑器提示更准、也更容易发现用错类型的调用。"
223
+ ),
224
+ "effort": "small",
225
+ }
226
+ )
227
+ return actions
228
+
229
+
230
+ def _architecture_actions(import_cycles: list[dict] | None) -> list[dict]:
231
+ if not import_cycles:
232
+ return []
233
+ actions: list[dict] = []
234
+ for c in import_cycles[:_MAX_PER_CATEGORY]:
235
+ files = c.get("files", [])
236
+ preview = "、".join(files[:3]) + ("…" if len(files) > 3 else "")
237
+ actions.append(
238
+ {
239
+ "priority": "medium",
240
+ "category": "architecture",
241
+ "title": f"打破循环依赖({len(files)} 个文件)",
242
+ "target": files[0] if files else "",
243
+ "detail": (
244
+ f"这些文件互相 import 形成环:{preview}。循环依赖会让模块难以单独理解和测试。"
245
+ ),
246
+ "effort": "medium",
247
+ }
248
+ )
249
+ return actions
250
+
251
+
252
+ def _tech_debt_actions(tech_debt_files: list[dict] | None) -> list[dict]:
253
+ if not tech_debt_files:
254
+ return []
255
+ # Only surface the single worst offender — tech debt is low priority next to
256
+ # security and missing tests, and we don't want to crowd the list.
257
+ worst = max(tech_debt_files, key=lambda f: f.get("count", 0), default=None)
258
+ if not worst or worst.get("count", 0) < 3:
259
+ return []
260
+ return [
261
+ {
262
+ "priority": "low",
263
+ "category": "tech_debt",
264
+ "title": f"清理技术债标记:{worst.get('path', '?')}",
265
+ "target": worst.get("path", ""),
266
+ "detail": (
267
+ f"这个文件有 {worst.get('count', 0)} 处 TODO/FIXME/HACK 标记,"
268
+ "择期清理能降低后续维护成本。"
269
+ ),
270
+ "effort": "small",
271
+ }
272
+ ]
273
+
274
+
275
+ def build_action_plan(
276
+ *,
277
+ security: dict | None = None,
278
+ test_coverage: dict | None = None,
279
+ complexity_files: list[dict] | None = None,
280
+ tech_debt_files: list[dict] | None = None,
281
+ import_cycles: list[dict] | None = None,
282
+ deep_nesting_files: list[dict] | None = None,
283
+ long_functions_files: list[dict] | None = None,
284
+ too_many_params_files: list[dict] | None = None,
285
+ duplicate_code_clusters: list[list[dict]] | None = None,
286
+ typing_files: list[dict] | None = None,
287
+ ) -> dict:
288
+ """Build a ranked, plain-language remediation list from existing analyses.
289
+
290
+ All arguments are optional and keyword-only; callers pass whichever analyses
291
+ are available. Returns::
292
+
293
+ {
294
+ "total": int,
295
+ "items": [
296
+ {
297
+ "priority": "high" | "medium" | "low",
298
+ "category": str,
299
+ "title": str,
300
+ "target": str, # file path, or "" when not file-specific
301
+ "detail": str, # plain-language explanation
302
+ "effort": "small" | "medium" | "large",
303
+ },
304
+ ...
305
+ ],
306
+ "notes": [str, ...],
307
+ }
308
+ """
309
+ items: list[dict] = []
310
+ items += _security_actions(security)
311
+ items += _coverage_actions(test_coverage)
312
+ items += _complexity_actions(complexity_files)
313
+ items += _deep_nesting_actions(deep_nesting_files)
314
+ items += _long_functions_actions(long_functions_files)
315
+ items += _too_many_params_actions(too_many_params_files)
316
+ items += _duplicate_code_actions(duplicate_code_clusters)
317
+ items += _architecture_actions(import_cycles)
318
+ items += _typing_actions(typing_files)
319
+ items += _tech_debt_actions(tech_debt_files)
320
+
321
+ # Stable sort by priority so within a priority the source order (security,
322
+ # coverage, complexity, …) is preserved.
323
+ items.sort(key=lambda a: _PRIORITY_RANK.get(a["priority"], 9))
324
+ items = items[:_MAX_ITEMS]
325
+
326
+ notes: list[str] = []
327
+ if not items:
328
+ notes.append("没有发现需要优先处理的问题,代码基线良好。")
329
+ elif items[0]["priority"] == "high":
330
+ notes.append("列表已按优先级排序,建议从第一条开始处理。")
331
+
332
+ return {"total": len(items), "items": items, "notes": notes}
333
+
334
+
335
+ _PRIORITY_LABEL = {"high": "🔴 高", "medium": "🟡 中", "low": "⚪ 低"}
336
+ _EFFORT_LABEL = {"small": "小", "medium": "中", "large": "大"}
337
+
338
+
339
+ def render_action_plan_markdown(project_name: str, data: dict | None) -> str:
340
+ """Render the action plan as a Markdown section."""
341
+ if not data or not data.get("items"):
342
+ return ""
343
+ lines = [
344
+ f"# 优先行动清单({project_name})",
345
+ "",
346
+ "> 按「先做哪个最值」排序:安全和缺测试的核心文件排在前面。",
347
+ "",
348
+ "| # | 优先级 | 事项 | 工作量 |",
349
+ "|---|--------|------|--------|",
350
+ ]
351
+ for i, item in enumerate(data["items"], start=1):
352
+ prio = _PRIORITY_LABEL.get(item["priority"], item["priority"])
353
+ effort = _EFFORT_LABEL.get(item["effort"], item["effort"])
354
+ lines.append(f"| {i} | {prio} | {item['title']} | {effort} |")
355
+
356
+ lines += ["", "## 说明", ""]
357
+ for item in data["items"]:
358
+ lines.append(f"- **{item['title']}** — {item['detail']}")
359
+
360
+ notes = data.get("notes", [])
361
+ if notes:
362
+ lines += ["", "## 注意事项", ""]
363
+ for n in notes:
364
+ lines.append(f"- {n}")
365
+
366
+ return "\n".join(lines).rstrip() + "\n"
@@ -0,0 +1,313 @@
1
+ """Project activity pulse: recent commit timeline and contributor rhythm.
2
+
3
+ When a non-programmer evaluates code they didn't write — outsourced work,
4
+ a library they're considering, a fork they inherited — one of the first
5
+ questions is "is this actively maintained?". The import graph and test
6
+ coverage say nothing about velocity. This module answers that question
7
+ from git history: how many commits landed recently, who is active, and
8
+ which parts of the codebase are in motion.
9
+
10
+ Four time windows:
11
+ week last 7 days
12
+ month last 30 days
13
+ quarter last 90 days
14
+ total all history in the collected log
15
+
16
+ For each window it reports commit count, unique authors, the files that
17
+ changed, and a simple activity label: ``active`` / ``slowing`` /
18
+ ``stale`` / ``abandoned``.
19
+
20
+ It reuses the git log text already collected by :func:`churn.collect_git_log`
21
+ (format ``::C::hash::timestamp::author``), so no extra shell call is needed.
22
+
23
+ Pure function over the log text — unit-testable with fixture strings, no
24
+ repo required.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import re
30
+ import time
31
+
32
+ _COMMIT_RE = re.compile(r"::C::([0-9a-f]+)::(\d+)::(.*)")
33
+ _NUMSTAT_RE = re.compile(r"^(\d+|-)\t(\d+|-)\t(.+)$")
34
+
35
+ # Heuristic thresholds for activity labels (commits per 30-day window)
36
+ _ACTIVE_THRESHOLD = 5 # ≥5 commits in last month → active
37
+ _SLOWING_THRESHOLD = 1 # ≥1 commit in last month but <5 → slowing
38
+
39
+
40
+ def _label(commits_last_30d: int, days_since_last: float) -> str:
41
+ if days_since_last > 365:
42
+ return "abandoned"
43
+ if days_since_last > 90:
44
+ return "stale"
45
+ if commits_last_30d >= _ACTIVE_THRESHOLD:
46
+ return "active"
47
+ if commits_last_30d >= _SLOWING_THRESHOLD:
48
+ return "slowing"
49
+ return "quiet"
50
+
51
+
52
+ _LABEL_ZH = {
53
+ "active": "活跃(近 30 天有 {n} 次提交)",
54
+ "slowing": "减速中(近 30 天仅 {n} 次提交,势头在减弱)",
55
+ "quiet": "平静(近 30 天没有提交,但最近 3 个月内有活动)",
56
+ "stale": "沉寂(超过 3 个月没有新提交)",
57
+ "abandoned": "疑似停摆(超过一年没有新提交)",
58
+ }
59
+
60
+
61
+ def _label_zh(label: str, commits_last_30d: int) -> str:
62
+ return _LABEL_ZH.get(label, label).format(n=commits_last_30d)
63
+
64
+
65
+ def analyze_activity(git_log: str | None, *, now: float | None = None) -> dict:
66
+ """Summarise recent project activity from git log text.
67
+
68
+ Args:
69
+ git_log: output of ``churn.collect_git_log`` — ``None`` means no git
70
+ history available (uploaded project without .git).
71
+ now: optional Unix timestamp to use as "now" (for testing).
72
+
73
+ Returns::
74
+
75
+ {
76
+ "available": bool,
77
+ "total_commits": int,
78
+ "first_commit_days_ago": float | None,
79
+ "last_commit_days_ago": float | None,
80
+ "label": str, # active | slowing | quiet | stale | abandoned
81
+ "label_zh": str,
82
+ "windows": {
83
+ "week": {"commits": int, "authors": [str, ...], "files": [str, ...]},
84
+ "month": {...},
85
+ "quarter": {...},
86
+ "total": {...},
87
+ },
88
+ "top_contributors": [{"author": str, "commits": int}, ...],
89
+ "recently_changed": [str, ...], # files touched in last 7 days, ranked
90
+ "notes": [str, ...],
91
+ }
92
+ """
93
+ if not git_log:
94
+ return {
95
+ "available": False,
96
+ "total_commits": 0,
97
+ "first_commit_days_ago": None,
98
+ "last_commit_days_ago": None,
99
+ "label": "unknown",
100
+ "label_zh": "无 git 历史(上传的项目无法分析活跃度)",
101
+ "windows": {},
102
+ "top_contributors": [],
103
+ "recently_changed": [],
104
+ "notes": ["上传的项目没有 git 历史,活跃度分析不可用。"],
105
+ }
106
+
107
+ ts_now = now if now is not None else time.time()
108
+
109
+ # Parse commits
110
+ commits: list[dict] = []
111
+ current: dict | None = None
112
+ for line in git_log.splitlines():
113
+ m = _COMMIT_RE.match(line)
114
+ if m:
115
+ if current:
116
+ commits.append(current)
117
+ current = {
118
+ "hash": m.group(1),
119
+ "ts": int(m.group(2)),
120
+ "author": m.group(3).strip(),
121
+ "files": [],
122
+ }
123
+ continue
124
+ if current is None:
125
+ continue
126
+ nm = _NUMSTAT_RE.match(line)
127
+ if nm:
128
+ fname = nm.group(3).strip()
129
+ # git renames: "old => new" or "{old => new}/suffix"
130
+ if " => " in fname:
131
+ fname = re.sub(r"\{[^}]* => ([^}]*)\}", r"\1", fname).replace(" => ", "/")
132
+ current["files"].append(fname)
133
+
134
+ if current:
135
+ commits.append(current)
136
+
137
+ if not commits:
138
+ return {
139
+ "available": True,
140
+ "total_commits": 0,
141
+ "first_commit_days_ago": None,
142
+ "last_commit_days_ago": None,
143
+ "label": "unknown",
144
+ "label_zh": "无法解析 git 提交记录",
145
+ "windows": {},
146
+ "top_contributors": [],
147
+ "recently_changed": [],
148
+ "notes": ["git 日志为空或格式无法识别。"],
149
+ }
150
+
151
+ # Sort newest → oldest (git log is already newest-first but be safe)
152
+ commits.sort(key=lambda c: c["ts"], reverse=True)
153
+ newest_ts = commits[0]["ts"]
154
+ oldest_ts = commits[-1]["ts"]
155
+ total_commits = len(commits)
156
+ days_since_last = (ts_now - newest_ts) / 86400
157
+ first_commit_days_ago = (ts_now - oldest_ts) / 86400
158
+
159
+ # Window boundaries (seconds from now)
160
+ _WINDOWS = {
161
+ "week": 7 * 86400,
162
+ "month": 30 * 86400,
163
+ "quarter": 90 * 86400,
164
+ "total": float("inf"),
165
+ }
166
+
167
+ windows: dict[str, dict] = {}
168
+ for name, span in _WINDOWS.items():
169
+ cutoff = ts_now - span
170
+ wc = [c for c in commits if c["ts"] >= cutoff]
171
+ authors: list[str] = []
172
+ files: list[str] = []
173
+ seen_a: set[str] = set()
174
+ seen_f: set[str] = set()
175
+ for c in wc:
176
+ if c["author"] not in seen_a:
177
+ authors.append(c["author"])
178
+ seen_a.add(c["author"])
179
+ for f in c["files"]:
180
+ if f not in seen_f:
181
+ files.append(f)
182
+ seen_f.add(f)
183
+ windows[name] = {
184
+ "commits": len(wc),
185
+ "authors": authors[:10],
186
+ "files": files[:15],
187
+ }
188
+
189
+ # Top contributors (total history)
190
+ author_counts: dict[str, int] = {}
191
+ for c in commits:
192
+ author_counts[c["author"]] = author_counts.get(c["author"], 0) + 1
193
+ top_contributors = sorted(author_counts.items(), key=lambda x: -x[1])[:8]
194
+
195
+ # Recently changed files (last 7 days)
196
+ recently_changed = windows["week"]["files"]
197
+
198
+ # Activity label
199
+ commits_last_30d = windows["month"]["commits"]
200
+ label = _label(commits_last_30d, days_since_last)
201
+ label_zh = _label_zh(label, commits_last_30d)
202
+
203
+ notes = _build_notes(
204
+ label, commits_last_30d, days_since_last, total_commits, len(author_counts)
205
+ )
206
+
207
+ return {
208
+ "available": True,
209
+ "total_commits": total_commits,
210
+ "first_commit_days_ago": round(first_commit_days_ago, 1),
211
+ "last_commit_days_ago": round(days_since_last, 1),
212
+ "label": label,
213
+ "label_zh": label_zh,
214
+ "windows": windows,
215
+ "top_contributors": [{"author": a, "commits": n} for a, n in top_contributors],
216
+ "recently_changed": recently_changed,
217
+ "notes": notes,
218
+ }
219
+
220
+
221
+ def _build_notes(
222
+ label: str,
223
+ commits_last_30d: int,
224
+ days_since_last: float,
225
+ total_commits: int,
226
+ unique_authors: int,
227
+ ) -> list[str]:
228
+ notes = []
229
+ if label == "active":
230
+ notes.append(
231
+ f"项目处于活跃维护期,近 30 天有 {commits_last_30d} 次提交,是一个健康的节奏。"
232
+ )
233
+ elif label == "slowing":
234
+ notes.append(
235
+ f"近 30 天提交减少到 {commits_last_30d} 次,势头在放缓;"
236
+ "如依赖此项目需关注它的维护节奏。"
237
+ )
238
+ elif label == "quiet":
239
+ notes.append(
240
+ f"近 30 天没有新提交,但最近 3 个月内有活动,"
241
+ f"距上次提交约 {round(days_since_last)} 天。可能正处于稳定期或低活跃阶段。"
242
+ )
243
+ elif label == "stale":
244
+ notes.append(
245
+ f"已超过 3 个月没有新提交(距上次提交 {round(days_since_last)} 天),"
246
+ "项目可能处于停更或维护模式,使用前请确认依赖的稳定性。"
247
+ )
248
+ elif label == "abandoned":
249
+ notes.append(
250
+ f"超过一年没有提交(距上次提交约 {round(days_since_last / 30)} 个月),"
251
+ "项目可能已停止维护,建议寻找活跃的替代方案或做好长期自维护的准备。"
252
+ )
253
+ if unique_authors == 1:
254
+ notes.append("这是单人项目,整个提交历史由一位作者贡献,对外部依赖方来说存在单点风险。")
255
+ elif unique_authors <= 3:
256
+ notes.append(f"小型团队项目,共 {unique_authors} 位贡献者参与了提交历史。")
257
+ else:
258
+ notes.append(f"共 {unique_authors} 位贡献者参与过提交,社区基础相对健康。")
259
+ if total_commits < 20:
260
+ notes.append(f"总提交数较少({total_commits} 次),项目可能较新或提交历史较短。")
261
+ return notes
262
+
263
+
264
+ def render_activity_markdown(project_name: str, data: dict | None) -> str:
265
+ """Render the activity summary as a Markdown section, or ``""`` if unavailable."""
266
+ if not data or not data.get("available"):
267
+ return ""
268
+ label_zh = data.get("label_zh", "")
269
+ lines = [
270
+ f"# 活跃度脉冲({project_name})",
271
+ "",
272
+ f"> 状态:**{label_zh}**",
273
+ "",
274
+ ]
275
+ for note in data.get("notes", []):
276
+ lines.append(f"- {note}")
277
+
278
+ # Window summary table
279
+ windows = data.get("windows", {})
280
+ if windows:
281
+ lines.append("")
282
+ lines.append("## 提交节奏")
283
+ lines.append("")
284
+ lines.append("| 时间窗口 | 提交次数 | 活跃作者 |")
285
+ lines.append("|----------|----------|----------|")
286
+ _WIN_LABELS = {
287
+ "week": "近 7 天",
288
+ "month": "近 30 天",
289
+ "quarter": "近 90 天",
290
+ "total": "全部历史",
291
+ }
292
+ for key in ("week", "month", "quarter", "total"):
293
+ w = windows.get(key, {})
294
+ label = _WIN_LABELS.get(key, key)
295
+ lines.append(f"| {label} | {w.get('commits', 0)} | {len(w.get('authors', []))} |")
296
+
297
+ top = data.get("top_contributors", [])
298
+ if top:
299
+ lines.append("")
300
+ lines.append("## 主要贡献者")
301
+ lines.append("")
302
+ for t in top[:5]:
303
+ lines.append(f"- {t['author']}({t['commits']} 次提交)")
304
+
305
+ recent = data.get("recently_changed", [])
306
+ if recent:
307
+ lines.append("")
308
+ lines.append("## 近 7 天改动的文件")
309
+ lines.append("")
310
+ for f in recent[:10]:
311
+ lines.append(f"- `{f}`")
312
+
313
+ return "\n".join(lines).rstrip() + "\n"