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,165 @@
|
|
|
1
|
+
"""Flag duplicated code blocks — the same code copied into more than one place.
|
|
2
|
+
|
|
3
|
+
Copy-paste is the quietest way a codebase rots. The first copy is fine; the
|
|
4
|
+
problem shows up three months later, when someone fixes a bug in one copy and
|
|
5
|
+
the others keep the old behavior. Nothing warns about that — the copies don't
|
|
6
|
+
know about each other, and they drift apart one silent edit at a time. For a
|
|
7
|
+
beginner this is also the easiest maintenance trap to understand: "the same
|
|
8
|
+
thing written twice means every change has to happen twice."
|
|
9
|
+
|
|
10
|
+
None of the other readability analyzers catch this: a copied block can be
|
|
11
|
+
short, shallow, low-complexity, and have few parameters. Duplication is its own
|
|
12
|
+
axis.
|
|
13
|
+
|
|
14
|
+
The detection is deliberately simple and honest: normalize each file's lines
|
|
15
|
+
(drop blanks, comments, and imports, collapse whitespace), hash every window
|
|
16
|
+
of a few consecutive lines, and report windows that show up in more than one
|
|
17
|
+
file, or three or more times in one file. Exact-duplicate only — fuzzy
|
|
18
|
+
"similar" matching belongs to a much bigger tool, and guessing wrong there
|
|
19
|
+
costs more trust than it earns.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import re
|
|
26
|
+
|
|
27
|
+
# Consecutive lines a window must span to count as a block.
|
|
28
|
+
_WINDOW = 6
|
|
29
|
+
# Same-file copies needed to flag (cross-file only needs two).
|
|
30
|
+
_SAME_FILE_MIN = 3
|
|
31
|
+
|
|
32
|
+
_BLANK_RE = re.compile(r"^\s*$")
|
|
33
|
+
_COMMENT_RE = re.compile(r"^\s*#")
|
|
34
|
+
_IMPORT_RE = re.compile(r"^\s*(import\s|from\s)")
|
|
35
|
+
_WS_RE = re.compile(r"\s+")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _normalized_lines(content: str) -> list[tuple[int, str]]:
|
|
39
|
+
"""Return (line number, normalized text) pairs, skipping noise lines."""
|
|
40
|
+
out: list[tuple[int, str]] = []
|
|
41
|
+
for lineno, raw in enumerate(content.splitlines(), 1):
|
|
42
|
+
if _BLANK_RE.match(raw) or _COMMENT_RE.match(raw) or _IMPORT_RE.match(raw):
|
|
43
|
+
continue
|
|
44
|
+
out.append((lineno, _WS_RE.sub(" ", raw.strip())))
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_test(path: str) -> bool:
|
|
49
|
+
base = path.rsplit("/", 1)[-1]
|
|
50
|
+
return base.startswith("test_") or base.endswith("_test.py") or "/tests/" in path
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _merge_overlapping(clusters: list[list[dict]], window: int) -> list[list[dict]]:
|
|
54
|
+
"""Union clusters whose occurrences sit in the same file within ``window``
|
|
55
|
+
lines — those are sliding windows of one physical copy, not two copies."""
|
|
56
|
+
parent = list(range(len(clusters)))
|
|
57
|
+
|
|
58
|
+
def find(x: int) -> int:
|
|
59
|
+
while parent[x] != x:
|
|
60
|
+
parent[x] = parent[parent[x]]
|
|
61
|
+
x = parent[x]
|
|
62
|
+
return x
|
|
63
|
+
|
|
64
|
+
def union(a: int, b: int) -> None:
|
|
65
|
+
ra, rb = find(a), find(b)
|
|
66
|
+
if ra != rb:
|
|
67
|
+
parent[rb] = ra
|
|
68
|
+
|
|
69
|
+
for i in range(len(clusters)):
|
|
70
|
+
for j in range(i + 1, len(clusters)):
|
|
71
|
+
if any(
|
|
72
|
+
oa["path"] == ob["path"] and abs(oa["line"] - ob["line"]) < window
|
|
73
|
+
for oa in clusters[i]
|
|
74
|
+
for ob in clusters[j]
|
|
75
|
+
):
|
|
76
|
+
union(i, j)
|
|
77
|
+
|
|
78
|
+
merged: dict[int, list[dict]] = {}
|
|
79
|
+
for idx, occurrences in enumerate(clusters):
|
|
80
|
+
merged.setdefault(find(idx), [])
|
|
81
|
+
merged[find(idx)].extend(occurrences)
|
|
82
|
+
return list(merged.values())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _collapse_window_ghosts(occurrences: list[dict], window: int) -> list[dict]:
|
|
86
|
+
"""Keep one occurrence per physical copy: within a file, windows starting
|
|
87
|
+
less than ``window`` lines apart are the same copy seen twice."""
|
|
88
|
+
kept: list[dict] = []
|
|
89
|
+
for o in sorted(occurrences, key=lambda o: (o["path"], o["line"])):
|
|
90
|
+
if kept and kept[-1]["path"] == o["path"] and o["line"] - kept[-1]["line"] < window:
|
|
91
|
+
continue
|
|
92
|
+
kept.append(o)
|
|
93
|
+
return kept
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def scan_duplicate_code(
|
|
97
|
+
file_contents: dict[str, str], *, window: int = _WINDOW, limit: int = 10
|
|
98
|
+
) -> dict:
|
|
99
|
+
"""Find line windows that appear in more than one place.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
file_contents: mapping of path to file text (as CodeABC already read it).
|
|
103
|
+
window: consecutive normalized lines that make up one block.
|
|
104
|
+
limit: how many clusters to return.
|
|
105
|
+
|
|
106
|
+
Returns ``{"total", "total_occurrences", "window", "clusters"}`` where each
|
|
107
|
+
cluster is a list of ``{"path", "line"}`` occurrences (one per physical
|
|
108
|
+
copy, sorted). A block counts when it appears in two different files, or
|
|
109
|
+
at least three times in one file. Test files and non-Python files are
|
|
110
|
+
skipped.
|
|
111
|
+
"""
|
|
112
|
+
blocks: dict[str, list[dict]] = {}
|
|
113
|
+
for path, content in file_contents.items():
|
|
114
|
+
if not content or not path.endswith(".py") or _is_test(path):
|
|
115
|
+
continue
|
|
116
|
+
lines = _normalized_lines(content)
|
|
117
|
+
for i in range(len(lines) - window + 1):
|
|
118
|
+
window_text = "\n".join(text for _, text in lines[i : i + window])
|
|
119
|
+
digest = hashlib.sha1(window_text.encode()).hexdigest()[:16]
|
|
120
|
+
occurrence = {"path": path, "line": lines[i][0]}
|
|
121
|
+
bucket = blocks.setdefault(digest, [])
|
|
122
|
+
if occurrence not in bucket:
|
|
123
|
+
bucket.append(occurrence)
|
|
124
|
+
|
|
125
|
+
clusters = []
|
|
126
|
+
# singleton windows can never be duplicates; only multi-occurrence digests
|
|
127
|
+
# are candidates, and spatial merging happens within that set so unique
|
|
128
|
+
# neighboring windows don't get absorbed into a real cluster
|
|
129
|
+
candidates = [occurrences for occurrences in blocks.values() if len(occurrences) >= 2]
|
|
130
|
+
for occurrences in _merge_overlapping(candidates, window):
|
|
131
|
+
copies = _collapse_window_ghosts(occurrences, window)
|
|
132
|
+
paths = {o["path"] for o in copies}
|
|
133
|
+
if len(paths) >= 2 or len(copies) >= _SAME_FILE_MIN:
|
|
134
|
+
clusters.append(sorted(copies, key=lambda o: (o["path"], o["line"])))
|
|
135
|
+
clusters.sort(key=lambda c: (-len(c), c[0]["path"], c[0]["line"]))
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
"total": len(clusters),
|
|
139
|
+
"total_occurrences": sum(len(c) for c in clusters),
|
|
140
|
+
"window": window,
|
|
141
|
+
"clusters": clusters[:limit],
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def render_duplicate_code_markdown(project_name: str, data: dict | None) -> str:
|
|
146
|
+
"""Render the duplicated-code section, or an empty string when none is found."""
|
|
147
|
+
if not data or not data.get("clusters"):
|
|
148
|
+
return ""
|
|
149
|
+
|
|
150
|
+
window = data.get("window", _WINDOW)
|
|
151
|
+
lines = [
|
|
152
|
+
f"## 重复的代码({project_name})",
|
|
153
|
+
"",
|
|
154
|
+
"下面这些代码块在不止一个地方出现,长得几乎一模一样(连续 "
|
|
155
|
+
f"{window} 行以上)。复制的时候省事,麻烦在后面:哪天改其中一处,"
|
|
156
|
+
"其余几处不会跟着变,bug 就这样一个版本一个版本地留下来。建议把它们"
|
|
157
|
+
"抽成一个公共函数,以后只改一处:",
|
|
158
|
+
"",
|
|
159
|
+
"| 出现位置 |",
|
|
160
|
+
"| --- |",
|
|
161
|
+
]
|
|
162
|
+
for cluster in data["clusters"]:
|
|
163
|
+
spots = "<br>".join(f"`{o['path']}` 第 {o['line']} 行" for o in cluster)
|
|
164
|
+
lines.append(f"| {spots} |")
|
|
165
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Entry points: where a project actually starts running.
|
|
2
|
+
|
|
3
|
+
The reading map tells a newcomer what to read first to *understand* the code;
|
|
4
|
+
this answers the other half of the question — "how do I run it, and where does
|
|
5
|
+
execution begin?". It collects the runnable "front doors" from the source
|
|
6
|
+
CodeABC already loaded: files with a ``__main__`` guard, the console commands
|
|
7
|
+
declared in ``pyproject.toml`` / ``setup.cfg``, the ``bin`` of a ``package.json``,
|
|
8
|
+
and a few conventional entry filenames — so a reader doesn't have to guess where
|
|
9
|
+
to start the program.
|
|
10
|
+
|
|
11
|
+
:func:`find_entry_points` is pure over the file contents, so it is unit-testable
|
|
12
|
+
with plain strings and needs no repository.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
# `if __name__ == "__main__":` — running the file executes this block.
|
|
21
|
+
_MAIN_GUARD_RE = re.compile(r"""^\s*if\s+__name__\s*==\s*['"]__main__['"]\s*:""", re.M)
|
|
22
|
+
# A console-script entry inside a [project.scripts]-style table: name = "target".
|
|
23
|
+
_SCRIPT_ENTRY_RE = re.compile(r"""^\s*([\w][\w\-.]*)\s*=\s*['"]([^'"]+)['"]""", re.M)
|
|
24
|
+
|
|
25
|
+
# Conventional entry filenames and what running them usually means. Only used as
|
|
26
|
+
# a fallback hint when nothing more explicit points at the file.
|
|
27
|
+
_CONVENTIONAL = {
|
|
28
|
+
"__main__.py": "包入口,可用 `python -m <package>` 运行",
|
|
29
|
+
"main.py": "常见的程序主入口",
|
|
30
|
+
"manage.py": "Django 管理命令入口(`python manage.py ...`)",
|
|
31
|
+
"wsgi.py": "WSGI 服务器入口(gunicorn/uwsgi 从这里加载应用)",
|
|
32
|
+
"asgi.py": "ASGI 服务器入口(uvicorn 从这里加载应用)",
|
|
33
|
+
"cli.py": "命令行入口",
|
|
34
|
+
"server.js": "Node 服务器入口",
|
|
35
|
+
"index.js": "Node 程序入口",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Priority for ranking: an explicitly declared command beats a runnable script,
|
|
39
|
+
# which beats a by-convention guess.
|
|
40
|
+
_KIND_RANK = {"command": 0, "script": 1, "convention": 2}
|
|
41
|
+
|
|
42
|
+
_SCRIPT_REASON = '直接运行会执行文件里的 `if __name__ == "__main__"` 代码'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _section(content: str, header: str) -> str:
|
|
46
|
+
"""Return the text of a TOML/INI ``[header]`` section (until the next header)."""
|
|
47
|
+
start = content.find(f"[{header}]")
|
|
48
|
+
if start == -1:
|
|
49
|
+
return ""
|
|
50
|
+
rest = content[start + len(header) + 2 :]
|
|
51
|
+
end = re.search(r"^\s*\[", rest, re.M)
|
|
52
|
+
return rest[: end.start()] if end else rest
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _declared_commands(path: str, content: str) -> list[tuple[str, str]]:
|
|
56
|
+
"""Console commands declared in pyproject.toml / setup.cfg / package.json."""
|
|
57
|
+
name = path.replace("\\", "/").rsplit("/", 1)[-1]
|
|
58
|
+
commands: list[tuple[str, str]] = []
|
|
59
|
+
|
|
60
|
+
if name == "pyproject.toml":
|
|
61
|
+
for header in ("project.scripts", "project.gui-scripts", "tool.poetry.scripts"):
|
|
62
|
+
for cmd, target in _SCRIPT_ENTRY_RE.findall(_section(content, header)):
|
|
63
|
+
commands.append((cmd, target))
|
|
64
|
+
|
|
65
|
+
elif name == "setup.cfg":
|
|
66
|
+
# console_scripts live under [options.entry_points] as "cmd = module:fn".
|
|
67
|
+
block = _section(content, "options.entry_points")
|
|
68
|
+
if "console_scripts" in block:
|
|
69
|
+
for cmd, target in _SCRIPT_ENTRY_RE.findall(block):
|
|
70
|
+
if cmd != "console_scripts":
|
|
71
|
+
commands.append((cmd, target))
|
|
72
|
+
|
|
73
|
+
elif name == "package.json":
|
|
74
|
+
try:
|
|
75
|
+
data = json.loads(content)
|
|
76
|
+
except (json.JSONDecodeError, ValueError):
|
|
77
|
+
return commands
|
|
78
|
+
bins = data.get("bin")
|
|
79
|
+
if isinstance(bins, str):
|
|
80
|
+
commands.append((str(data.get("name") or "bin"), bins))
|
|
81
|
+
elif isinstance(bins, dict):
|
|
82
|
+
commands.extend((str(k), str(v)) for k, v in bins.items())
|
|
83
|
+
|
|
84
|
+
return commands
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def find_entry_points(file_contents: dict[str, str], *, limit: int = 40) -> dict:
|
|
88
|
+
"""Collect the project's runnable entry points from its source.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
file_contents: mapping of path to file text (as CodeABC already read it).
|
|
92
|
+
limit: how many entry points to return in the ranked list.
|
|
93
|
+
|
|
94
|
+
Returns ``{"total", "entry_points"}`` where each entry point is
|
|
95
|
+
``{"path", "kind", "command", "reason"}`` and ``kind`` is one of
|
|
96
|
+
``command`` (a declared console command), ``script`` (a file with a
|
|
97
|
+
``__main__`` guard) or ``convention`` (a conventional entry filename).
|
|
98
|
+
"""
|
|
99
|
+
# path -> best entry (keep the highest-priority kind seen for a path/command).
|
|
100
|
+
found: dict[tuple[str, str], dict] = {}
|
|
101
|
+
|
|
102
|
+
def _add(path: str, kind: str, command: str, reason: str) -> None:
|
|
103
|
+
key = (path, command)
|
|
104
|
+
existing = found.get(key)
|
|
105
|
+
if existing is None or _KIND_RANK[kind] < _KIND_RANK[existing["kind"]]:
|
|
106
|
+
found[key] = {"path": path, "kind": kind, "command": command, "reason": reason}
|
|
107
|
+
|
|
108
|
+
for path, content in file_contents.items():
|
|
109
|
+
if not content:
|
|
110
|
+
continue
|
|
111
|
+
name = path.replace("\\", "/").rsplit("/", 1)[-1]
|
|
112
|
+
|
|
113
|
+
for cmd, target in _declared_commands(path, content):
|
|
114
|
+
_add(path, "command", cmd, f"命令行命令 `{cmd}`(运行 {target})")
|
|
115
|
+
|
|
116
|
+
if name.endswith(".py") and _MAIN_GUARD_RE.search(content):
|
|
117
|
+
_add(path, "script", f"python {path}", _SCRIPT_REASON)
|
|
118
|
+
elif name in _CONVENTIONAL:
|
|
119
|
+
_add(path, "convention", path, _CONVENTIONAL[name])
|
|
120
|
+
|
|
121
|
+
entries = sorted(found.values(), key=lambda e: (_KIND_RANK[e["kind"]], e["path"]))
|
|
122
|
+
return {"total": len(entries), "entry_points": entries[:limit]}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def render_entrypoints_markdown(project_name: str, data: dict | None) -> str:
|
|
126
|
+
"""Render the entry points as Markdown, or ``""`` if none were found."""
|
|
127
|
+
entries = (data or {}).get("entry_points") or []
|
|
128
|
+
if not entries:
|
|
129
|
+
return ""
|
|
130
|
+
|
|
131
|
+
labels = {
|
|
132
|
+
"command": "命令行命令",
|
|
133
|
+
"script": "可直接运行的脚本",
|
|
134
|
+
"convention": "按惯例的入口文件",
|
|
135
|
+
}
|
|
136
|
+
lines = [
|
|
137
|
+
f"# {project_name} — 怎么跑起来(入口点)",
|
|
138
|
+
"",
|
|
139
|
+
"> 程序从这些“门”开始运行。命令行命令是作者明确声明的,脚本是能直接跑的文件,"
|
|
140
|
+
"其余是按文件名惯例推断的入口。",
|
|
141
|
+
"",
|
|
142
|
+
]
|
|
143
|
+
for kind in ("command", "script", "convention"):
|
|
144
|
+
group = [e for e in entries if e["kind"] == kind]
|
|
145
|
+
if not group:
|
|
146
|
+
continue
|
|
147
|
+
lines.append(f"## {labels[kind]}")
|
|
148
|
+
lines.append("")
|
|
149
|
+
lines.extend(f"- `{e['command']}` — {e['reason']}" for e in group)
|
|
150
|
+
lines.append("")
|
|
151
|
+
|
|
152
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Environment-variable surface: what a project needs configured to run.
|
|
2
|
+
|
|
3
|
+
One of the first questions a newcomer has is "what do I have to set up before
|
|
4
|
+
this will run?". Most of that answer lives in the environment variables the code
|
|
5
|
+
reads. This collects them from the source CodeABC already loaded — which names
|
|
6
|
+
are read, whether they are required (the code crashes if they are missing) or
|
|
7
|
+
optional (a default is supplied) — so a reader gets a setup checklist without
|
|
8
|
+
hunting through the codebase.
|
|
9
|
+
|
|
10
|
+
:func:`scan_env_vars` is pure: it matches the common read patterns over the file
|
|
11
|
+
contents, so it is unit-testable with plain strings and needs no repository:
|
|
12
|
+
|
|
13
|
+
- stdlib ``os.environ["X"]`` (required — raises when unset), ``os.getenv("X")`` /
|
|
14
|
+
``os.environ.get("X")`` (optional — a default may follow),
|
|
15
|
+
- JS ``process.env.X`` (optional),
|
|
16
|
+
- Go ``os.Getenv("X")`` / ``os.LookupEnv("X")`` (optional — return "" / a found
|
|
17
|
+
flag rather than panicking) and Rust ``env::var("X")`` (required when
|
|
18
|
+
``.unwrap()`` / ``.expect(...)`` panics on an unset value, optional otherwise),
|
|
19
|
+
- the env-loader libraries many Django/Flask projects use instead of the
|
|
20
|
+
stdlib: ``python-decouple`` (``config("X")``) and ``environs`` /
|
|
21
|
+
``django-environ`` (``env("X")`` / ``env.str("X")`` / ``env.int(...)`` …).
|
|
22
|
+
|
|
23
|
+
A name is *required* if it is ever read without a default (``os.environ["X"]``, or
|
|
24
|
+
a bare ``config("X")`` / ``env("X")`` — both raise when the variable is unset) and
|
|
25
|
+
*optional* if every read supplies a fallback.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
|
|
32
|
+
# Required reads: os.environ["X"] / os.environ['X'] — a missing key raises.
|
|
33
|
+
_REQUIRED_RE = re.compile(r"""os\.environ\[\s*['"](\w+)['"]\s*\]""")
|
|
34
|
+
# Optional reads: os.getenv("X"...) / os.environ.get("X"...) — a default may follow.
|
|
35
|
+
# os.environ.setdefault("X", ...) always supplies a fallback, so it is optional too.
|
|
36
|
+
_OPTIONAL_RE = re.compile(
|
|
37
|
+
r"""os\.(?:getenv|environ\.get|environ\.setdefault)\(\s*['"](\w+)['"]\s*(,)?"""
|
|
38
|
+
)
|
|
39
|
+
# JS: process.env.X / process.env["X"] — always optional (undefined if unset).
|
|
40
|
+
_JS_RE = re.compile(r"""process\.env(?:\.(\w+)|\[\s*['"](\w+)['"]\s*\])""")
|
|
41
|
+
# python-decouple: config("X") / config("X", default=...). A bare read raises
|
|
42
|
+
# UndefinedValueError when the var is unset, so config("X") with no default is
|
|
43
|
+
# required; a default arg (trailing comma) makes it optional. The lookbehind
|
|
44
|
+
# avoids matching attribute calls like ``app.config(...)``.
|
|
45
|
+
_DECOUPLE_RE = re.compile(r"""(?<![\w.])config\(\s*['"](\w+)['"]\s*(,)?""")
|
|
46
|
+
# environs / django-environ: env("X"), env.str("X"), env.int(...), env.bool(...),
|
|
47
|
+
# etc. Same rule as decouple — a bare read raises when the var is unset, a
|
|
48
|
+
# default arg makes it optional. ``os.getenv`` / ``os.environ`` never match here
|
|
49
|
+
# because the char before this ``env`` is a word char / dot.
|
|
50
|
+
_ENVIRONS_RE = re.compile(
|
|
51
|
+
r"""(?<![\w.])env(?:\.(?:str|int|bool|float|list|json|url|path|dict|log_level))?"""
|
|
52
|
+
r"""\(\s*['"](\w+)['"]\s*(,)?"""
|
|
53
|
+
)
|
|
54
|
+
# Go: os.Getenv("X") returns "" when the variable is unset and os.LookupEnv("X")
|
|
55
|
+
# returns (value, ok); both are graceful, so the variable is optional.
|
|
56
|
+
_GO_RE = re.compile(r"""os\.(?:Getenv|LookupEnv)\(\s*['"](\w+)['"]""")
|
|
57
|
+
# Rust: env::var("X") / std::env::var("X") (and the *_os variants) return a
|
|
58
|
+
# ``Result``. A trailing ``.unwrap()`` / ``.expect(...)`` panics when the variable
|
|
59
|
+
# is unset (required); ``.unwrap_or(...)`` or any other handling is graceful
|
|
60
|
+
# (optional).
|
|
61
|
+
_RUST_RE = re.compile(
|
|
62
|
+
r"""(?:std::)?env::var(?:_os)?\(\s*['"](\w+)['"]\s*\)\s*(\.(?:unwrap|expect)\s*\()?"""
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def scan_env_vars(file_contents: dict[str, str], *, limit: int = 40) -> dict:
|
|
67
|
+
"""Collect environment variables read across the project's source.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
file_contents: mapping of path to file text (as CodeABC already read it).
|
|
71
|
+
limit: how many variables to return in the ranked list.
|
|
72
|
+
|
|
73
|
+
Returns ``{"total", "required", "vars"}`` where ``vars`` is every variable
|
|
74
|
+
name with its read count and whether it is required (read with no default
|
|
75
|
+
anywhere), and ``required`` is just the names of the required ones.
|
|
76
|
+
"""
|
|
77
|
+
# name -> {"count": int, "required": bool, "first": (path, line)}
|
|
78
|
+
found: dict[str, dict] = {}
|
|
79
|
+
|
|
80
|
+
def _record(name: str, required: bool, path: str, line_no: int) -> None:
|
|
81
|
+
entry = found.get(name)
|
|
82
|
+
if entry is None:
|
|
83
|
+
found[name] = {"count": 1, "required": required, "first": (path, line_no)}
|
|
84
|
+
return
|
|
85
|
+
entry["count"] += 1
|
|
86
|
+
# Required wins: if any read is ``os.environ["X"]`` (raises when unset),
|
|
87
|
+
# the variable must be set regardless of graceful reads elsewhere.
|
|
88
|
+
entry["required"] = entry["required"] or required
|
|
89
|
+
|
|
90
|
+
for path, content in file_contents.items():
|
|
91
|
+
if not content:
|
|
92
|
+
continue
|
|
93
|
+
for line_no, line in enumerate(content.splitlines(), start=1):
|
|
94
|
+
for m in _REQUIRED_RE.finditer(line):
|
|
95
|
+
_record(m.group(1), True, path, line_no)
|
|
96
|
+
for m in _OPTIONAL_RE.finditer(line):
|
|
97
|
+
_record(m.group(1), False, path, line_no)
|
|
98
|
+
for m in _JS_RE.finditer(line):
|
|
99
|
+
_record(m.group(1) or m.group(2), False, path, line_no)
|
|
100
|
+
# decouple/environs: required unless a default arg (trailing comma) follows.
|
|
101
|
+
for m in _DECOUPLE_RE.finditer(line):
|
|
102
|
+
_record(m.group(1), m.group(2) is None, path, line_no)
|
|
103
|
+
for m in _ENVIRONS_RE.finditer(line):
|
|
104
|
+
_record(m.group(1), m.group(2) is None, path, line_no)
|
|
105
|
+
for m in _GO_RE.finditer(line):
|
|
106
|
+
_record(m.group(1), False, path, line_no)
|
|
107
|
+
# Rust: required when the read is unwrap()/expect()-ed (panics if unset).
|
|
108
|
+
for m in _RUST_RE.finditer(line):
|
|
109
|
+
_record(m.group(1), m.group(2) is not None, path, line_no)
|
|
110
|
+
|
|
111
|
+
vars_list = [
|
|
112
|
+
{
|
|
113
|
+
"name": name,
|
|
114
|
+
"count": info["count"],
|
|
115
|
+
"required": info["required"],
|
|
116
|
+
"path": info["first"][0],
|
|
117
|
+
"line": info["first"][1],
|
|
118
|
+
}
|
|
119
|
+
for name, info in found.items()
|
|
120
|
+
]
|
|
121
|
+
# Required first, then by how widely the name is read, then alphabetical.
|
|
122
|
+
vars_list.sort(key=lambda v: (not v["required"], -v["count"], v["name"]))
|
|
123
|
+
|
|
124
|
+
required = [v["name"] for v in vars_list if v["required"]]
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
"total": len(vars_list),
|
|
128
|
+
"required": required,
|
|
129
|
+
"vars": vars_list[:limit],
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def render_env_markdown(project_name: str, env_data: dict | None) -> str:
|
|
134
|
+
"""Render the environment-variable surface as Markdown, or ``""`` if none."""
|
|
135
|
+
data = env_data or {}
|
|
136
|
+
vars_list = data.get("vars") or []
|
|
137
|
+
if not vars_list:
|
|
138
|
+
return ""
|
|
139
|
+
|
|
140
|
+
total = data.get("total", 0)
|
|
141
|
+
required = data.get("required") or []
|
|
142
|
+
lines = [
|
|
143
|
+
f"# {project_name} — 环境变量",
|
|
144
|
+
"",
|
|
145
|
+
f"> 代码里读取的环境变量共 {total} 个,是“想跑起来要先配什么”的清单。"
|
|
146
|
+
"标 **必填** 的没有默认值,缺了会直接报错。",
|
|
147
|
+
"",
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
if required:
|
|
151
|
+
lines.append(f"## 必填(缺了会报错):{len(required)} 个")
|
|
152
|
+
lines.append("")
|
|
153
|
+
lines.extend(
|
|
154
|
+
f"- `{v['name']}` — 在 `{v['path']}` 等 {v['count']} 处读取,必须配置。"
|
|
155
|
+
for v in vars_list
|
|
156
|
+
if v["required"]
|
|
157
|
+
)
|
|
158
|
+
lines.append("")
|
|
159
|
+
|
|
160
|
+
optional = [v for v in vars_list if not v["required"]]
|
|
161
|
+
if optional:
|
|
162
|
+
lines.append("## 可选(有默认值)")
|
|
163
|
+
lines.append("")
|
|
164
|
+
lines.extend(f"- `{v['name']}` — {v['count']} 处读取,不配会用默认值。" for v in optional)
|
|
165
|
+
lines.append("")
|
|
166
|
+
|
|
167
|
+
return "\n".join(lines).rstrip() + "\n"
|