feat(i18n): add full i18n audit toolchain and pre-commit hook

- 新增 GitHub Actions 工作流实现 PR 时自动审计国际化内容
- 添加 pre-commit 钩子,提交前自动检查相关文件并拦截问题提交
- 编写完整的 i18n 审计脚本、文档与示例报告
- 实现多维度国际化校验:键匹配、占位符、data()误用、硬编码标题等
This commit is contained in:
2026-07-04 09:57:43 +08:00
parent ea504d8434
commit f37baad045
7 changed files with 4458 additions and 0 deletions

33
.github/workflows/i18n-audit.yml vendored Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

104
scripts/I18N_AUDIT.md Normal file
View 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`。

View 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
View 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())

View 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": []
}