2026-09-14 22:17:39 +08:00
|
|
|
|
"""验证投顾工作台的前端模块:语法正确、import 能解析、用到的符号有来源。
|
2026-09-14 02:07:44 +08:00
|
|
|
|
|
|
|
|
|
|
## 为什么要这个检查
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-13 合并组员提交后发现:`dashboard.js` 被拆成三个模块后,
|
|
|
|
|
|
**`CONTENT_TYPE_LABELS` 的定义搬走了、import 却忘了加** —— 文件里还在用这个常量。
|
|
|
|
|
|
这类问题单元测试抓不到(前端 js 不过 Python 的导入检查),
|
|
|
|
|
|
只会在浏览器里以 `ReferenceError: CONTENT_TYPE_LABELS is not defined` 的形式爆出来,
|
|
|
|
|
|
表现为「投顾工作台打开是白板」,而且**测试全绿**。
|
|
|
|
|
|
|
2026-09-14 22:17:39 +08:00
|
|
|
|
所以这里做四件事:
|
2026-09-14 02:07:44 +08:00
|
|
|
|
1. 每个文件交给 `node --check` 按 ES module 解析(语法错当场暴露);
|
|
|
|
|
|
2. 每个相对 import 的目标文件必须存在;
|
|
|
|
|
|
3. 每个 import 进来的名字,在目标文件里必须真的有 `export`;
|
|
|
|
|
|
4. 文件里用到的全大写常量(`FOO_BAR` 形态、且不是 `x.FOO_BAR` 属性访问),
|
|
|
|
|
|
必须在「import 进来的 + 本文件声明的 + 已知全局」里 —— 第 4 条正是抓上面那个 bug 的。
|
|
|
|
|
|
|
2026-09-14 22:17:39 +08:00
|
|
|
|
## 第 4 条的取词范围(`code_only()`)
|
|
|
|
|
|
|
|
|
|
|
|
只保留**真代码**:注释、`'…'` / `"…"` 字符串、以及模板字符串里的**文字部分**都被清空,
|
|
|
|
|
|
唯独 `${…}` 里的表达式保留下来(那里是真代码,也正是原 bug 容易藏身的地方)。
|
|
|
|
|
|
|
|
|
|
|
|
早期版本只剔除引号字符串,结果注释里的 `HHI`、文案里的 `AUM`、模板串里的 `QDII`
|
|
|
|
|
|
全被当成"未声明的变量" —— 误报比真问题还多,检查会被当成摆设跳过。
|
2026-09-14 02:07:44 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
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",
|
2026-09-16 18:17:46 +08:00
|
|
|
|
"history-module.js",
|
|
|
|
|
|
"service-request-module.js",
|
2026-09-14 02:07:44 +08:00
|
|
|
|
"published-module.js",
|
2026-09-14 22:17:39 +08:00
|
|
|
|
"assistant-module.js",
|
|
|
|
|
|
"customer-module.js",
|
|
|
|
|
|
"advisor-engine.js",
|
2026-09-14 02:07:44 +08:00
|
|
|
|
"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")
|
|
|
|
|
|
#: 对象字面量里作为 key 的大写标识符(`{ FOO: 1 }`)不算使用。
|
|
|
|
|
|
OBJECT_KEY_RE = re.compile(r"(?<![\w.$])([A-Z][A-Z0-9_]{2,})\s*:")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-14 22:17:39 +08:00
|
|
|
|
def code_only(source: str) -> 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-14 02:07:44 +08:00
|
|
|
|
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)
|
2026-09-14 22:17:39 +08:00
|
|
|
|
# 先清掉注释 / 字符串 / 模板文字,再排除对象字面量的 key。
|
|
|
|
|
|
stripped = OBJECT_KEY_RE.sub(" ", code_only(source))
|
2026-09-14 02:07:44 +08:00
|
|
|
|
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())
|