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