|
| 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" |
0 commit comments