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