- 新增 GitHub Actions 工作流实现 PR 时自动审计国际化内容 - 添加 pre-commit 钩子,提交前自动检查相关文件并拦截问题提交 - 编写完整的 i18n 审计脚本、文档与示例报告 - 实现多维度国际化校验:键匹配、占位符、data()误用、硬编码标题等
480 lines
18 KiB
Python
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}')
|