Files
lucky_shop/scripts/audit_lang_bundle_v2.py
ZF sun f37baad045 feat(i18n): add full i18n audit toolchain and pre-commit hook
- 新增 GitHub Actions 工作流实现 PR 时自动审计国际化内容
- 添加 pre-commit 钩子,提交前自动检查相关文件并拦截问题提交
- 编写完整的 i18n 审计脚本、文档与示例报告
- 实现多维度国际化校验:键匹配、占位符、data()误用、硬编码标题等
2026-07-04 09:57:43 +08:00

480 lines
18 KiB
Python

"""
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}')