Skip to content

Commit 6a4e7a5

Browse files
committed
feat(settings): map the tunable constants a project hard-codes
The env-var surface answers "what do I set outside the code before it runs". This is the mirror question: "what is set inside the code that I might want to change". Almost every project bakes a handful of knobs — retry counts, timeouts, a default model, feature flags — into module-level UPPER_SNAKE constants, scattered across files and easy to miss. For a non-coder those lines are the closest thing the project has to a settings panel. New settings_map.find_tunable_settings reads them straight from the source already loaded (no LLM, nothing to run): module-level Python constants via AST and top-level JS/TS `const` via a line-anchored match. Only literal scalar or simple-collection values on UPPER_SNAKE names are kept, which cleanly drops type aliases (Vector = list[float]) and computed values (TIMEOUT = BASE * 2) — neither is a knob a reader can just edit. Each entry carries its name, value (rendered short), kind, file and line. Wired into ProjectMeta, the Overview UI (a new "what you can change" card) and the offline codemap export, alongside the env-var and data-model maps.
1 parent 9337408 commit 6a4e7a5

9 files changed

Lines changed: 448 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ CodeABC/
208208

209209
### Shipped
210210

211-
The reading experience is already complete end to end: a plain-language project manual, hover annotations, a terminology dictionary, natural-language editing, and snippet Q&A, all in a bilingual UI you launch with one command (or as a native Tauri desktop app). On top of that sit a dozen deterministic, no-API-key maps — reading map, core-module ranking, a project-wide definition index with find-all-references (look up any name to jump to where it's declared and list every place it's used), test coverage, git-history hotspots and ownership, tech-debt markers, env-var surface, entry points, CLI commands, the data-model shapes a project declares (dataclasses, pydantic models, TypedDicts and NamedTuples, with their fields and types), external integrations, silent-failure spots, under-documented files, and logic complexity. It all exports offline too: every map stitches into a single `codemap.md`, or a self-contained HTML report you can email to a non-technical stakeholder — no server, no API key, nothing fetched from the network. Analyses also persist to a small on-disk library (`GET /api/projects`), so you can list past projects and reopen one without remembering its id or re-scanning.
211+
The reading experience is already complete end to end: a plain-language project manual, hover annotations, a terminology dictionary, natural-language editing, and snippet Q&A, all in a bilingual UI you launch with one command (or as a native Tauri desktop app). On top of that sit a dozen deterministic, no-API-key maps — reading map, core-module ranking, a project-wide definition index with find-all-references (look up any name to jump to where it's declared and list every place it's used), test coverage, git-history hotspots and ownership, tech-debt markers, env-var surface, entry points, CLI commands, the data-model shapes a project declares (dataclasses, pydantic models, TypedDicts and NamedTuples, with their fields and types), the tunable settings hard-coded as UPPER_SNAKE constants (retry counts, timeouts, default model, feature flags — the values you might change without reading the code), external integrations, silent-failure spots, under-documented files, and logic complexity. It all exports offline too: every map stitches into a single `codemap.md`, or a self-contained HTML report you can email to a non-technical stakeholder — no server, no API key, nothing fetched from the network. Analyses also persist to a small on-disk library (`GET /api/projects`), so you can list past projects and reopen one without remembering its id or re-scanning.
212212

213213
### Planned
214214

README_CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ npm run tauri:dev
190190

191191
### 已交付
192192

193-
阅读体验已经完整跑通:大白话项目说明书、悬停批注、术语词典、自然语言编辑、片段问答,全部在双语界面里,一条命令就能启动(也可以打包成 Tauri 原生桌面应用)。在这之上是十几张无需 API Key 的确定性图谱:阅读路线、核心模块排序、全项目定义索引(查任何名字,跳到它的定义处、并列出所有用到它的地方)、测试覆盖、Git 历史热点与代码归属、技术债标记、环境变量清单、程序入口、外部依赖、错误被吞点、最缺文档的文件,还有逻辑复杂度。这些还能离线导出:所有图谱可以拼成一份 `codemap.md`,或者一份自包含的 HTML 报告,直接发给不懂技术的同事或老板——不用起服务、不用 API Key、不从网上拉任何东西。分析结果还会存进一个小的本地项目库(`GET /api/projects`),可以列出以前分析过的项目、重开其中一个,不用记住它的 id、也不用再扫一遍。
193+
阅读体验已经完整跑通:大白话项目说明书、悬停批注、术语词典、自然语言编辑、片段问答,全部在双语界面里,一条命令就能启动(也可以打包成 Tauri 原生桌面应用)。在这之上是十几张无需 API Key 的确定性图谱:阅读路线、核心模块排序、全项目定义索引(查任何名字,跳到它的定义处、并列出所有用到它的地方)、测试覆盖、Git 历史热点与代码归属、技术债标记、环境变量清单、程序入口、命令行命令、数据模型形状(dataclass / pydantic / TypedDict / NamedTuple 及其字段类型)、写死成全大写常量的可调设置(重试次数、超时、默认模型、开关——不读代码也能改的那些值)、外部依赖、错误被吞点、最缺文档的文件,还有逻辑复杂度。这些还能离线导出:所有图谱可以拼成一份 `codemap.md`,或者一份自包含的 HTML 报告,直接发给不懂技术的同事或老板——不用起服务、不用 API Key、不从网上拉任何东西。分析结果还会存进一个小的本地项目库(`GET /api/projects`),可以列出以前分析过的项目、重开其中一个,不用记住它的 id、也不用再扫一遍。
194194

195195
### 后续规划
196196

backend/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,14 @@ class DataModel(BaseModel):
232232
line: int
233233

234234

235+
class TunableSetting(BaseModel):
236+
name: str # the constant's UPPER_SNAKE name
237+
kind: str # "number" | "text" | "flag" | "list" | "mapping" | "other"
238+
value: str # the literal value, rendered short (strings quoted, long ones truncated)
239+
path: str
240+
line: int
241+
242+
235243
class ComplexFile(BaseModel):
236244
path: str
237245
complexity: int # approximate cyclomatic complexity (decision points + 1)
@@ -363,6 +371,7 @@ class ProjectMeta(BaseModel):
363371
entry_points: list[EntryPoint] = []
364372
cli_commands: list[CliCommand] = []
365373
data_models: list[DataModel] = []
374+
tunable_settings: list[TunableSetting] = []
366375
complexity_files: list[ComplexFile] = []
367376
dependencies: list[Dependency] = []
368377
security: SecuritySummary | None = None

backend/routers/project.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
SilentFailure,
6161
TechDebtFile,
6262
TestCoverageSummary,
63+
TunableSetting,
6364
UploadedFile,
6465
)
6566
from backend.services import (
@@ -90,6 +91,7 @@
9091
risk,
9192
scanner,
9293
security,
94+
settings_map,
9395
symbols,
9496
techdebt,
9597
)
@@ -202,6 +204,7 @@ def _content_analyses(
202204
entries = entrypoints.find_entry_points(file_contents)
203205
cli_commands = commands.find_cli_commands(file_contents)
204206
data_models = datamodels.find_data_models(file_contents)
207+
tunable = settings_map.find_tunable_settings(file_contents)
205208
complex_files = complexity.scan_complexity(file_contents)
206209
deps = dependencies.scan_dependencies(file_contents)
207210
sec = security.scan_security(file_contents)
@@ -255,6 +258,16 @@ def _content_analyses(
255258
)
256259
for m in data_models["models"]
257260
],
261+
"tunable_settings": [
262+
TunableSetting(
263+
name=s["name"],
264+
kind=s["kind"],
265+
value=s["value"],
266+
path=s["path"],
267+
line=s["line"],
268+
)
269+
for s in tunable["settings"]
270+
],
258271
"complexity_files": [
259272
ComplexFile(
260273
path=f["path"],

backend/services/codemap_export.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
ownership,
3131
risk,
3232
security,
33+
settings_map,
3334
techdebt,
3435
)
3536
from backend.services import (
@@ -66,6 +67,7 @@ def _ordered_sections(proj: dict) -> list[str]:
6667
entrypoints.render_entrypoints_markdown(name, entrypoints.find_entry_points(contents)),
6768
commands.render_commands_markdown(name, commands.find_cli_commands(contents)),
6869
datamodels.render_data_models_markdown(name, datamodels.find_data_models(contents)),
70+
settings_map.render_settings_markdown(name, settings_map.find_tunable_settings(contents)),
6971
complexity.render_complexity_markdown(name, complexity.scan_complexity(contents)),
7072
dependencies.render_dependencies_markdown(name, dependencies.scan_dependencies(contents)),
7173
security.render_security_markdown(name, security.scan_security(contents)),

backend/services/settings_map.py

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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"

frontend/src/lib/api.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,14 @@ export interface CliCommand {
197197
line: number;
198198
}
199199

200+
export interface TunableSetting {
201+
name: string;
202+
kind: string; // "number" | "text" | "flag" | "list" | "mapping" | "other"
203+
value: string;
204+
path: string;
205+
line: number;
206+
}
207+
200208
export interface ComplexFile {
201209
path: string;
202210
complexity: number;
@@ -344,6 +352,7 @@ export interface ProjectMeta {
344352
env_vars?: EnvVar[];
345353
entry_points?: EntryPoint[];
346354
cli_commands?: CliCommand[];
355+
tunable_settings?: TunableSetting[];
347356
complexity_files?: ComplexFile[];
348357
doc_coverage?: DocCoverage;
349358
error_handling?: ErrorHandling;

0 commit comments

Comments
 (0)