feat(i18n): add full i18n audit toolchain and pre-commit hook
- 新增 GitHub Actions 工作流实现 PR 时自动审计国际化内容 - 添加 pre-commit 钩子,提交前自动检查相关文件并拦截问题提交 - 编写完整的 i18n 审计脚本、文档与示例报告 - 实现多维度国际化校验:键匹配、占位符、data()误用、硬编码标题等
This commit is contained in:
33
.github/workflows/i18n-audit.yml
vendored
Normal file
33
.github/workflows/i18n-audit.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
# GitHub Actions 示例:i18n audit on PR
|
||||
name: i18n-audit
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'lang/**'
|
||||
- 'lang-bundle.json'
|
||||
- '**/*.vue'
|
||||
- '**/*.js'
|
||||
- 'scripts/i18n_audit.py'
|
||||
- 'scripts/audit_lang_bundle_v2.py'
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Run i18n audit
|
||||
run: |
|
||||
python scripts/i18n_audit.py --strict
|
||||
- name: Upload audit report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-audit-report
|
||||
path: scripts/lang_audit_report_v2.json
|
||||
43
.husky/pre-commit
Normal file
43
.husky/pre-commit
Normal file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env sh
|
||||
#
|
||||
# 自动 hook:i18n 完整性。
|
||||
# 在 commit 前,如果改动触及 lang/、使用 $lang() 的 .vue/.js,或 lang-bundle.json,
|
||||
# 就跑一次审计,真问题会拦下 commit。
|
||||
#
|
||||
# 启用方法:
|
||||
# 1) 项目根已有 .husky/pre-commit(本脚本)
|
||||
# 2) 把这个 commit-msg / pre-commit 加入 husky 钩子(本仓库用 simple-git-hooks
|
||||
# 或 husky 都行,本脚本不依赖特定框架)
|
||||
#
|
||||
# 也可以在 CI 跑:
|
||||
# python scripts/i18n_audit.py --strict
|
||||
|
||||
set -e
|
||||
|
||||
# 检测本次 diff 是否触动相关文件
|
||||
changed=$(git diff --cached --name-only HEAD)
|
||||
|
||||
relevant=$(echo "$changed" | grep -E '(lang/|lang-bundle\.json|\.vue$|\.js$)' | grep -v 'scripts/i18n_audit\|scripts/audit\|scripts/I18N_AUDIT' || true)
|
||||
|
||||
if [ -z "$relevant" ]; then
|
||||
echo "[pre-commit] No lang-touching files; skip i18n audit."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[pre-commit] Running i18n audit on changed files:"
|
||||
echo "$relevant" | sed 's/^/ /'
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
if python scripts/i18n_audit.py --strict; then
|
||||
echo "[pre-commit] i18n OK"
|
||||
exit 0
|
||||
else
|
||||
echo "[pre-commit] i18n FAILED — 修复方法见 scripts/I18N_AUDIT.md"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "[pre-commit] python not available; skipping"
|
||||
exit 0
|
||||
fi
|
||||
3463
lang-bundle.json
Normal file
3463
lang-bundle.json
Normal file
File diff suppressed because it is too large
Load Diff
104
scripts/I18N_AUDIT.md
Normal file
104
scripts/I18N_AUDIT.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# i18n 审计流程
|
||||
|
||||
> 把所有"$lang 是否能正常翻译"的检查收口到一个入口:
|
||||
> **新增组件 / 新增翻译 / 拉取新 bundle.json 之后,跑这一个命令就能出报告。**
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 标准体检(最常用)
|
||||
python scripts/i18n_audit.py
|
||||
|
||||
# 严格模式 — 出现真问题就 exit 1,适合 CI / pre-commit hook
|
||||
python scripts/i18n_audit.py --strict
|
||||
|
||||
# 拉了新 bundle.json(覆盖了 lang-bundle.json),指定新的:
|
||||
python scripts/i18n_audit.py --bundle ./lang-bundle.2026-07-04.json
|
||||
```
|
||||
|
||||
报告结束时会输出:
|
||||
|
||||
```
|
||||
健康度评分:
|
||||
✓ 99.95% 翻译覆盖,0 个真问题
|
||||
或
|
||||
! 存在 N 类问题需要修:
|
||||
- <类目 1>
|
||||
- <类目 2>
|
||||
```
|
||||
|
||||
完整结构化报告写到 `scripts/lang_audit_report_v2.json`,适合 diff 或后续处理。
|
||||
|
||||
## 它在做什么(7 个检查)
|
||||
|
||||
| # | 检查维度 | 暴露什么问题 | 真实程度 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | **静态 `$lang(key)` 字面 → bundle leaf** | key 字符串不存在于 bundle | 直接失效 |
|
||||
| 2 | **占位符参数匹配** | `key` 含 `{name}` 但调用没传 `{ name: ... }`,运行时会**字面显示 `{name}`** | 直接失效 |
|
||||
| 3 | **`data()` 中误用** | `data() { return { x: this.$lang(...) } }` 在 mp/鸿蒙 remote-only 模式下会**永久缓存空串** | 远程首屏失效 |
|
||||
| 4 | **bundle ↔ 本地 lang drift** | `lang/zh-cn/...` 有的 key,bundle 没有(可能影响兜底) | 加载策略变更时显现 |
|
||||
| 5 | **pages.json 硬编码导航标题** | `navigationBarTitleText: "商品分类"`,运行时**必须 `lang.refresh()` 兜底** | 取决于 refresh() 调用时机 |
|
||||
| 6 | **i18n-lazy mixin 使用情况** | 哪些组件没遵循延迟初始化约定 | 标记数据风险点 |
|
||||
| 7 | **P2 动态 key** (`verify-p2-keys.js`) | 拼接类 `$lang('prefix.' + var)` 在所有值下都能解析 | 模拟 runtime |
|
||||
|
||||
## 误报防御(已针对历史坑修过)
|
||||
|
||||
- **JS 注释剥离** 改用等长空白,正则偏移不偏(以前的 ghost key 大都是这个原因)
|
||||
- **`[^'\"]+` 跨换行陷阱** 已用手动 paren-depth 解析替代,不会把整个文件吃进 key
|
||||
- **JS 对象简写** `{ appName }` 等同于 `{ appName: appName }`,正则同时识别
|
||||
- **`/api/lang/bundle` 真会有的** `tabBar.*` 走 root level,审计对 `common.tabBar.home` 故意不报警
|
||||
- **`lang.js` JSDoc 内** 的 `$lang('title')`、`$lang('welcomeText')` 示例被注释剥离规则前置过滤
|
||||
|
||||
## 文件清单
|
||||
|
||||
```
|
||||
scripts/
|
||||
├── i18n_audit.py ← 唯一入口(run this)
|
||||
├── audit_lang_bundle_v2.py ← 静态扫描(被 i18n_audit.py 调)
|
||||
├── audit_lang_bundle.js ← Node 端 lang 加载工具
|
||||
├── verify-p2-keys.js ← 动态 key 验证(被 i18n_audit.py 调)
|
||||
├── I18N_AUDIT.md ← 本文档
|
||||
└── lang_audit_report_v2.json ← 最近一次报告(自动生成)
|
||||
```
|
||||
|
||||
## 何时该跑
|
||||
|
||||
1. **拉了新 bundle.json**:`/api/lang/bundle` 返回结果存到 `lang-bundle.json` 后,跑一次。
|
||||
2. **新增 / 修改翻译 key**:`lang/zh-cn/...` 或 `lang/en-us/...` 文件改动后,跑一次。
|
||||
3. **新增组件**:任何 .vue/.js 引入 `$lang(...)` 后,跑一次。
|
||||
4. **修复 i18n bug 后**:跑一次,确认`健康度评分`达 `99.95%`。
|
||||
5. **PR 合并前(CI)**:`python scripts/i18n_audit.py --strict`,exit code 控制是否合入。
|
||||
|
||||
## 想加新的检查怎么办
|
||||
|
||||
在 `audit_lang_bundle_v2.py` 加一个 section,产生新字段 → 在 `i18n_audit.py::summarize` 里加一行 `print`。`audit_lang_bundle_v2.py` 写好了自动落入结构化报告。
|
||||
|
||||
## 已知"非 bug 但需注意"(已知风险不需修)
|
||||
|
||||
- **`pages.json` 中 20 个 `navigationBarTitleText` 硬编码**:由 `lang.refresh()` 自动覆盖,只要 refresh 在页面 mount 后被调到即可。
|
||||
- **`components-diy/diy-tab.vue` 的 title 是运行时 DIY 数据**:DIY 数据规范/白名单层面解决,不在审计范围。
|
||||
|
||||
## 最近一次结果速查
|
||||
|
||||
```bash
|
||||
cat scripts/lang_audit_report_v2.json
|
||||
```
|
||||
|
||||
主要字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"code_calls_total": 2675,
|
||||
"code_unique_keys": 2083,
|
||||
"found_in_bundle": 2082,
|
||||
"missing_summary": {"SYNTAX_BUG": 1},
|
||||
"missing_classified": {...},
|
||||
"placeholder_issues": [],
|
||||
"data_lang_issues": {...},
|
||||
"hardcoded_navbar_titles": [...],
|
||||
"tabbar_missing": [...],
|
||||
"local_only_keys": []
|
||||
}
|
||||
```
|
||||
|
||||
`placeholder_issues` 为 `[]` 是绿;`data_lang_issues` 为 `{}` 是绿;`missing_summary.SYNTAX_BUG` ≥ 1 通常是 grep 误报,详见 `missing_classified` 字段的 `reason`。
|
||||
479
scripts/audit_lang_bundle_v2.py
Normal file
479
scripts/audit_lang_bundle_v2.py
Normal file
@@ -0,0 +1,479 @@
|
||||
"""
|
||||
Robust $lang vs bundle audit (v2).
|
||||
|
||||
Improvements over v1:
|
||||
1. Comment/JSDoc-aware:SYNTAX_BUG 判定前置剥离注释(///*)后再判断
|
||||
2. Format 占位符检查: 检测 bundle 里 translation 含 {name}/{0} 占位符,
|
||||
但调用点没有提供参数(或参数不匹配)的情况
|
||||
3. data() 内调用检测: 在 remote-only 模式下,data() 里直接调 $lang(key) 会
|
||||
在 boot 完成前把空串缓存到 data 字段 —— 标记为风险点(使用 i18n-lazy
|
||||
mixin 的组件是安全的)
|
||||
4. pages.json 硬编码 navigationBarTitleText 警告: 这些标题不随语言切换变化
|
||||
(除非 lang.refresh() 被显式调用)
|
||||
5. tabBar 翻译完整性: 检查 common.tabBar.{home,category,cart,member} 是否齐
|
||||
|
||||
Usage:
|
||||
python scripts/audit_lang_bundle_v2.py
|
||||
"""
|
||||
import json, os, re, subprocess
|
||||
from collections import defaultdict
|
||||
|
||||
ROOT = r'D:\projects\shop-projects\frontend'
|
||||
BUNDLE_PATH = os.path.join(ROOT, 'lang-bundle.json')
|
||||
NODE_SCRIPT = os.path.join(ROOT, 'scripts', 'audit_lang_bundle.js')
|
||||
SKIP_DIRS = {'node_modules', '.unpackage', 'unpackage', '.git', 'dist', 'stub-app-projects'}
|
||||
all_files = []
|
||||
for dirpath, dirnames, filenames in os.walk(ROOT):
|
||||
if any(s in dirpath for s in SKIP_DIRS):
|
||||
continue
|
||||
for fn in filenames:
|
||||
if fn.endswith(('.vue', '.js')):
|
||||
all_files.append(os.path.join(dirpath, fn))
|
||||
|
||||
# ---------- 1) Load bundle ----------
|
||||
with open(BUNDLE_PATH, 'r', encoding='utf-8') as f:
|
||||
bundle = json.load(f)
|
||||
tree = bundle['data']['tree']
|
||||
|
||||
bundle_leaves = set()
|
||||
def walk(obj, prefix=''):
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
np = f'{prefix}.{k}' if prefix else k
|
||||
if isinstance(v, dict):
|
||||
walk(v, np)
|
||||
else:
|
||||
bundle_leaves.add(np)
|
||||
walk(tree)
|
||||
|
||||
# ---------- 2) Strip comments ----------
|
||||
# JS/Vue 有 // 与 /* */ 注释.我们在提取 $lang 调用之前先把它们剥离,
|
||||
# 避免注释里的 '$lang(\"xxx\")' 占位被误判为运行时调用
|
||||
def strip_comments(text, is_vue=False):
|
||||
# 去掉块注释 /* ... */
|
||||
out = re.sub(r'/\*[\s\S]*?\*/', lambda m: ' ' * (m.end() - m.start()) if not is_vue else ' ' * (m.end() - m.start()), text)
|
||||
# 但 vue 模板里也可能有 <!-- comment --> —— 不处理(那里面几乎不会有 $lang)
|
||||
# 去掉行注释 // ... (保留 \n 让行号不变)
|
||||
out = re.sub(r'//[^\n]*', lambda m: ' ' * len(m.group()), out)
|
||||
return out
|
||||
|
||||
# ---------- 3) Extract $lang calls ----------
|
||||
# 注意:PARAM_RE 之前用 `[^)]*` 抓参,但反模式 — `[^)]` 跨匹配换行,
|
||||
# 会一路吃到全文件末尾的下一个 ')'。改成只匹配到行尾:
|
||||
CALL_RE = re.compile(r"\$lang\(\s*(['\"])([^'\"]+)\1")
|
||||
PARAM_RE = re.compile(r"\$lang\(\s*(['\"])([^'\"]+)\1") # 用同一组做 key 抓取,params 改手动解析
|
||||
|
||||
def extract_calls(content, file):
|
||||
# 先剥离注释,再做正则 (注意:strip 后行号会变,但相对偏移我们记录原始位置即可)
|
||||
stripped = strip_comments(content)
|
||||
code_calls = []
|
||||
for m in CALL_RE.finditer(stripped):
|
||||
# Skip matches inside remaining strings (rare)
|
||||
code_calls.append({'file': file, 'key': m.group(2), 'pos': m.start()})
|
||||
return code_calls
|
||||
|
||||
# Param extraction
|
||||
def extract_calls_with_args(content, file):
|
||||
stripped = strip_comments(content)
|
||||
results = []
|
||||
for m in PARAM_RE.finditer(stripped):
|
||||
key = m.group(2)
|
||||
# Look for the corresponding raw text after this position in original
|
||||
# to extract params
|
||||
# Strip the regex capture; we instead scan AFTER the key quote
|
||||
pos = m.end()
|
||||
rest = stripped[pos:]
|
||||
# Stop at the matching close paren of $lang
|
||||
params = []
|
||||
if rest.lstrip().startswith(','):
|
||||
rest = rest.lstrip()[1:]
|
||||
depth = 1
|
||||
i = 0
|
||||
in_str = None
|
||||
esc = False
|
||||
while i < len(rest) and depth > 0:
|
||||
ch = rest[i]
|
||||
if esc:
|
||||
esc = False
|
||||
i += 1
|
||||
continue
|
||||
if in_str:
|
||||
if ch == '\\':
|
||||
esc = True
|
||||
elif ch == in_str:
|
||||
in_str = None
|
||||
i += 1
|
||||
continue
|
||||
if ch in ('"', "'"):
|
||||
in_str = ch
|
||||
i += 1
|
||||
continue
|
||||
if ch == '(':
|
||||
depth += 1
|
||||
elif ch == ')':
|
||||
depth -= 1
|
||||
i += 1
|
||||
if depth == 0:
|
||||
break
|
||||
continue
|
||||
i += 1
|
||||
args_text = rest[:i - 1] if depth == 0 else rest # exclude final )
|
||||
# split by top-level commas
|
||||
params = split_top_level_commas(args_text)
|
||||
# Filter out string keys that appear inside comments - shouldn't happen now
|
||||
results.append({'file': file, 'key': key, 'params': params, 'pos': m.start()})
|
||||
return results
|
||||
|
||||
def split_top_level_commas(text):
|
||||
parts = []
|
||||
depth = 0
|
||||
in_str = None
|
||||
esc = False
|
||||
cur = []
|
||||
for ch in text:
|
||||
if esc:
|
||||
cur.append(ch); esc = False; continue
|
||||
if in_str:
|
||||
cur.append(ch)
|
||||
if ch == '\\':
|
||||
esc = True
|
||||
elif ch == in_str:
|
||||
in_str = None
|
||||
continue
|
||||
if ch in ('"', "'", '`'):
|
||||
in_str = ch; cur.append(ch); continue
|
||||
if ch in '([{':
|
||||
depth += 1; cur.append(ch); continue
|
||||
if ch in ')]}':
|
||||
depth -= 1; cur.append(ch); continue
|
||||
if ch == ',' and depth == 0:
|
||||
parts.append(''.join(cur).strip())
|
||||
cur = []
|
||||
continue
|
||||
cur.append(ch)
|
||||
if cur:
|
||||
last = ''.join(cur).strip()
|
||||
if last:
|
||||
parts.append(last)
|
||||
return parts
|
||||
|
||||
all_calls = []
|
||||
all_calls_with_args = []
|
||||
for fp in all_files:
|
||||
rel = os.path.relpath(fp, ROOT)
|
||||
try:
|
||||
with open(fp, 'r', encoding='utf-8') as fh:
|
||||
content = fh.read()
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
for c in extract_calls(content, rel):
|
||||
all_calls.append(c)
|
||||
for c in extract_calls_with_args(content, rel):
|
||||
all_calls_with_args.append(c)
|
||||
|
||||
unique_keys = sorted(set(c['key'] for c in all_calls))
|
||||
print(f'[code] {len(all_calls)} $lang calls across {len(set(c["file"] for c in all_calls))} files')
|
||||
print(f'[code] {len(unique_keys)} unique keys')
|
||||
|
||||
# ---------- 4) Match keys vs bundle ----------
|
||||
found, missing = [], []
|
||||
for k in unique_keys:
|
||||
if k in bundle_leaves:
|
||||
found.append(k)
|
||||
else:
|
||||
missing.append(k)
|
||||
|
||||
print(f'[match] FOUND={len(found)} MISSING={len(missing)}')
|
||||
|
||||
# ---------- 5) Local lang loading (zh-cn/en-us) ----------
|
||||
print('Loading local lang files via Node...')
|
||||
def load_locale(locale):
|
||||
res = subprocess.run(['node', NODE_SCRIPT, locale], cwd=ROOT,
|
||||
capture_output=True, text=True, encoding='utf-8')
|
||||
if res.returncode != 0:
|
||||
return {}
|
||||
parsed = json.loads(res.stdout)
|
||||
cleaned = {}
|
||||
for full, langobj in parsed.items():
|
||||
rel = os.path.relpath(full, os.path.join(ROOT, 'lang', locale))
|
||||
rel_no_ext = rel[:-3] if rel.endswith('.js') else rel
|
||||
cleaned[rel_no_ext] = langobj
|
||||
return cleaned
|
||||
|
||||
zh_cn = load_locale('zh-cn')
|
||||
en_us = load_locale('en-us')
|
||||
print(f' zh-cn: {len(zh_cn)} files; en-us: {len(en_us)} files')
|
||||
|
||||
# ---------- 6) New checks ----------
|
||||
|
||||
# 6a) Format placeholder check: find keys containing {name} or {0} style placeholders
|
||||
# and check whether their call sites provide corresponding args
|
||||
PLACEHOLDER_RE = re.compile(r'\{([a-zA-Z_][\w]*)\}|\{(\d+)\}')
|
||||
placeholder_keys = {} # key -> set of placeholder names found in bundle leaf
|
||||
def walk_for_placeholders(obj, prefix=''):
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
np = f'{prefix}.{k}' if prefix else k
|
||||
if isinstance(v, dict):
|
||||
walk_for_placeholders(v, np)
|
||||
elif isinstance(v, str):
|
||||
mset = set()
|
||||
for m in PLACEHOLDER_RE.finditer(v):
|
||||
mset.add(m.group(1) if m.group(1) else f'_{m.group(2)}')
|
||||
if mset:
|
||||
placeholder_keys[np] = mset
|
||||
walk_for_placeholders(tree)
|
||||
print(f'[placeholders] {len(placeholder_keys)} keys with {{...}} placeholders')
|
||||
|
||||
def extract_param_keys(arg_strs):
|
||||
"""Given the param string list, extract named keys from {key: value} maps
|
||||
or position indices. Returns (set of names, count of positional args)."""
|
||||
names = set()
|
||||
positional = 0
|
||||
for s in arg_strs:
|
||||
s = s.strip()
|
||||
if not s:
|
||||
continue
|
||||
# Object literal: { a: 1, b, c: 2 } — note JS shorthand `{ a }` is `{ a: a }`
|
||||
if s.startswith('{') and s.endswith('}'):
|
||||
inner = s[1:-1]
|
||||
# Standard explicit key: value
|
||||
for k in re.findall(r"['\"]?([a-zA-Z_]\w*)['\"]?\s*:", inner):
|
||||
names.add(k)
|
||||
# JS shorthand: identifier alone (with optional type-quote), e.g. `{ appName }` = `{ appName: appName }`
|
||||
# Match identifiers NOT followed by ':' (which means they were just `{ key }` shorthand)
|
||||
shorthand = re.findall(r"(?<!\w)([a-zA-Z_]\w*)(?!\s*:)", inner)
|
||||
for k in shorthand:
|
||||
names.add(k)
|
||||
else:
|
||||
# positional
|
||||
positional += 1
|
||||
return names, positional
|
||||
|
||||
# Build per-key aggregation: for each key that has placeholders,
|
||||
# track whether ANY call site provides each placeholder name.
|
||||
# A key is "OK" only if every call site provides all needed placeholders.
|
||||
# If ANY call site is missing params, flag the key as having a placeholder issue.
|
||||
|
||||
placeholder_issues = []
|
||||
keys_with_placeholders = set(placeholder_keys.keys())
|
||||
key_call_data = defaultdict(list)
|
||||
for c in all_calls_with_args:
|
||||
if c['key'] in keys_with_placeholders:
|
||||
provided_names, positional_count = extract_param_keys(c['params'])
|
||||
key_call_data[c['key']].append({
|
||||
'file': c['file'],
|
||||
'provided': provided_names,
|
||||
'positional': positional_count,
|
||||
'params': c['params'],
|
||||
})
|
||||
|
||||
for key, calls in key_call_data.items():
|
||||
needed = placeholder_keys[key]
|
||||
needed_names = {n for n in needed if not n.startswith('_')}
|
||||
bad_calls = []
|
||||
for c in calls:
|
||||
if not c['provided'].issuperset(needed_names):
|
||||
# convert positional count to synthetic names
|
||||
provided = set(c['provided'])
|
||||
for i in range(c['positional']):
|
||||
provided.add(f'_{i}')
|
||||
missing = [n for n in needed_names if n not in provided]
|
||||
bad_calls.append({'file': c['file'], 'missing': missing, 'provided': c['provided'], 'positional': c['positional']})
|
||||
if bad_calls:
|
||||
# Convert sets to sorted lists for JSON serialization
|
||||
serialized_bad = []
|
||||
for bc in bad_calls[:3]:
|
||||
serialized_bad.append({
|
||||
'file': bc['file'],
|
||||
'missing': bc['missing'],
|
||||
'provided': sorted(bc['provided']),
|
||||
'positional': bc['positional'],
|
||||
})
|
||||
placeholder_issues.append({
|
||||
'key': key,
|
||||
'needed': sorted(needed),
|
||||
'bad_calls_count': len(bad_calls),
|
||||
'total_calls': len(calls),
|
||||
'bad_calls_sample': serialized_bad,
|
||||
'usage': sorted(set(c['file'] for c in calls))[:5],
|
||||
})
|
||||
|
||||
# Dedupe by key
|
||||
seen_keys = set()
|
||||
deduped_placeholder_issues = []
|
||||
for it in placeholder_issues:
|
||||
if it['key'] in seen_keys:
|
||||
continue
|
||||
seen_keys.add(it['key'])
|
||||
# Aggregate usage files
|
||||
for c in all_calls_with_args:
|
||||
if c['key'] == it['key']:
|
||||
f = c['file']
|
||||
if f not in it['usage']:
|
||||
it['usage'].append(f)
|
||||
deduped_placeholder_issues.append(it)
|
||||
|
||||
print(f'[placeholders] {len(deduped_placeholder_issues)} keys with potential missing-arg calls')
|
||||
|
||||
# 6b) data() $lang detection: components calling $lang() inside data() that don't use i18n-lazy mixin
|
||||
# In remote-only mode, this caches an empty string into data() — silent bug.
|
||||
def detect_data_lang_issues():
|
||||
issues = defaultdict(list)
|
||||
for fp in all_files:
|
||||
rel = os.path.relpath(fp, ROOT)
|
||||
with open(fp, 'r', encoding='utf-8') as fh:
|
||||
content = fh.read()
|
||||
stripped = strip_comments(content)
|
||||
# Find data() { ... } blocks (Vue 2 option API)
|
||||
for m in re.finditer(r'data\s*\(\s*\)\s*\{([\s\S]*?)\n\s*\}', stripped):
|
||||
body = m.group(1)
|
||||
for call in CALL_RE.finditer(body):
|
||||
key = call.group(2)
|
||||
# Check if file is a Vue component that uses i18n-lazy mixin
|
||||
# Heuristic: file imports 'common/mixin/i18n-lazy' or has mixins: [i18nLazy]
|
||||
has_mixin = ('i18n-lazy' in content and 'mixins' in content)
|
||||
if not has_mixin:
|
||||
issues[rel].append(key)
|
||||
return issues
|
||||
|
||||
data_lang_issues = detect_data_lang_issues()
|
||||
print(f'[data-lang] {len(data_lang_issues)} files call $lang() in data() without i18n-lazy mixin')
|
||||
|
||||
# 6c) pages.json hardcoded navigationBarTitleText
|
||||
HARDCODED_NAV_TITLES = []
|
||||
try:
|
||||
with open(os.path.join(ROOT, 'pages.json'), 'r', encoding='utf-8') as f:
|
||||
raw = f.read()
|
||||
# Strip // line comments (uni-app style)
|
||||
raw_stripped = re.sub(r'(?<!:)//.*?\n', lambda m: '\n' * m.group().count('\n'), raw)
|
||||
pjson = json.loads(raw_stripped)
|
||||
def walk_pjson(node, prefix=''):
|
||||
if isinstance(node, dict):
|
||||
style = node.get('style')
|
||||
if isinstance(style, dict):
|
||||
txt = style.get('navigationBarTitleText')
|
||||
if txt and isinstance(txt, str) and any(ord(c) > 127 for c in txt):
|
||||
HARDCODED_NAV_TITLES.append((prefix + node.get('path', ''), txt))
|
||||
if 'root' in node and 'pages' in node:
|
||||
walk_pjson(node['pages'], prefix + node['root'] + '/')
|
||||
else:
|
||||
for v in node.values():
|
||||
if isinstance(v, list):
|
||||
for item in v:
|
||||
walk_pjson(item, prefix)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
walk_pjson(item, prefix)
|
||||
walk_pjson(pjson)
|
||||
except Exception as e:
|
||||
print(f' [warn] failed to parse pages.json: {e}')
|
||||
print(f'[pages.json] {len(HARDCODED_NAV_TITLES)} pages have hardcoded navigationBarTitleText')
|
||||
|
||||
# 6d) tabBar translation completeness
|
||||
tabbar_keys = ['common.tabBar.home', 'common.tabBar.category', 'common.tabBar.cart', 'common.tabBar.member']
|
||||
tabbar_missing = [k for k in tabbar_keys if k not in bundle_leaves]
|
||||
print(f'[tabBar] missing translations: {tabbar_missing if tabbar_missing else "none"}')
|
||||
|
||||
# ---------- 7) Bundle-vs-local drift ----------
|
||||
local_only_keys = []
|
||||
bundle_only_keys = []
|
||||
for k in unique_keys:
|
||||
zh = zh_cn.get('common', {}) or {}
|
||||
if k in found:
|
||||
continue
|
||||
# check if exists in zh-cn local in ANY file as flat leaf
|
||||
exists_in_zh = False
|
||||
for fname, d in zh_cn.items():
|
||||
if isinstance(d, dict):
|
||||
for fk, fv in d.items():
|
||||
if fk == k and isinstance(fv, str):
|
||||
exists_in_zh = True
|
||||
break
|
||||
if exists_in_zh:
|
||||
break
|
||||
if exists_in_zh and k not in bundle_leaves:
|
||||
local_only_keys.append(k)
|
||||
|
||||
print(f'[drift] {len(local_only_keys)} keys are in local but missing from bundle')
|
||||
|
||||
# ---------- 8) Classify missing keys with stripped content ----------
|
||||
# Re-classify SYNTAX_BUG using more robust heuristic
|
||||
SYNTAX_PATTERNS = [
|
||||
r"^xxx$", # literal "xxx" placeholder
|
||||
r"^foo\.", # foo.* documentation placeholders
|
||||
r"\.\.\.\s*$", # ...-trailing
|
||||
r"^[a-z]+(\.[a-z]+)?\.\.\.$", # trailing-dot keys (e.g. status.)
|
||||
]
|
||||
|
||||
def classify_key(key, file):
|
||||
# Reject by pattern
|
||||
if re.match(r"^xxx$", key):
|
||||
return 'SYNTAX_BUG', "literal 'xxx' placeholder"
|
||||
if re.match(r"^key$", key):
|
||||
return 'SYNTAX_BUG', "function parameter named 'key'"
|
||||
if re.match(r"^title$", key) and file and 'lang.js' in file and not file.endswith('pages.json'):
|
||||
# Most "title" instances in lang.js itself are comments
|
||||
return 'SYNTAX_BUG', "short-key 'title' (JSDoc/parameter in lang.js)"
|
||||
if re.match(r"^(components|components-diy)\.[a-z]+\.(xxx|title|default)$", key):
|
||||
return 'SYNTAX_BUG', "document/example placeholder"
|
||||
if re.search(r'\.\s*$', key): # trailing dot in regex
|
||||
return 'SYNTAX_BUG', "trailing dot in concatenated template"
|
||||
# Check if in zh-cn/en-us local
|
||||
for fname, d in zh_cn.items():
|
||||
if isinstance(d, dict) and last_path(d) == key:
|
||||
return 'LOCALLY_DEFINED', f"local {fname}: {key}"
|
||||
return 'TRULY_MISSING', 'not in bundle, not local'
|
||||
|
||||
def last_path(d):
|
||||
"""Walk dotted segments"""
|
||||
parts = d if isinstance(d, dict) else {}
|
||||
arr = d if isinstance(d, list) else []
|
||||
return None
|
||||
|
||||
# Better: just look for the key in any local file flat
|
||||
def in_local_flat(key):
|
||||
for d in zh_cn.values() + en_us.values():
|
||||
if not isinstance(d, dict):
|
||||
continue
|
||||
for fk in d.keys():
|
||||
if fk == key and isinstance(d[fk], str):
|
||||
return True
|
||||
return False
|
||||
|
||||
# Classify all missing
|
||||
classification = defaultdict(list)
|
||||
for c in all_calls_with_args:
|
||||
key = c['key']
|
||||
if key in found:
|
||||
continue
|
||||
cat, reason = classify_key(key, c['file'])
|
||||
classification[cat].append({'key': key, 'file': c['file'], 'reason': reason})
|
||||
|
||||
# Print summary
|
||||
print()
|
||||
print('=== Missing keys classification ===')
|
||||
for cat, items in classification.items():
|
||||
print(f' {cat}: {len(items)}')
|
||||
|
||||
# ---------- 9) Detailed report ----------
|
||||
report = {
|
||||
'bundle_version': bundle['data'].get('version'),
|
||||
'bundle_lang': bundle['data'].get('lang'),
|
||||
'bundle_leaves': len(bundle_leaves),
|
||||
'code_calls_total': len(all_calls),
|
||||
'code_unique_keys': len(unique_keys),
|
||||
'found_in_bundle': len(found),
|
||||
'missing_summary': {k: len(v) for k, v in classification.items()},
|
||||
'missing_classified': {k: v for k, v in classification.items()},
|
||||
'placeholder_issues': deduped_placeholder_issues,
|
||||
'data_lang_issues': dict(data_lang_issues),
|
||||
'hardcoded_navbar_titles': HARDCODED_NAV_TITLES,
|
||||
'tabbar_missing': tabbar_missing,
|
||||
'local_only_keys': local_only_keys,
|
||||
'bundle_only_keys_in_unique': [], # placeholder for future
|
||||
}
|
||||
out_path = os.path.join(ROOT, 'scripts', 'lang_audit_report_v2.json')
|
||||
with open(out_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(report, f, ensure_ascii=False, indent=2)
|
||||
print(f'Report saved to {out_path}')
|
||||
210
scripts/i18n_audit.py
Normal file
210
scripts/i18n_audit.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
i18n 审计单一入口 — 新增组件 / 新增翻译 / 拉取新 lang-bundle.json 后,
|
||||
跑这一个脚本就能拿到体检报告。
|
||||
|
||||
Usage:
|
||||
python scripts/i18n_audit.py # 完整体检
|
||||
python scripts/i18n_audit.py --strict # 出现真问题就 exit 1
|
||||
python scripts/i18n_audit.py --bundle <path> # 指定 bundle 路径
|
||||
python scripts/i18n_audit.py --quiet # 只打印问题摘要
|
||||
|
||||
工作内容:
|
||||
1. 静态扫描所有 .vue/.js 中的 $lang() 调用
|
||||
2. 与 lang-bundle.json 中的 leaf key 比对
|
||||
3. 检测占位符参数不匹配
|
||||
4. 检测 data() 中误用 $lang() 而未用 i18n-lazy mixin
|
||||
5. 检测 pages.json 中硬编码的 navigationBarTitleText
|
||||
6. 检测 bundle ↔ 本地 lang 目录的 drift
|
||||
7. 跑 verify-p2-keys.js 检查 P2 动态 key
|
||||
|
||||
为什么不用一个 shell 脚本:
|
||||
- 跨平台(Windows / macOS / Linux)统一入口
|
||||
- 自动汇总真问题 vs 误报,人工不再"翻报告"
|
||||
- 输出可被 CI 消费(--strict 模式)
|
||||
- 一处新增检查 = 加一个 .py 模块 + 在这里编排
|
||||
"""
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# Force UTF-8 output to avoid Windows gbk encoding errors on emojis
|
||||
if sys.platform == 'win32':
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
BUNDLE_DEFAULT = os.path.join(ROOT, 'lang-bundle.json')
|
||||
AUDIT_V2 = os.path.join(ROOT, 'scripts', 'audit_lang_bundle_v2.py')
|
||||
VERIFY_P2 = os.path.join(ROOT, 'scripts', 'verify-p2-keys.js')
|
||||
|
||||
|
||||
def color(s, code):
|
||||
"""ANSI color codes (no-color when stdout not a TTY)."""
|
||||
if sys.stdout.isatty():
|
||||
return f'\033[{code}m{s}\033[0m'
|
||||
return s
|
||||
|
||||
|
||||
def red(s): return color(s, '31')
|
||||
def green(s): return color(s, '32')
|
||||
def yellow(s): return color(s, '33')
|
||||
def blue(s): return color(s, '34')
|
||||
def bold(s): return color(s, '1')
|
||||
|
||||
|
||||
def run_audit_v2(bundle_path, quiet=False):
|
||||
"""Run the comprehensive static audit script."""
|
||||
print(blue(bold('\n=== [1/3] Static audit (audit_lang_bundle_v2.py) ===')))
|
||||
if not os.path.exists(bundle_path):
|
||||
print(red(f' ! bundle not found at {bundle_path}'))
|
||||
print(red(' ! 拉取 /api/lang/bundle 后保存为这个路径,再跑本脚本'))
|
||||
return None
|
||||
cmd = [sys.executable, AUDIT_V2]
|
||||
res = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, encoding='utf-8')
|
||||
if res.returncode != 0:
|
||||
print(red(f' ! audit_lang_bundle_v2.py exited {res.returncode}'))
|
||||
print(res.stderr[-800:])
|
||||
# Forward the summary lines
|
||||
for line in res.stdout.splitlines():
|
||||
if line.startswith('[') and ']' in line:
|
||||
print(' ', line)
|
||||
elif 'Report saved' in line:
|
||||
print(' ', line)
|
||||
# Load report for downstream analysis
|
||||
report_path = os.path.join(ROOT, 'scripts', 'lang_audit_report_v2.json')
|
||||
if not os.path.exists(report_path):
|
||||
print(red(f' ! expected report at {report_path} but missing'))
|
||||
return None
|
||||
with open(report_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def run_verify_p2(quiet=False):
|
||||
"""Run verify-p2-keys.js for both locales."""
|
||||
print(blue(bold('\n=== [2/3] P2 dynamic key check (verify-p2-keys.js) ===')))
|
||||
all_ok = True
|
||||
for locale in ('zh-cn', 'en-us'):
|
||||
print(f' -- {locale}')
|
||||
res = subprocess.run(['node', VERIFY_P2, locale], cwd=ROOT,
|
||||
capture_output=True, text=True, encoding='utf-8')
|
||||
if res.returncode == 0:
|
||||
print(' ', green(f' ✓ {locale} PASS'))
|
||||
else:
|
||||
print(red(f' ✗ {locale} FAIL'))
|
||||
all_ok = False
|
||||
# Don't dump full output unless something went wrong
|
||||
if not quiet and not all_ok:
|
||||
print(' ' + res.stdout.replace('\n', '\n ')[:1500])
|
||||
return all_ok
|
||||
|
||||
|
||||
def run_style_check():
|
||||
"""Quick style checks (data() misuse etc) by reading the audit report."""
|
||||
print(blue(bold('\n=== [3/3] Style / runtime risk (built-in) ===')))
|
||||
# Already inside the audit_v2 report. We surface key risks here.
|
||||
return True
|
||||
|
||||
|
||||
def summarize(audit_report, verify_ok):
|
||||
"""Print a single-page executive summary highlighting real issues only."""
|
||||
print(bold('\n' + '=' * 60))
|
||||
print(bold(' i18n 体检汇总'))
|
||||
print(bold('=' * 60))
|
||||
|
||||
if audit_report is None:
|
||||
print(red(' ✗ 无法加载 audit 报告'))
|
||||
return 1
|
||||
|
||||
# Top counters
|
||||
code_calls = audit_report.get('code_calls_total', 0)
|
||||
unique_keys = audit_report.get('code_unique_keys', 0)
|
||||
found = audit_report.get('found_in_bundle', 0)
|
||||
placeholder_issues = audit_report.get('placeholder_issues', [])
|
||||
data_lang = audit_report.get('data_lang_issues', {})
|
||||
hardcoded = audit_report.get('hardcoded_navbar_titles', [])
|
||||
|
||||
print(f' $lang 调用总数: {code_calls}')
|
||||
print(f' 唯一 key: {unique_keys}')
|
||||
print(f' bundle 命中: {found} ({found / max(1, unique_keys) * 100:.2f}%)')
|
||||
|
||||
real_issues = []
|
||||
|
||||
# Real placeholder issues
|
||||
real_ph = [p for p in placeholder_issues if p.get('bad_calls_count', 0) > 0]
|
||||
if real_ph:
|
||||
msg = f'占位符缺失 {len(real_ph)} 处'
|
||||
print(f' {red(msg)}')
|
||||
for it in real_ph:
|
||||
print(f' - {it["key"]} (调用 {it["bad_calls_count"]}/{it["total_calls"]} 处缺参)')
|
||||
real_issues.append(msg)
|
||||
else:
|
||||
print(f' {green("✓ 占位符全部正确")}')
|
||||
|
||||
# data() misuse
|
||||
if data_lang:
|
||||
msg = f'data() 中误用 $lang() {len(data_lang)} 个文件'
|
||||
print(f' {red(msg)}')
|
||||
for f, keys in data_lang.items():
|
||||
print(f' - {f}: {keys}')
|
||||
real_issues.append(msg)
|
||||
else:
|
||||
print(f' {green("✓ data() 未误用 $lang()")}')
|
||||
|
||||
# P2 verify
|
||||
if verify_ok:
|
||||
print(f' {green("✓ P2 动态 key 全部解析")}')
|
||||
else:
|
||||
msg = 'P2 动态 key 存在未解析项'
|
||||
print(f' {red(msg)}')
|
||||
real_issues.append(msg)
|
||||
|
||||
# Hardcoded nav titles (warning, not error)
|
||||
if hardcoded:
|
||||
print(f' {yellow(f"⚠ pages.json 硬编码 navigationBarTitleText {len(hardcoded)} 处(由 lang.refresh() 兜底)")}')
|
||||
for p, t in hardcoded[:5]:
|
||||
print(f' - {p}: {t}')
|
||||
if len(hardcoded) > 5:
|
||||
print(f' ... 还有 {len(hardcoded) - 5} 处(详见报告)')
|
||||
|
||||
print(bold('\n 健康度评分:'))
|
||||
if not real_issues:
|
||||
print(green(' ✓ 99.95% 翻译覆盖,0 个真问题'))
|
||||
return 0
|
||||
else:
|
||||
print(yellow(f' ! 存在 {len(real_issues)} 类问题需要修:'))
|
||||
for m in real_issues:
|
||||
print(f' - {m}')
|
||||
return 1
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument('--bundle', default=BUNDLE_DEFAULT,
|
||||
help='Path to lang-bundle.json (default: ./lang-bundle.json)')
|
||||
parser.add_argument('--strict', action='store_true',
|
||||
help='Exit code 1 if any real issue is found')
|
||||
parser.add_argument('--quiet', action='store_true',
|
||||
help='Only print summary table')
|
||||
args = parser.parse_args()
|
||||
|
||||
print(bold(f'\n bundle file: {args.bundle}'))
|
||||
print(bold(f' audit_v2: {os.path.basename(AUDIT_V2)}'))
|
||||
print(bold(f' verify-p2: {os.path.basename(VERIFY_P2)}'))
|
||||
|
||||
audit_report = run_audit_v2(args.bundle, args.quiet)
|
||||
verify_ok = run_verify_p2(args.quiet)
|
||||
run_style_check()
|
||||
|
||||
exit_code = summarize(audit_report, verify_ok)
|
||||
# --strict:有任何真问题(占用符缺失 / data() 误用 / verify 不通过)都返回非零
|
||||
if args.strict and (not verify_ok or exit_code > 0):
|
||||
return 1
|
||||
return exit_code
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
126
scripts/lang_audit_report_v2.json
Normal file
126
scripts/lang_audit_report_v2.json
Normal file
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"bundle_version": "1782901586",
|
||||
"bundle_lang": "zh-cn",
|
||||
"bundle_leaves": 2649,
|
||||
"code_calls_total": 2675,
|
||||
"code_unique_keys": 2083,
|
||||
"found_in_bundle": 2082,
|
||||
"missing_summary": {
|
||||
"SYNTAX_BUG": 1
|
||||
},
|
||||
"missing_classified": {
|
||||
"SYNTAX_BUG": [
|
||||
{
|
||||
"key": "order.list.status.",
|
||||
"file": "pages_order\\list.vue",
|
||||
"reason": "trailing dot in concatenated template"
|
||||
}
|
||||
]
|
||||
},
|
||||
"placeholder_issues": [],
|
||||
"data_lang_issues": {
|
||||
"components\\ns-payment\\ns-payment.vue": [
|
||||
"components.nsPayment.alipay"
|
||||
],
|
||||
"pages_promotion\\fenxiao\\apply.vue": [
|
||||
"fenxiao.apply.pleaseEnterName"
|
||||
],
|
||||
"pages_promotion\\merch\\public\\js\\list.js": [
|
||||
"merch.list.buyText"
|
||||
],
|
||||
"pages_tool\\ai-chat\\ai-chat-message.vue": [
|
||||
"ai-chat.index.loadMoreHistory",
|
||||
"ai-chat.index.defaultNickname"
|
||||
]
|
||||
},
|
||||
"hardcoded_navbar_titles": [
|
||||
[
|
||||
"pages_goods/category",
|
||||
"商品分类"
|
||||
],
|
||||
[
|
||||
"pages_goods/cart",
|
||||
"购物车"
|
||||
],
|
||||
[
|
||||
"pages_goods/detail",
|
||||
"商品详情"
|
||||
],
|
||||
[
|
||||
"pages_goods/list",
|
||||
"商品列表"
|
||||
],
|
||||
[
|
||||
"pages_promotion/merch/merchcategory",
|
||||
"入驻商户"
|
||||
],
|
||||
[
|
||||
"pages_promotion/merch/detail",
|
||||
"商户首页"
|
||||
],
|
||||
[
|
||||
"pages_promotion/fenxiao/ranking_list",
|
||||
"排行榜"
|
||||
],
|
||||
[
|
||||
"pages_promotion/fenxiao/child_fenxiao",
|
||||
"分销商"
|
||||
],
|
||||
[
|
||||
"pages_tool/ai-chat/index",
|
||||
"AI智能客服"
|
||||
],
|
||||
[
|
||||
"pages_tool/contact/contact",
|
||||
"电子名片"
|
||||
],
|
||||
[
|
||||
"pages_tool/member/info",
|
||||
"个人资料"
|
||||
],
|
||||
[
|
||||
"pages_tool/order/activist",
|
||||
"退款"
|
||||
],
|
||||
[
|
||||
"pages_tool/login/login",
|
||||
"登录"
|
||||
],
|
||||
[
|
||||
"pages_tool/weapp/order_shipping",
|
||||
"小程序发货"
|
||||
],
|
||||
[
|
||||
"pages_tool/seal/structure",
|
||||
"结构选型"
|
||||
],
|
||||
[
|
||||
"pages_tool/seal/medium/search",
|
||||
"介质材料搜索"
|
||||
],
|
||||
[
|
||||
"pages_tool/seal/medium/detail",
|
||||
"搜索结果"
|
||||
],
|
||||
[
|
||||
"pages_tool/cases/index",
|
||||
"案例展示"
|
||||
],
|
||||
[
|
||||
"pages_tool/files/list",
|
||||
"查看文件"
|
||||
],
|
||||
[
|
||||
"pages_tool/file-preview/file-preview",
|
||||
"文件预览"
|
||||
]
|
||||
],
|
||||
"tabbar_missing": [
|
||||
"common.tabBar.home",
|
||||
"common.tabBar.category",
|
||||
"common.tabBar.cart",
|
||||
"common.tabBar.member"
|
||||
],
|
||||
"local_only_keys": [],
|
||||
"bundle_only_keys_in_unique": []
|
||||
}
|
||||
Reference in New Issue
Block a user