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,190 @@
1
+ """Config-file surface: the settings files a project ships and what you can change.
2
+
3
+ Three questions cover "how is this configured", and CodeABC answers each from a
4
+ different angle:
5
+
6
+ - :mod:`envscan` — "what must I set *outside* the code before it runs" (env vars).
7
+ - :mod:`settings_map` — "what constants are baked *inside* the code" (module-level
8
+ ``UPPER_SNAKE`` literals).
9
+ - this module — "what config *files* does the project ship, and what top-level
10
+ settings can I change in them" — the ``config.yaml`` / ``settings.toml`` /
11
+ ``*.ini`` a newcomer opens to configure the project without touching code.
12
+
13
+ :func:`find_config_files` is pure over the file contents: it recognises config
14
+ files by name/extension and extracts their top-level keys (YAML/JSON) or
15
+ ``[section]`` headers (TOML/INI) with the conventional syntax — no YAML/TOML
16
+ parser, nothing to install — so it is unit-testable with plain strings and needs
17
+ no repository.
18
+
19
+ Recognised as config (so a random ``data.json`` fixture is not mistaken for one):
20
+
21
+ - a config-ish **basename** — ``config`` / ``settings`` / ``conf`` / ``options`` /
22
+ ``appsettings`` (any extension below), or a well-known name like ``pyproject.toml``,
23
+ ``tox.ini``, ``setup.cfg``;
24
+ - with a config **extension** — ``.yaml`` / ``.yml`` / ``.toml`` / ``.ini`` /
25
+ ``.cfg`` / ``.conf`` / ``.json`` / ``.properties``.
26
+
27
+ Top-level settings only (kept honest): nested keys and array items are not
28
+ descended into — this is a surface map ("here is the config file and its top-level
29
+ knobs"), not a full parse.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import re
35
+ from pathlib import PurePosixPath
36
+
37
+ # A config extension is necessary but not sufficient — pair it with a config-ish
38
+ # name so ordinary data/fixture files in those formats are not swept in.
39
+ _CONFIG_EXTS = {".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".json", ".properties"}
40
+ # Basenames (sans extension) that mark a file as project configuration.
41
+ _CONFIG_STEMS = {"config", "settings", "conf", "options", "appsettings", "application"}
42
+ # Well-known config files whose name doesn't contain a config stem. Kept to
43
+ # files with a config extension so their format is unambiguous; extension-less
44
+ # dotfiles (.editorconfig / .prettierrc) are left out since their syntax isn't
45
+ # implied by the name.
46
+ _WELL_KNOWN = {
47
+ "pyproject.toml",
48
+ "setup.cfg",
49
+ "tox.ini",
50
+ }
51
+
52
+ # YAML/properties: a top-level `key:` / `key =` at column 0 (no leading space, so
53
+ # nested mapping keys and list items are skipped).
54
+ _YAML_KEY_RE = re.compile(r"^([A-Za-z_][\w-]*)\s*:", re.MULTILINE)
55
+ _PROPS_KEY_RE = re.compile(r"^([A-Za-z_][\w.-]*)\s*[=:]", re.MULTILINE)
56
+ # TOML/INI: `[section]` / `[section.sub]` headers at column 0.
57
+ _SECTION_RE = re.compile(r"^\[([^\]]+)\]", re.MULTILINE)
58
+ # TOML/INI: a bare `key =` at column 0 (top of file, before any section).
59
+ _TOML_KEY_RE = re.compile(r"^([A-Za-z_][\w.-]*)\s*=", re.MULTILINE)
60
+ # JSON: keys nested exactly one level (two-space or tab indent) — the top-level
61
+ # object's members. Deeper keys carry more indent and are skipped.
62
+ _JSON_TOP_KEY_RE = re.compile(r'^(?: |\t)"([^"]+)"\s*:', re.MULTILINE)
63
+
64
+
65
+ def _kind_for(name: str) -> str | None:
66
+ """Classify a path as a config file, returning its format kind or ``None``."""
67
+ p = PurePosixPath(name)
68
+ base = p.name.lower()
69
+ ext = p.suffix.lower()
70
+ if ext not in _CONFIG_EXTS:
71
+ return None
72
+ stem = p.stem.lower()
73
+ # A config extension alone isn't enough; require a config-ish name so a data
74
+ # ``users.json`` or a CI ``matrix.yaml`` fixture isn't treated as settings.
75
+ if base not in _WELL_KNOWN and stem not in _CONFIG_STEMS:
76
+ # Allow `<name>.config.json` / `<name>.conf.yaml` style compound names.
77
+ if ".config" not in stem and ".conf" not in stem:
78
+ return None
79
+ if ext in (".yaml", ".yml"):
80
+ return "yaml"
81
+ if ext == ".toml":
82
+ return "toml"
83
+ if ext in (".ini", ".cfg", ".conf"):
84
+ return "ini"
85
+ if ext == ".json":
86
+ return "json"
87
+ if ext == ".properties":
88
+ return "properties"
89
+ return None
90
+
91
+
92
+ def _dedupe(items: list[str], limit: int) -> list[str]:
93
+ seen: set[str] = set()
94
+ out: list[str] = []
95
+ for it in items:
96
+ if it not in seen:
97
+ seen.add(it)
98
+ out.append(it)
99
+ return out[:limit]
100
+
101
+
102
+ def _extract(kind: str, content: str, *, per_file_limit: int) -> tuple[list[str], list[str]]:
103
+ """Return ``(sections, keys)`` for one config file's top-level surface."""
104
+ sections: list[str] = []
105
+ keys: list[str] = []
106
+ if kind == "yaml":
107
+ keys = _dedupe(_YAML_KEY_RE.findall(content), per_file_limit)
108
+ elif kind == "properties":
109
+ keys = _dedupe(_PROPS_KEY_RE.findall(content), per_file_limit)
110
+ elif kind in ("toml", "ini"):
111
+ sections = _dedupe(_SECTION_RE.findall(content), per_file_limit)
112
+ # Keys above the first section header are the file's top-level settings.
113
+ # A file that opens straight into a ``[section]`` (e.g. pyproject.toml,
114
+ # tox.ini) has none, so take only the text before the first header —
115
+ # otherwise the first section's own keys are mis-read as top-level.
116
+ first_section = _SECTION_RE.search(content)
117
+ head = content[: first_section.start()] if first_section else content
118
+ keys = _dedupe(_TOML_KEY_RE.findall(head), per_file_limit)
119
+ elif kind == "json":
120
+ keys = _dedupe(_JSON_TOP_KEY_RE.findall(content), per_file_limit)
121
+ return sections, keys
122
+
123
+
124
+ def find_config_files(
125
+ file_contents: dict[str, str], *, limit: int = 40, per_file_limit: int = 30
126
+ ) -> dict:
127
+ """Collect the project's shipped config files and their top-level settings.
128
+
129
+ Args:
130
+ file_contents: mapping of path to file text (as CodeABC already read it).
131
+ limit: how many config files to return in the ranked list.
132
+ per_file_limit: how many sections/keys to keep per file.
133
+
134
+ Returns ``{"total", "kinds", "files"}`` where each file has its ``kind``
135
+ (yaml/toml/ini/json/properties), its top-level ``sections`` (TOML/INI) and
136
+ ``keys``, and a ``setting_count`` for ranking.
137
+ """
138
+ files: list[dict] = []
139
+ for path, content in file_contents.items():
140
+ if not content:
141
+ continue
142
+ kind = _kind_for(path)
143
+ if kind is None:
144
+ continue
145
+ sections, keys = _extract(kind, content, per_file_limit=per_file_limit)
146
+ # A recognised config file with no readable top-level settings is still
147
+ # worth surfacing (the reader now knows the file exists), but rank it low.
148
+ files.append(
149
+ {
150
+ "path": path,
151
+ "kind": kind,
152
+ "sections": sections,
153
+ "keys": keys,
154
+ "setting_count": len(sections) + len(keys),
155
+ }
156
+ )
157
+
158
+ # Richest, most-central config first, then alphabetical for stability.
159
+ files.sort(key=lambda f: (-f["setting_count"], f["path"]))
160
+ kinds = sorted({f["kind"] for f in files})
161
+ return {"total": len(files), "kinds": kinds, "files": files[:limit]}
162
+
163
+
164
+ def render_config_files_markdown(project_name: str, data: dict | None) -> str:
165
+ """Render the config-file surface as Markdown, or ``""`` if none."""
166
+ d = data or {}
167
+ files = d.get("files") or []
168
+ if not files:
169
+ return ""
170
+
171
+ lines = [
172
+ f"# {project_name} — 配置文件",
173
+ "",
174
+ f"> 项目自带 {d.get('total', len(files))} 个配置文件,是"
175
+ "“不改代码就能调整项目行为”的地方。下面列出每个文件里你可以改的顶层设置。",
176
+ "",
177
+ ]
178
+ for f in files:
179
+ lines.append(f"## `{f['path']}` — {f['kind'].upper()}")
180
+ if f["sections"]:
181
+ joined = "、".join(f"`{s}`" for s in f["sections"])
182
+ lines.append(f"- 分区(section):{joined}")
183
+ if f["keys"]:
184
+ joined = "、".join(f"`{k}`" for k in f["keys"])
185
+ lines.append(f"- 顶层设置:{joined}")
186
+ if not f["sections"] and not f["keys"]:
187
+ lines.append("- (识别为配置文件,但没解析出顶层设置——可能是嵌套结构,打开看看。)")
188
+ lines.append("")
189
+
190
+ return "\n".join(lines).rstrip() + "\n"
@@ -0,0 +1,308 @@
1
+ """Contribution map — what a project asks of you before it will take your change.
2
+
3
+ Entry points say how to *start* a project and the CI map says what gets
4
+ *checked* automatically; this answers the part a first-time contributor worries
5
+ about most: **if I want to hand a change in, what does this project expect from
6
+ me — which file do I read first, what do I have to sign, how must my commits and
7
+ my pull request look?** Almost all of that lives in a handful of well-known
8
+ "community health" files, and missing one of them is the usual reason a first PR
9
+ bounces before anyone even reads the code.
10
+
11
+ It reads the files CodeABC already loaded — no LLM, nothing run — recognising the
12
+ signals people reach for most:
13
+
14
+ 贡献指南 CONTRIBUTING(.md/.rst) in the repo root, ``.github/`` or ``docs/``
15
+ PR 模板 ``.github/PULL_REQUEST_TEMPLATE`` (a single file or a folder)
16
+ Issue 模板 ``.github/ISSUE_TEMPLATE`` (a single file or a folder of forms)
17
+ 行为准则 CODE_OF_CONDUCT(.md)
18
+ 代码负责人 CODEOWNERS — who is auto-requested to review what you touched
19
+ 安全披露 SECURITY(.md) — how to report a vulnerability privately
20
+ DCO 签署 a "Signed-off-by" / ``git commit -s`` / DCO requirement
21
+ CLA 签署 a Contributor License Agreement / cla-assistant gate
22
+ 提交信息规范 Conventional Commits / commitlint / commitizen
23
+
24
+ Every signal is glossed in plain Chinese (DCO 签署 → 每个提交带一行 Signed-off-by)
25
+ so a newcomer can read the row without knowing what a "DCO" or a "Conventional
26
+ Commit" is.
27
+
28
+ :func:`find_contribution_guide` is pure over the file contents, so it is
29
+ unit-testable with plain strings and needs no repository.
30
+
31
+ Limitations (kept honest on purpose):
32
+
33
+ * Signature based, not a policy parse. Rules documented only in prose (a wiki, a
34
+ website, a buried README paragraph) this module cannot see. To keep the
35
+ content scans (DCO / CLA / commit convention) from firing on a stray mention
36
+ in source code, they are restricted to the files those rules actually live in
37
+ — CONTRIBUTING, the PR / issue templates, CI workflows and the dedicated
38
+ config files.
39
+ * Presence, not enforcement. It reports that a CLA bot or a commit convention is
40
+ *configured*, not whether the maintainers truly block a merge on it.
41
+ * Community-health files only. Code-level concerns (secrets in the tree, the
42
+ licences of dependencies, the version it ships) have their own maps and are
43
+ not repeated here.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import re
49
+
50
+ # Plain-Chinese label + one-line gloss for each signal, in display order. The
51
+ # order is roughly "read this first" → "shape your commits" → "who signs off".
52
+ _LABELS = {
53
+ "guide": "贡献指南",
54
+ "pr-template": "PR 模板",
55
+ "issue-template": "Issue 模板",
56
+ "commit-convention": "提交信息规范",
57
+ "dco": "DCO 签署",
58
+ "cla": "CLA 签署",
59
+ "codeowners": "代码负责人",
60
+ "code-of-conduct": "行为准则",
61
+ "security": "安全披露",
62
+ }
63
+ _ORDER = list(_LABELS)
64
+
65
+ _DETAIL = {
66
+ "guide": "动手改之前先读它——项目把“怎么参与、怎么提交、本地怎么跑”都写在这里。",
67
+ "pr-template": "提 PR 时会自动带出一个模板,按它把改动说明、关联 issue、自测情况填好再提交。",
68
+ "issue-template": "提 issue 有固定格式,照着选类型、填字段,维护者更容易接手。",
69
+ "commit-convention": (
70
+ "提交信息要按约定式提交(Conventional Commits)写,例如 `fix: ...`、`feat: ...`。"
71
+ ),
72
+ "dco": (
73
+ "每个提交都要带一行 Signed-off-by(用 `git commit -s` 自动加),声明这段代码可以被合入。"
74
+ ),
75
+ "cla": "首次贡献要先签一份贡献者许可协议(CLA),通常有机器人在 PR 里引导你点一下。",
76
+ "codeowners": "你改到的目录会自动请对应的“代码负责人”来审查,少了他们点头通常合不进去。",
77
+ "code-of-conduct": "社区有一份行为准则,约定大家怎么友好地协作、出问题找谁。",
78
+ "security": "发现安全漏洞别公开提 issue,按这份说明走私下披露渠道。",
79
+ }
80
+
81
+ # Where GitHub looks for community-health files: the repo root, ``.github/`` or
82
+ # ``docs/`` (``.gitlab/`` for GitLab projects). A file only counts as, say, a
83
+ # CODEOWNERS if it sits in one of these — not anywhere a ``CONTRIBUTING.md``
84
+ # happens to appear deep inside an example folder.
85
+ _HEALTH_DIRS = ("/", "/.github/", "/docs/", "/.gitlab/")
86
+
87
+ # commitlint / commitizen config file names — strong evidence of a commit
88
+ # convention even with no prose to read.
89
+ _COMMIT_CONFIG = frozenset(
90
+ {
91
+ "commitlint.config.js",
92
+ "commitlint.config.cjs",
93
+ "commitlint.config.mjs",
94
+ "commitlint.config.ts",
95
+ ".commitlintrc",
96
+ ".commitlintrc.js",
97
+ ".commitlintrc.cjs",
98
+ ".commitlintrc.json",
99
+ ".commitlintrc.yml",
100
+ ".commitlintrc.yaml",
101
+ ".czrc",
102
+ ".cz.toml",
103
+ ".cz.json",
104
+ ".cz.yaml",
105
+ }
106
+ )
107
+
108
+ _DCO_RE = re.compile(
109
+ r"developer certificate of origin|signed-off-by|git commit\s+-s\b|--signoff\b|\bDCO\b",
110
+ re.I,
111
+ )
112
+ _CLA_RE = re.compile(
113
+ r"contributor licen[sc]e agreement|\bCLA\b|cla-assistant|contributor-assistant/github-action",
114
+ re.I,
115
+ )
116
+ _CONVENTION_RE = re.compile(
117
+ r"conventional commits|conventionalcommits\.org|commitlint|commitizen",
118
+ re.I,
119
+ )
120
+
121
+
122
+ def _norm(path: str) -> str:
123
+ return "/" + path.replace("\\", "/").lstrip("/")
124
+
125
+
126
+ def _line_of(content: str, pos: int) -> int:
127
+ return content.count("\n", 0, pos) + 1
128
+
129
+
130
+ def _in_health_location(path: str) -> bool:
131
+ norm = _norm(path)
132
+ parent = norm.rsplit("/", 1)[0] + "/"
133
+ return parent in _HEALTH_DIRS
134
+
135
+
136
+ def _presence_kind(path: str) -> str | None:
137
+ """Classify a path as a known community-health file, else ``None``."""
138
+ norm = _norm(path).lower()
139
+ base = norm.rsplit("/", 1)[-1]
140
+
141
+ # Template folders — a project may keep several PR / issue templates together.
142
+ if "/.github/pull_request_template/" in norm and base.endswith((".md", ".rst")):
143
+ return "pr-template"
144
+ if "/.github/issue_template/" in norm and base.endswith((".md", ".yml", ".yaml")):
145
+ return "issue-template"
146
+ # Single-file templates — the name is specific enough to match anywhere.
147
+ if base in (
148
+ "pull_request_template.md",
149
+ "pull_request_template.rst",
150
+ "pull_request_template.txt",
151
+ ):
152
+ return "pr-template"
153
+ if base == "issue_template.md":
154
+ return "issue-template"
155
+
156
+ # The remaining files only count in a recognised community-health location.
157
+ if not _in_health_location(path):
158
+ return None
159
+ if base in ("contributing.md", "contributing.rst", "contributing.txt", "contributing"):
160
+ return "guide"
161
+ if base in ("code_of_conduct.md", "code_of_conduct.rst", "code-of-conduct.md"):
162
+ return "code-of-conduct"
163
+ if base == "codeowners":
164
+ return "codeowners"
165
+ if base in ("security.md", "security.rst"):
166
+ return "security"
167
+ return None
168
+
169
+
170
+ def _is_text_signal_file(path: str) -> bool:
171
+ """Whether DCO / CLA / commit-convention prose may be trusted in this file.
172
+
173
+ Restricting the content scan to the files those rules actually live in keeps
174
+ a "Signed-off-by" sitting in a source-code comment from being mistaken for a
175
+ project-wide DCO requirement.
176
+ """
177
+ norm = _norm(path).lower()
178
+ base = norm.rsplit("/", 1)[-1]
179
+ if base in (
180
+ "readme.md",
181
+ "readme.rst",
182
+ "contributing.md",
183
+ "contributing.rst",
184
+ "contributing.txt",
185
+ "contributing",
186
+ "code_of_conduct.md",
187
+ ):
188
+ return True
189
+ if base.startswith("pull_request_template") or base == "issue_template.md":
190
+ return True
191
+ if "/.github/pull_request_template/" in norm or "/.github/issue_template/" in norm:
192
+ return True
193
+ if "/.github/workflows/" in norm and base.endswith((".yml", ".yaml")):
194
+ return True
195
+ if base in ("package.json", ".pre-commit-config.yaml", ".pre-commit-config.yml"):
196
+ return True
197
+ if base in _COMMIT_CONFIG:
198
+ return True
199
+ if base in ("dco.yml", "dco.yaml", "cla.yml", "cla.yaml"):
200
+ return True
201
+ return False
202
+
203
+
204
+ def find_contribution_guide(file_contents: dict[str, str]) -> dict:
205
+ """Map what a project requires of a contributor before it accepts a change.
206
+
207
+ Args:
208
+ file_contents: mapping of path to file text (as CodeABC already read it).
209
+
210
+ Returns ``{"has_guide", "requirements", "notes"}`` where each requirement is
211
+ ``{"kind", "label_zh", "detail_zh", "path", "line"}`` (``line`` is ``0`` for a
212
+ plain presence signal, or the line of the matched text for DCO / CLA /
213
+ commit-convention prose).
214
+ """
215
+ found: dict[str, dict] = {} # kind -> {"path", "line", "depth"}
216
+
217
+ def record(kind: str, path: str, line: int = 0) -> None:
218
+ depth = _norm(path).count("/")
219
+ prev = found.get(kind)
220
+ if prev is None or depth < prev["depth"]:
221
+ found[kind] = {"path": path, "line": line, "depth": depth}
222
+
223
+ for path, content in file_contents.items():
224
+ norm = _norm(path).lower()
225
+ base = norm.rsplit("/", 1)[-1]
226
+
227
+ kind = _presence_kind(path)
228
+ if kind:
229
+ record(kind, path)
230
+ if base in _COMMIT_CONFIG:
231
+ record("commit-convention", path)
232
+ if base in ("dco.yml", "dco.yaml") and "/.github/" in norm:
233
+ record("dco", path)
234
+ if base in ("cla.yml", "cla.yaml") and "/.github/" in norm:
235
+ record("cla", path)
236
+
237
+ if content and _is_text_signal_file(path):
238
+ for signal, rx in (
239
+ ("dco", _DCO_RE),
240
+ ("cla", _CLA_RE),
241
+ ("commit-convention", _CONVENTION_RE),
242
+ ):
243
+ match = rx.search(content)
244
+ if match:
245
+ record(signal, path, _line_of(content, match.start()))
246
+
247
+ requirements = [
248
+ {
249
+ "kind": kind,
250
+ "label_zh": _LABELS[kind],
251
+ "detail_zh": _DETAIL[kind],
252
+ "path": found[kind]["path"],
253
+ "line": found[kind]["line"],
254
+ }
255
+ for kind in _ORDER
256
+ if kind in found
257
+ ]
258
+ return {
259
+ "has_guide": "guide" in found,
260
+ "requirements": requirements,
261
+ "notes": _build_notes(found),
262
+ }
263
+
264
+
265
+ def _build_notes(found: dict[str, dict]) -> list[str]:
266
+ notes: list[str] = []
267
+ if not found:
268
+ return notes
269
+ if "guide" not in found:
270
+ notes.append(
271
+ "没找到 CONTRIBUTING 贡献指南文件,参与方式可能写在 README 里,或得直接问维护者。"
272
+ )
273
+ blockers = [k for k in ("dco", "cla", "commit-convention") if k in found]
274
+ if blockers:
275
+ names = "、".join(_LABELS[k] for k in blockers)
276
+ notes.append(f"提交前尤其注意:{names}——这几项最容易让第一次 PR 在还没被看代码前就被打回。")
277
+ if "codeowners" in found:
278
+ notes.append("有 CODEOWNERS,改动需要对应负责人审查通过,找对审查人能少等很久。")
279
+ return notes
280
+
281
+
282
+ def render_contributing_markdown(project_name: str, data: dict | None) -> str:
283
+ """Render the contribution map as Markdown, or ``""`` if nothing was found."""
284
+ requirements = (data or {}).get("requirements") or []
285
+ if not requirements:
286
+ return ""
287
+
288
+ lines = [
289
+ f"# {project_name} — 怎么给这个项目贡献代码",
290
+ "",
291
+ "> 想把自己的改动提给这个项目,又怕一上来就踩流程的坑被打回?下面这些是它对贡献者的"
292
+ "要求,全部来自项目里现成的“社区规范”文件。提 PR 前照着对一遍,基本就不会卡在流程上。",
293
+ "",
294
+ "## 提交改动前,先满足这几项",
295
+ ]
296
+ for req in requirements:
297
+ loc = f"`{req['path']}`"
298
+ if req.get("line"):
299
+ loc += f" 第 {req['line']} 行"
300
+ lines.append(f"- **{req['label_zh']}**({loc}):{req['detail_zh']}")
301
+
302
+ notes = (data or {}).get("notes") or []
303
+ if notes:
304
+ lines.append("")
305
+ lines.append("## 一句话提醒")
306
+ for note in notes:
307
+ lines.append(f"- {note}")
308
+ return "\n".join(lines).rstrip() + "\n"