"""验证投顾工作台的前端模块:语法正确、import 能解析、用到的符号有来源。 ## 为什么要这个检查 2026-09-13 合并组员提交后发现:`dashboard.js` 被拆成三个模块后, **`CONTENT_TYPE_LABELS` 的定义搬走了、import 却忘了加** —— 文件里还在用这个常量。 这类问题单元测试抓不到(前端 js 不过 Python 的导入检查), 只会在浏览器里以 `ReferenceError: CONTENT_TYPE_LABELS is not defined` 的形式爆出来, 表现为「投顾工作台打开是白板」,而且**测试全绿**。 所以这里做四件事: 1. 每个文件交给 `node --check` 按 ES module 解析(语法错当场暴露); 2. 每个相对 import 的目标文件必须存在; 3. 每个 import 进来的名字,在目标文件里必须真的有 `export`; 4. 文件里用到的全大写常量(`FOO_BAR` 形态、且不是 `x.FOO_BAR` 属性访问), 必须在「import 进来的 + 本文件声明的 + 已知全局」里 —— 第 4 条正是抓上面那个 bug 的。 ## 第 4 条的取词范围(`code_only()`) 只保留**真代码**:注释、`'…'` / `"…"` 字符串、以及模板字符串里的**文字部分**都被清空, 唯独 `${…}` 里的表达式保留下来(那里是真代码,也正是原 bug 容易藏身的地方)。 早期版本只剔除引号字符串,结果注释里的 `HHI`、文案里的 `AUM`、模板串里的 `QDII` 全被当成"未声明的变量" —— 误报比真问题还多,检查会被当成摆设跳过。 """ from __future__ import annotations import re import shutil import subprocess import sys import tempfile from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[1] PORTAL = PROJECT_ROOT / "app" / "static" / "portal" DASHBOARD_DIR = PORTAL / "employee-advisor" / "dashboard" MODULES = [ "dashboard.js", "actions-module.js", "history-module.js", "service-request-module.js", "published-module.js", "assistant-module.js", "customer-module.js", "advisor-engine.js", "advisor-config.js", ] #: 浏览器与运行时天然存在的名字,不算"没来源"。 KNOWN_GLOBALS = { "JSON", "Math", "Number", "String", "Boolean", "Object", "Array", "Promise", "Date", "Error", "Map", "Set", "RegExp", "Symbol", "URL", "URLSearchParams", "NaN", "Infinity", "undefined", "null", "true", "false", "console", "window", "document", "localStorage", "sessionStorage", "fetch", "setTimeout", "clearTimeout", "setInterval", "clearInterval", "requestAnimationFrame", "HTMLElement", "Node", "Event", "CustomEvent", "AbortController", } IMPORT_RE = re.compile( r"import\s+(?:(?P\{[^}]*\})|(?P[\w$]+))\s+from\s+['\"](?P[^'\"]+)['\"]" ) EXPORT_RE = re.compile( r"export\s+(?:const|let|var|function|class)\s+(?P[\w$]+)" r"|export\s*\{(?P[^}]*)\}" ) #: 全大写常量:至少两个字符、全大写/数字/下划线,且不是"前面带点"的属性访问。 CONST_USE_RE = re.compile(r"(? str: """只保留真代码:清空注释、引号字符串与模板字符串的文字部分(`${…}` 里的表达式保留)。 逐字符扫描而不是一把正则:模板字符串里可以嵌套 `${…}`(甚至再嵌模板), 正则匹配不了嵌套,只能老老实实走一遍。 """ out: list[str] = [] index = 0 length = len(source) while index < length: char = source[index] following = source[index + 1] if index + 1 < length else "" if char == "/" and following == "*": end = source.find("*/", index + 2) index = length if end == -1 else end + 2 out.append(" ") continue if char == "/" and following == "/": end = source.find("\n", index) index = length if end == -1 else end out.append(" ") continue if char in "'\"": cursor = index + 1 while cursor < length: if source[cursor] == "\\": cursor += 2 continue if source[cursor] == char: break cursor += 1 out.append(" ") index = min(cursor + 1, length) continue if char == "`": index += 1 while index < length: if source[index] == "\\": index += 2 continue if source[index] == "`": index += 1 break if source[index] == "$" and index + 1 < length and source[index + 1] == "{": depth = 1 index += 2 start = index while index < length and depth: if source[index] == "{": depth += 1 elif source[index] == "}": depth -= 1 if depth == 0: break index += 1 out.append(" " + source[start:index] + " ") index += 1 continue index += 1 out.append(" ") continue out.append(char) index += 1 return "".join(out) def exported_names(path: Path) -> set[str]: source = path.read_text(encoding="utf-8") names: set[str] = set() for match in EXPORT_RE.finditer(source): if match.group("name"): names.add(match.group("name")) if match.group("list"): for piece in match.group("list").split(","): piece = piece.strip() if not piece: continue names.add(piece.split(" as ")[-1].strip()) return names def resolve_import(source: Path, spec: str) -> Path | None: """相对 import 才能解析到本地文件;`/static/...` 这类站内绝对路径跳过。""" if not spec.startswith("."): return None target = (source.parent / spec.split("?")[0]).resolve() return target if target.is_file() else None def check_syntax(paths: list[Path]) -> list[str]: """用 node 按 ES module 解析。装不了 node 就跳过(不让环境问题变成假失败)。""" node = shutil.which("node") if node is None: return [] problems: list[str] = [] with tempfile.TemporaryDirectory() as tmp: for path in paths: # `node --check` 按扩展名决定模块类型,所以复制成 .mjs 再检查。 probe = Path(tmp) / (path.stem + ".mjs") probe.write_text(path.read_text(encoding="utf-8"), encoding="utf-8") result = subprocess.run( [node, "--check", str(probe)], capture_output=True, text=True, check=False ) if result.returncode != 0: tail = (result.stderr or "").strip().splitlines() problems.append(f"{path.name}: 语法错误 {tail[-1] if tail else ''}") return problems def main() -> int: paths = [DASHBOARD_DIR / name for name in MODULES] missing = [p.name for p in paths if not p.is_file()] if missing: print(f"[失败] 缺少文件:{missing}") return 1 problems = check_syntax(paths) for path in paths: source = path.read_text(encoding="utf-8") # ---- import 的目标必须存在,且名字必须真被导出 ---- imported: set[str] = set() for match in IMPORT_RE.finditer(source): spec = match.group("src") if match.group("names"): for piece in match.group("names").strip("{}").split(","): piece = piece.strip() if piece: imported.add(piece.split(" as ")[-1].strip()) if match.group("default"): imported.add(match.group("default")) target = resolve_import(path, spec) if target is None: if spec.startswith("."): problems.append(f"{path.name}: import '{spec}' 指向的文件不存在") continue available = exported_names(target) wanted = set() if match.group("names"): for piece in match.group("names").strip("{}").split(","): piece = piece.strip() if piece: wanted.add(piece.split(" as ")[0].strip()) for name in wanted - available: problems.append( f"{path.name}: 从 {target.name} 导入了 '{name}',但那边没有导出它" ) # ---- 用到的全大写常量必须有来源 ---- declared = set(re.findall(r"(?:const|let|var|function|class)\s+([A-Z][A-Z0-9_]{2,})\b", source)) declared |= exported_names(path) # 先清掉注释 / 字符串 / 模板文字,再排除对象字面量的 key。 stripped = OBJECT_KEY_RE.sub(" ", code_only(source)) for match in CONST_USE_RE.finditer(stripped): name = match.group(1) if name in imported or name in declared or name in KNOWN_GLOBALS: continue problems.append(f"{path.name}: 使用了 '{name}',但既没 import 也没在本文件声明") print(f"检查 {len(paths)} 个模块:{', '.join(p.name for p in paths)}") if problems: print(f"\n发现 {len(problems)} 个问题:") for problem in problems: print(f" · {problem}") print( "\n提示:拆分模块时最容易漏 import —— 定义搬走了、使用处还留在原文件," "\n 浏览器里表现为页面白板 + ReferenceError,而 Python 测试全绿。" ) return 1 print("全部通过:语法正确、import 可解析、常量都有来源。") return 0 if __name__ == "__main__": sys.exit(main())