Files
group_fqcd_jr/tools/check_portal_modules.py
lzf_0626 e096ffab22 修复投顾工作台白板:补上模块拆分时漏掉的 import
## 问题(合并进来的故障,不是本次会话改坏的)

合并 `origin/qyqy_develop`(f5d1b24 / 3134fe5)后 `pytest` 红了一条:
`test_advisor_dashboard_is_composed_from_feature_modules`。

查下去发现是**拆分做了一半**:

- 新建了 `advisor-config.js` / `actions-module.js` / `published-module.js`
- 把 `CONTENT_TYPE_LABELS`、`actionLabels`、`resultMessages` 从 `dashboard.js` 删掉了
- **但没有在 `dashboard.js` 里 import 它们**,`dashboard.js` 仍是拆分前的内联版本,
  第 39 行还在用 `CONTENT_TYPE_LABELS`

后果不只是测试红:投顾工作台一打开就 `ReferenceError: CONTENT_TYPE_LABELS is not defined`,
页面渲染不出来;同时那两个新模块是**死代码**(没有任何地方 import 它们)。
`index.html` 是单入口(只加载 `dashboard.js`),所以模块必须由它 import。

## 修法:把重构接完,而不是把测试改掉

- `dashboard.js` 变成薄组合层:挂 shell、取 DOM、组合两个模块,其余逻辑不再内联
- `advisor-config.js` 收拢 `GOAL_STATUS_LABELS` / `BOOK_STATUS_LABELS`(原来内联在 dashboard.js)
- `actions-module.js` 接管「目标确认与方案书」
- `published-module.js` 直接可用

⚠️ 关键点:`bind()` 会给**所有** `[data-action]` 按钮挂 `open()`,而 `actions-module.js`
原先不认识 `goal-status` —— 直接接线会让它掉到最后一行的兜底分支、被当成
「资产配置」发出去(点"目标确认与方案书"却收到一份配置建议)。
所以把「目标确认与方案书」一并做进 `open()` 的分支里,并在两处留了注释说明这个约束。

「目标确认与方案书」这条功能本身要保留:此前工作台只有 4 个"生成草案"操作 + 1 个只读列表,
而确认目标与查看方案书这两个端点**有接口没入口**,导致目标永远停在 `pending_confirmation`、
方案书永远停在 `pending`(实测客户 9001 正是如此)。

## 防回归:tools/check_portal_modules.py(新)

上面那个 bug **不能靠现有断言发现** —— 那些测试断言的是"某个字符串在文件里出现",
而这里的问题是"定义搬走了、使用处还在",浏览器里才炸,Python 测试全绿。

新检查做四件事:`node --check` 按 ES module 解析语法、相对 import 的目标文件存在、
import 的名字在目标文件里真有 `export`、**用到的全大写常量必须有来源**。

第 4 条是抓这个 bug 的关键。写的时候踩了两次坑,都已修正并记录在文件里:

1. 第一版用 `(?<![\w.$])` 排除属性访问、却把**模板字符串整体**当字符串剔除了 ——
   而 `CONTENT_TYPE_LABELS[row.content_type]` 恰好写在模板字符串里,
   于是漏报、检查全绿。现在只剔除单双引号字符串,模板字符串保留(`${}` 里是真代码)。
2. 用负面验证确认它真的有效:把 `published-module.js` 的 import 拿掉后,
   检查精确报出 `使用了 'CONTENT_TYPE_LABELS',但既没 import 也没在本文件声明`(exit 1);
   恢复后 exit 0。没有这一步,这个检查就是个摆设。

同时接进测试:`test_portal_feature_modules_have_consistent_imports` 调用它,
保证以后每次 `pytest tests/unit` 都会执行。

## 实测

- `pytest tests/unit tests/contract` -> **1400 passed, 2 skipped, 0 failed**
  (合并后未修时是 1399 passed + 1 failed)
- `pytest tests/unit/api/test_portal_frontend.py` -> 39 passed(38 + 新增 1 条)
- `python tools/check_portal_modules.py` -> 全部通过;负面验证 exit 1
- `ruff check app tests tools alembic hq.py` -> All checks passed
2026-09-14 02:07:44 +08:00

183 lines
7.7 KiB
Python

"""验证投顾工作台四个前端模块:语法正确、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 条是启发式,会刻意避开引号内的字符串与对象字面量的 key,减少误报。
"""
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",
"published-module.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<names>\{[^}]*\})|(?P<default>[\w$]+))\s+from\s+['\"](?P<src>[^'\"]+)['\"]"
)
EXPORT_RE = re.compile(
r"export\s+(?:const|let|var|function|class)\s+(?P<name>[\w$]+)"
r"|export\s*\{(?P<list>[^}]*)\}"
)
#: 全大写常量:至少两个字符、全大写/数字/下划线,且不是"前面带点"的属性访问。
CONST_USE_RE = re.compile(r"(?<![\w.$])([A-Z][A-Z0-9_]{2,})\b")
#: 单双引号包裹的内容,检查常量使用时先剔除。
#: ⚠️ **反引号模板字符串不能剔除**:`${...}` 里面是真代码,而这类 bug 恰恰
#: 常常发生在模板字符串里 —— 第一版把整段模板字符串也剔了,结果
#: `published-module.js` 少了 `CONTENT_TYPE_LABELS` 的 import 时**检查依然全绿**
#: (实测确认,差点让这个检查变成摆设)。宁可对模板里的中文文案误报,也不能漏。
STRING_RE = re.compile(r"'[^'\n]*'|\"[^\"\n]*\"")
#: 对象字面量里作为 key 的大写标识符(`{ FOO: 1 }`)不算使用。
OBJECT_KEY_RE = re.compile(r"(?<![\w.$])([A-Z][A-Z0-9_]{2,})\s*:")
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,避免把文案/枚举 key 当成变量使用。
stripped = OBJECT_KEY_RE.sub(" ", STRING_RE.sub(" ", 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())