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,445 @@
1
+ """Security-pattern scanner: surface common dangerous patterns in code.
2
+
3
+ A non-programmer reviewing code — a founder auditing outsourced work, say —
4
+ can't easily spot subtle security issues, but certain patterns are clear red
5
+ flags: hardcoded passwords, unvalidated shell commands, dangerous deserialisation.
6
+
7
+ Four categories:
8
+
9
+ hardcoded_secret — passwords / tokens / API keys embedded directly in code
10
+ instead of env vars or a secrets store
11
+ dangerous_call — eval() / exec() / pickle.loads() / yaml.load() without
12
+ SafeLoader — calls where untrusted input can run arbitrary
13
+ code or deserialise untrusted data unsafely
14
+ shell_injection — os.system() / subprocess with shell=True — OS commands
15
+ assembled from dynamic input can execute arbitrary shell code
16
+ debug_mode — DEBUG=True / app.debug=True / app.run(debug=True) outside
17
+ test files — settings that leak stack traces in production
18
+
19
+ Each finding carries the file path, line number, the matching line snippet
20
+ (truncated to 120 chars), category, and a plain-language explanation for a
21
+ non-programmer.
22
+
23
+ Limitations:
24
+ * Regex-based — cannot track data flow. A variable ultimately controlled by
25
+ user input may not match these patterns; a pattern in a comment or test
26
+ fixture may fire spuriously.
27
+ * Absence of findings does not mean the code is secure.
28
+ * Best treated as "flags for human review", not definitive security verdicts.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import re
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Category: hardcoded_secret
37
+ # Variables whose names suggest they hold a credential are assigned a
38
+ # non-trivial string literal rather than read from env vars or config.
39
+ # ---------------------------------------------------------------------------
40
+
41
+ # Step 1: match any identifier (possibly attribute like self.key) assigned a
42
+ # string literal that is at least 6 chars. Group 1 = varname, group 2 = quote
43
+ # char, group 3 = string content. Optional type annotation handled inline.
44
+ _ASSIGN_STR_RE = re.compile(
45
+ r"([\w.]+)" # variable / attribute name
46
+ r"(?:\s*:\s*\S+)?" # optional type annotation (: str, : bytes, …)
47
+ r"\s*=\s*" # assignment
48
+ r"(['\"])([^'\"]{6,})\2", # string literal ≥ 6 chars
49
+ )
50
+
51
+ # Step 2: check if the variable name (lowercased, underscores/dots stripped)
52
+ # contains one of these secret-component words.
53
+ _SECRET_KEYWORDS: frozenset[str] = frozenset(
54
+ {
55
+ "password",
56
+ "passwd",
57
+ "passphrase",
58
+ "token",
59
+ "apitoken",
60
+ "accesstoken",
61
+ "refreshtoken",
62
+ "secret",
63
+ "clientsecret",
64
+ "jwtsecret",
65
+ "apikey",
66
+ "apisecret",
67
+ "secretkey",
68
+ "authkey",
69
+ "authsecret",
70
+ "privatekey",
71
+ "accesskey",
72
+ "signingkey",
73
+ }
74
+ )
75
+
76
+ # Obvious placeholder markers — these are NOT real secrets.
77
+ _PLACEHOLDER_RE = re.compile(
78
+ r"(?i)(your[_\-]|<[a-z]|{[a-z]|example|placeholder|dummy|fake|test|changeme|replace)"
79
+ )
80
+
81
+ # All-same-character strings are clearly placeholders (e.g. "xxxxxxxxxxxx").
82
+ _UNIFORM_RE = re.compile(r"^(.)\1{5,}$")
83
+
84
+ _SECRET_REASON = (
85
+ "硬编码的凭据:把密码/密钥/Token 直接写进代码里,一旦代码泄漏"
86
+ "(GitHub 提交历史、日志、截图)这个凭据就对所有人可见。"
87
+ "应该改成从环境变量读取,或者用 Vault / Secrets Manager 管理。"
88
+ )
89
+
90
+
91
+ def _is_real_secret(value: str) -> bool:
92
+ """Return True if the string literal looks like a real credential, not a placeholder."""
93
+ if not value or len(value) < 6:
94
+ return False
95
+ if _PLACEHOLDER_RE.search(value):
96
+ return False
97
+ if _UNIFORM_RE.match(value.strip()):
98
+ return False
99
+ return True
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Category: dangerous_call
104
+ # eval / exec / pickle.loads / yaml.load without SafeLoader.
105
+ # ---------------------------------------------------------------------------
106
+
107
+ _EVAL_RE = re.compile(r"\beval\s*\(")
108
+ _EXEC_RE = re.compile(r"\bexec\s*\(")
109
+ _PICKLE_RE = re.compile(r"\bpickle\.(loads?)\s*\(")
110
+ # yaml.load( but NOT yaml.safe_load( (safe_load doesn't match \byaml\.load\b)
111
+ _YAML_LOAD_RE = re.compile(r"\byaml\.load\s*\(")
112
+
113
+ _DANGEROUS_PATTERNS: list[tuple[re.Pattern, str]] = [
114
+ (
115
+ _EVAL_RE,
116
+ "eval() 把字符串当代码执行——如果字符串来自用户输入或网络,"
117
+ "攻击者可以借此在服务器上运行任意代码。",
118
+ ),
119
+ (
120
+ _EXEC_RE,
121
+ "exec() 与 eval() 一样危险——动态执行任意代码,输入来源可控时是严重的远程代码执行风险。",
122
+ ),
123
+ (
124
+ _PICKLE_RE,
125
+ "pickle.load(s)() 会执行对象里嵌入的 Python 代码——"
126
+ "反序列化不可信来源的 pickle 数据等价于对攻击者开放一个 shell。",
127
+ ),
128
+ (
129
+ _YAML_LOAD_RE,
130
+ "yaml.load() 在 PyYAML 5.1 之前默认允许执行任意 Python 对象——"
131
+ "反序列化不可信 YAML 可以在服务器上运行任意代码。"
132
+ "改用 yaml.safe_load() 或显式传 Loader=yaml.SafeLoader。",
133
+ ),
134
+ ]
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Category: shell_injection
138
+ # os.system / os.popen / subprocess + shell=True.
139
+ # ---------------------------------------------------------------------------
140
+
141
+ _OS_SYSTEM_RE = re.compile(r"\bos\.(system|popen)\s*\(")
142
+ _SUBPROCESS_SHELL_RE = re.compile(r"\bshell\s*=\s*True\b")
143
+
144
+ _OS_SYSTEM_REASON = (
145
+ "os.system() / os.popen() 把字符串作为 shell 命令执行——"
146
+ "如果命令中包含来自用户输入或外部数据的部分,攻击者可以注入分号、管道等"
147
+ "执行任意命令(命令注入)。"
148
+ )
149
+ _SUBPROCESS_SHELL_REASON = (
150
+ "subprocess 使用 shell=True 时,参数经由 shell 解释——"
151
+ "如果命令字符串含有未经清洗的用户输入,攻击者可注入任意 shell 命令。"
152
+ "安全做法是 shell=False(默认)并把参数拆成列表传递。"
153
+ )
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Category: debug_mode
157
+ # DEBUG=True / app.debug=True / app.run(debug=True) outside test files.
158
+ # ---------------------------------------------------------------------------
159
+
160
+ _DEBUG_TRUE_RE = re.compile(r"\bDEBUG\s*=\s*True\b")
161
+ _APP_DEBUG_RE = re.compile(r"\bapp\.(debug)\s*=\s*True\b")
162
+ _APP_RUN_DEBUG_RE = re.compile(r"\bapp\.run\s*\([^)]*debug\s*=\s*True")
163
+
164
+ _DEBUG_PATTERNS: list[tuple[re.Pattern, str]] = [
165
+ (
166
+ _DEBUG_TRUE_RE,
167
+ "DEBUG=True:调试模式开着——在生产环境中会把详细的错误栈(包括源代码、变量值)"
168
+ "暴露给用户,是严重的信息泄露。上线前必须改为 DEBUG=False 或通过环境变量控制。",
169
+ ),
170
+ (
171
+ _APP_DEBUG_RE,
172
+ "app.debug=True:Flask/类似框架的调试模式——会在浏览器里显示交互式调试器,"
173
+ "生产环境中这允许任何用户在服务器上执行任意 Python 代码。",
174
+ ),
175
+ (
176
+ _APP_RUN_DEBUG_RE,
177
+ "app.run(debug=True):以调试模式启动服务——同上,只应出现在本地开发脚本,"
178
+ "绝不应进入生产部署代码。",
179
+ ),
180
+ ]
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Helpers
184
+ # ---------------------------------------------------------------------------
185
+
186
+ _SNIPPET_MAX = 120
187
+
188
+
189
+ def _snip(line: str) -> str:
190
+ """Return the line stripped of leading whitespace, capped at _SNIPPET_MAX chars."""
191
+ stripped = line.strip()
192
+ return stripped[:_SNIPPET_MAX] + ("…" if len(stripped) > _SNIPPET_MAX else "")
193
+
194
+
195
+ def _is_comment(line: str, lang: str) -> bool:
196
+ """True if the entire line is a comment (not just partially)."""
197
+ s = line.lstrip()
198
+ if not s:
199
+ return False
200
+ if lang in ("python",):
201
+ return s.startswith("#")
202
+ # JS / TS / Java / Go etc.
203
+ return s.startswith("//") or s.startswith("/*") or s.startswith("*")
204
+
205
+
206
+ def _is_test_file(path: str) -> bool:
207
+ """True if the path looks like a test or fixture file."""
208
+ p = path.replace("\\", "/").lower()
209
+ parts = p.split("/")
210
+ name = parts[-1]
211
+ if any(d in parts[:-1] for d in ("test", "tests", "__tests__", "spec", "specs", "fixtures")):
212
+ return True
213
+ stem = name.rsplit(".", 1)[0] if "." in name else name
214
+ return (
215
+ stem.startswith("test_")
216
+ or stem.endswith("_test")
217
+ or stem.endswith(".test")
218
+ or stem.endswith(".spec")
219
+ )
220
+
221
+
222
+ def _lang_for(path: str) -> str:
223
+ """Coarse language label from file extension."""
224
+ ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
225
+ if ext in ("py",):
226
+ return "python"
227
+ if ext in ("js", "ts", "jsx", "tsx", "mjs", "cjs"):
228
+ return "javascript"
229
+ return ext
230
+
231
+
232
+ # ---------------------------------------------------------------------------
233
+ # Public API
234
+ # ---------------------------------------------------------------------------
235
+
236
+
237
+ def scan_security(file_contents: dict[str, str], *, limit: int = 30) -> dict:
238
+ """Scan file contents for common security-risk patterns.
239
+
240
+ Args:
241
+ file_contents: mapping of path to file text.
242
+ limit: cap on total findings returned (keep the report scannable).
243
+
244
+ Returns::
245
+
246
+ {
247
+ "total": int, # findings before capping
248
+ "critical": int, # hardcoded_secret + dangerous_call count
249
+ "findings": [ # capped to *limit*, sorted critical-first
250
+ {
251
+ "file": str,
252
+ "line": int,
253
+ "category": str, # one of the four category names above
254
+ "snippet": str, # truncated line text
255
+ "reason": str, # plain-language explanation
256
+ }, …
257
+ ],
258
+ "notes": [str, …], # human-readable summary bullets
259
+ }
260
+ """
261
+ findings: list[dict] = []
262
+
263
+ for path, content in file_contents.items():
264
+ if not content:
265
+ continue
266
+ lang = _lang_for(path)
267
+ is_test = _is_test_file(path)
268
+
269
+ for lineno, line in enumerate(content.splitlines(), start=1):
270
+ if _is_comment(line, lang):
271
+ continue
272
+
273
+ # ── hardcoded_secret ──────────────────────────────────────────
274
+ for m in _ASSIGN_STR_RE.finditer(line):
275
+ varname = m.group(1)
276
+ value = m.group(3)
277
+ # normalise to bare lowercase (strip _ and . separators) then
278
+ # check whether any secret keyword appears as a substring
279
+ norm = varname.lower().replace("_", "").replace(".", "")
280
+ if any(k in norm for k in _SECRET_KEYWORDS) and _is_real_secret(value):
281
+ findings.append(
282
+ {
283
+ "file": path,
284
+ "line": lineno,
285
+ "category": "hardcoded_secret",
286
+ "snippet": _snip(line),
287
+ "reason": _SECRET_REASON,
288
+ "is_test": is_test,
289
+ }
290
+ )
291
+ break # one finding per line is enough
292
+
293
+ # ── dangerous_call ────────────────────────────────────────────
294
+ for pattern, reason in _DANGEROUS_PATTERNS:
295
+ if pattern.search(line):
296
+ findings.append(
297
+ {
298
+ "file": path,
299
+ "line": lineno,
300
+ "category": "dangerous_call",
301
+ "snippet": _snip(line),
302
+ "reason": reason,
303
+ "is_test": is_test,
304
+ }
305
+ )
306
+ break # first matching pattern per line
307
+
308
+ # ── shell_injection ───────────────────────────────────────────
309
+ if _OS_SYSTEM_RE.search(line):
310
+ findings.append(
311
+ {
312
+ "file": path,
313
+ "line": lineno,
314
+ "category": "shell_injection",
315
+ "snippet": _snip(line),
316
+ "reason": _OS_SYSTEM_REASON,
317
+ "is_test": is_test,
318
+ }
319
+ )
320
+ elif _SUBPROCESS_SHELL_RE.search(line):
321
+ findings.append(
322
+ {
323
+ "file": path,
324
+ "line": lineno,
325
+ "category": "shell_injection",
326
+ "snippet": _snip(line),
327
+ "reason": _SUBPROCESS_SHELL_REASON,
328
+ "is_test": is_test,
329
+ }
330
+ )
331
+
332
+ # ── debug_mode (skip test files entirely — intentional there) ──
333
+ if not is_test:
334
+ for pattern, reason in _DEBUG_PATTERNS:
335
+ if pattern.search(line):
336
+ findings.append(
337
+ {
338
+ "file": path,
339
+ "line": lineno,
340
+ "category": "debug_mode",
341
+ "snippet": _snip(line),
342
+ "reason": reason,
343
+ "is_test": False,
344
+ }
345
+ )
346
+ break
347
+
348
+ # Deduplicate: same file + line can match at most one finding per category.
349
+ seen: set[tuple] = set()
350
+ unique: list[dict] = []
351
+ for f in findings:
352
+ key = (f["file"], f["line"], f["category"])
353
+ if key not in seen:
354
+ seen.add(key)
355
+ unique.append(f)
356
+
357
+ total = len(unique)
358
+
359
+ # Sort: non-test critical findings first, then others; within each group
360
+ # by category weight then file+line.
361
+ _WEIGHT = {"hardcoded_secret": 0, "dangerous_call": 1, "shell_injection": 2, "debug_mode": 3}
362
+ unique.sort(
363
+ key=lambda f: (
364
+ int(f.get("is_test", False)), # non-test before test
365
+ _WEIGHT.get(f["category"], 9),
366
+ f["file"],
367
+ f["line"],
368
+ )
369
+ )
370
+
371
+ critical_cats = {"hardcoded_secret", "dangerous_call"}
372
+ critical = sum(1 for f in unique if f["category"] in critical_cats and not f.get("is_test"))
373
+
374
+ # Strip the internal `is_test` flag before returning.
375
+ capped = [{k: v for k, v in f.items() if k != "is_test"} for f in unique[:limit]]
376
+
377
+ notes = _build_notes(total, critical, unique)
378
+
379
+ return {
380
+ "total": total,
381
+ "critical": critical,
382
+ "findings": capped,
383
+ "notes": notes,
384
+ }
385
+
386
+
387
+ def _build_notes(total: int, critical: int, findings: list[dict]) -> list[str]:
388
+ if total == 0:
389
+ return ["未发现常见安全风险模式,代码没有明显的高危信号。"]
390
+ notes = []
391
+ if critical:
392
+ notes.append(
393
+ f"发现 {critical} 处高危问题(硬编码凭据或危险调用),建议在上线或开源前逐一排查。"
394
+ )
395
+ cats: dict[str, int] = {}
396
+ for f in findings:
397
+ cats[f["category"]] = cats.get(f["category"], 0) + 1
398
+ cat_zh = {
399
+ "hardcoded_secret": "硬编码凭据",
400
+ "dangerous_call": "危险调用",
401
+ "shell_injection": "Shell 注入",
402
+ "debug_mode": "调试模式残留",
403
+ }
404
+ for cat, count in sorted(cats.items(), key=lambda x: _WEIGHT.get(x[0], 9)):
405
+ notes.append(f"{cat_zh.get(cat, cat)}:{count} 处")
406
+ if total > 10:
407
+ notes.append("安全问题较多——建议优先清理高危项,再结合安全审计工具做全面扫描。")
408
+ notes.append("注意:这是静态模式匹配,无法追踪数据流。测试文件里的同类模式危险性通常较低。")
409
+ return notes
410
+
411
+
412
+ def render_security_markdown(project_name: str, data: dict | None) -> str:
413
+ """Render the security scan as a Markdown section, or ``""`` if clean."""
414
+ if not data or not data.get("findings"):
415
+ return ""
416
+ total = data.get("total", 0)
417
+ critical = data.get("critical", 0)
418
+ lines = [
419
+ f"# 安全风险({project_name})",
420
+ "",
421
+ f"> 发现 {total} 处安全模式,其中 {critical} 处高危(硬编码凭据/危险调用)。"
422
+ " 静态检测,建议逐一核查上下文。",
423
+ "",
424
+ ]
425
+ lines.extend(f"- {note}" for note in data.get("notes", []))
426
+
427
+ _CAT_ZH = {
428
+ "hardcoded_secret": "⚠ 硬编码凭据",
429
+ "dangerous_call": "⚠ 危险调用",
430
+ "shell_injection": "Shell 注入",
431
+ "debug_mode": "调试模式残留",
432
+ }
433
+
434
+ lines.append("")
435
+ lines.append("## 发现清单")
436
+ lines.append("")
437
+ for f in data.get("findings", []):
438
+ cat = _CAT_ZH.get(f["category"], f["category"])
439
+ lines.append(f"- `{f['file']}:{f['line']}` [{cat}] `{f['snippet']}`")
440
+ lines.append(f" > {f['reason']}")
441
+ return "\n".join(lines).rstrip() + "\n"
442
+
443
+
444
+ # Expose the category weight for tests / external sorting.
445
+ _WEIGHT = {"hardcoded_secret": 0, "dangerous_call": 1, "shell_injection": 2, "debug_mode": 3}
@@ -0,0 +1,234 @@
1
+ """Tunable-settings map — the values baked into the code you might want to change.
2
+
3
+ The env-var surface answers "what do I set *outside* the code before it runs".
4
+ This answers the opposite question: "what is set *inside* the code that I might
5
+ want to tweak". Almost every project hard-codes a handful of knobs — a retry
6
+ count, a timeout, a default model name, a page size, a feature flag — as
7
+ module-level constants. For a non-programmer those uppercase lines are the
8
+ closest thing to a settings panel the project has, but they are scattered across
9
+ files and easy to miss.
10
+
11
+ This collects them straight from the source CodeABC already loaded — no LLM,
12
+ nothing to install — by recognising the two ways a constant is conventionally
13
+ declared with a *literal* value:
14
+
15
+ Python ``MAX_RETRIES = 3`` / ``DEFAULT_MODEL: str = "gpt-5"`` (module level)
16
+ JS/TS ``const PAGE_SIZE = 20`` / ``export const DEBUG = false`` (top level)
17
+
18
+ Only ``UPPER_SNAKE_CASE`` names assigned a literal scalar or simple literal
19
+ collection are kept — that is the universal "this is a constant" convention, and
20
+ the literal test cleanly drops type aliases (``Vector = list[float]``) and
21
+ computed values (``TIMEOUT = BASE * 2``), which are not knobs a reader can just
22
+ edit.
23
+
24
+ :func:`find_tunable_settings` is pure over the file contents, so it is
25
+ unit-testable with plain strings and needs no repository.
26
+
27
+ Limitations (kept honest on purpose):
28
+
29
+ * Module/top level only. A constant defined inside a function or class body is
30
+ local to that scope and not a project-wide knob, so it is skipped.
31
+ * Literal values only. ``RETRIES = int(os.getenv("RETRIES", 3))`` is reported
32
+ by the env-var map instead; here only the bare literal forms are caught.
33
+ * Names must be ``UPPER_SNAKE_CASE``. Lower-case module globals are too easily
34
+ ordinary state to flag as settings safely.
35
+ * A file that does not parse as Python is skipped for the AST pass; the JS/TS
36
+ pass is a conservative line-anchored match and ignores indented constants.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import ast
42
+ import re
43
+
44
+ _PY_SUFFIX = ".py"
45
+ _JS_SUFFIXES = (".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs")
46
+
47
+ # A constant name: all-caps, digits and underscores, at least two chars so a
48
+ # lone loop variable like ``N`` is not mistaken for a setting.
49
+ _CONST_NAME = re.compile(r"^[A-Z][A-Z0-9_]*[A-Z0-9]$")
50
+
51
+ # Top-level JS/TS const with a literal scalar RHS. Anchored at line start (after
52
+ # optional ``export``) so indented constants inside functions are ignored.
53
+ _JS_CONST = re.compile(
54
+ r"""^(?:export\s+)?const\s+
55
+ ([A-Z][A-Z0-9_]*[A-Z0-9]) # NAME (UPPER_SNAKE)
56
+ \s*(?::[^=]+?)?\s*=\s* # optional : type =
57
+ (.+?)\s*;?\s*$ # the value, up to an optional ;
58
+ """,
59
+ re.MULTILINE | re.VERBOSE,
60
+ )
61
+
62
+ # A bare JS literal we are willing to report: number, quoted string, boolean.
63
+ _JS_NUMBER = re.compile(r"^-?\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?$")
64
+ _JS_STRING = re.compile(r"""^(['"`])(.*)\1$""", re.DOTALL)
65
+
66
+
67
+ def _is_const_name(name: str) -> bool:
68
+ return bool(_CONST_NAME.match(name))
69
+
70
+
71
+ def _kind_of(value: object) -> str:
72
+ # bool first: it is a subclass of int.
73
+ if isinstance(value, bool):
74
+ return "flag"
75
+ if isinstance(value, (int, float)):
76
+ return "number"
77
+ if isinstance(value, str):
78
+ return "text"
79
+ if isinstance(value, (list, tuple, set)):
80
+ return "list"
81
+ if isinstance(value, dict):
82
+ return "mapping"
83
+ return "other"
84
+
85
+
86
+ def _truncate(text: str, width: int = 60) -> str:
87
+ return text if len(text) <= width else text[: width - 3] + "..."
88
+
89
+
90
+ def _render_py_value(value: object) -> str:
91
+ if isinstance(value, str):
92
+ return '"' + _truncate(value) + '"'
93
+ return _truncate(repr(value))
94
+
95
+
96
+ def _py_constants(content: str) -> list[dict]:
97
+ """Module-level UPPER_SNAKE constants assigned a literal, in source order."""
98
+ try:
99
+ tree = ast.parse(content)
100
+ except (SyntaxError, ValueError):
101
+ return []
102
+
103
+ found: list[dict] = []
104
+ # Module body only — nested scopes are not project-wide settings.
105
+ for stmt in tree.body:
106
+ if isinstance(stmt, ast.Assign):
107
+ targets = stmt.targets
108
+ value_node = stmt.value
109
+ elif isinstance(stmt, ast.AnnAssign) and stmt.value is not None:
110
+ targets = [stmt.target]
111
+ value_node = stmt.value
112
+ else:
113
+ continue
114
+
115
+ # A single simple name target only (skip ``A = B = 1`` and tuple unpack).
116
+ if len(targets) != 1 or not isinstance(targets[0], ast.Name):
117
+ continue
118
+ name = targets[0].id
119
+ if not _is_const_name(name):
120
+ continue
121
+
122
+ try:
123
+ value = ast.literal_eval(value_node)
124
+ except (ValueError, SyntaxError, TypeError):
125
+ # Not a pure literal: type alias, computed value, call — not a knob.
126
+ continue
127
+ if value is None:
128
+ continue # a None sentinel is not a value a reader tweaks
129
+
130
+ found.append(
131
+ {
132
+ "name": name,
133
+ "kind": _kind_of(value),
134
+ "value": _render_py_value(value),
135
+ "line": stmt.lineno,
136
+ }
137
+ )
138
+ return found
139
+
140
+
141
+ def _js_constants(content: str) -> list[dict]:
142
+ """Top-level UPPER_SNAKE consts assigned a literal scalar, in source order."""
143
+ found: list[dict] = []
144
+ for match in _JS_CONST.finditer(content):
145
+ name, raw = match.group(1), match.group(2).strip()
146
+ # Drop a trailing comment on the same line.
147
+ raw = re.split(r"\s+//", raw, maxsplit=1)[0].strip().rstrip(";").strip()
148
+
149
+ if _JS_NUMBER.match(raw):
150
+ kind, value = "number", raw
151
+ elif raw in ("true", "false"):
152
+ kind, value = "flag", raw
153
+ else:
154
+ string = _JS_STRING.match(raw)
155
+ if not string or "${" in raw:
156
+ # Objects, arrays, calls, template interpolation: not a scalar knob.
157
+ continue
158
+ kind, value = "text", '"' + _truncate(string.group(2)) + '"'
159
+
160
+ line = content.count("\n", 0, match.start()) + 1
161
+ found.append({"name": name, "kind": kind, "value": value, "line": line})
162
+ return found
163
+
164
+
165
+ def find_tunable_settings(file_contents: dict[str, str], *, limit: int = 50) -> dict:
166
+ """Collect the literal UPPER_SNAKE constants a project hard-codes.
167
+
168
+ Args:
169
+ file_contents: mapping of path to file text (as CodeABC already read it).
170
+ limit: how many settings to return in the sorted list.
171
+
172
+ Returns ``{"total", "kinds", "settings"}`` where each setting is
173
+ ``{"name", "kind", "value", "path", "line"}`` and ``kind`` is one of
174
+ ``number`` / ``text`` / ``flag`` / ``list`` / ``mapping``.
175
+ """
176
+ seen: set[tuple[str, str, int]] = set()
177
+ settings: list[dict] = []
178
+
179
+ for path, content in file_contents.items():
180
+ if not content:
181
+ continue
182
+ if path.endswith(_PY_SUFFIX):
183
+ entries = _py_constants(content)
184
+ elif path.endswith(_JS_SUFFIXES):
185
+ entries = _js_constants(content)
186
+ else:
187
+ continue
188
+
189
+ for entry in entries:
190
+ key = (path, entry["name"], entry["line"])
191
+ if key in seen:
192
+ continue
193
+ seen.add(key)
194
+ settings.append({**entry, "path": path})
195
+
196
+ settings.sort(key=lambda s: (s["path"], s["line"]))
197
+ kinds = sorted({s["kind"] for s in settings})
198
+ return {"total": len(settings), "kinds": kinds, "settings": settings[:limit]}
199
+
200
+
201
+ _KIND_LABEL = {
202
+ "number": "数字",
203
+ "text": "文本",
204
+ "flag": "开关",
205
+ "list": "列表",
206
+ "mapping": "映射",
207
+ "other": "其它",
208
+ }
209
+
210
+
211
+ def render_settings_markdown(project_name: str, data: dict | None) -> str:
212
+ """Render the tunable-settings map as Markdown, or ``""`` if none were found."""
213
+ settings = (data or {}).get("settings") or []
214
+ if not settings:
215
+ return ""
216
+
217
+ lines = [
218
+ f"# {project_name} — 能改哪些值(可调设置)",
219
+ "",
220
+ "> 环境变量是“在代码外面要设置什么”,这里是“代码里面写死、你可能想改的值”——"
221
+ "作者用全大写常量声明的重试次数、超时、默认模型、开关之类。"
222
+ "改它们通常不用读懂整段代码,但请改前看清它在哪、是什么类型。",
223
+ "",
224
+ ]
225
+ current_path = None
226
+ for setting in settings:
227
+ if setting["path"] != current_path:
228
+ current_path = setting["path"]
229
+ lines.append(f"## `{current_path}`")
230
+ label = _KIND_LABEL.get(setting["kind"], setting["kind"])
231
+ lines.append(
232
+ f"- `{setting['name']}` = {setting['value']} ({label},第 {setting['line']} 行)"
233
+ )
234
+ return "\n".join(lines).rstrip() + "\n"