- i18n_audit.py: 同步扫描 lang/<locale>/<module>.js 多文件 + lang/<locale>.js 单文件 - i18n_check_naming.py (新增): 检测中文 key / 扁平 key / 行号式 locational key / 跨业务域同名 / 非 snake_case - i18n_check.py: 加入 naming 子命令,默认全套审计包含 naming - vue.config.js: I18nGuardPlugin 增加 naming 阻断 - full_migrate_i18n.py: 标 DEPRECATED,文档说明其输出违反 CLAUDE.md 1.2 铁律 - diagnose_keys.py (新增): 定位 orphan 调用 key 背景:之前审计'假阳性通过',实际 6018 处违规;现在审计能看到真实状态。 Fix: docs/i18n-rules.md 1.6 条铁律需补强 (modular 审计入口已加) 统计 (i18n_check_naming.py 当前输出): A. 中文 key: 2146 B. 扁平 key: 188 C. 行号式 locational key: 1612 D. 跨业务域同名 tail: 1366 E. 非 snake_case key 段: 2924 TOTAL: 8236 处
257 lines
8.6 KiB
Python
257 lines
8.6 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
i18n_check_naming.py - 检测 i18n key 命名规范违规
|
||
|
||
检测 4 类问题:
|
||
A. 中文 key(key 段是中文,违反 CLAUDE.md 1.2 铁律)
|
||
B. 扁平 key(无业务域前缀,如 `save` / `buy`,违反 1.2)
|
||
C. 行号式 locational key(如 `chat-message.l60.t0`,违反目标 4 长期维护)
|
||
D. 跨业务域同名(不同业务域下定义相同语义路径,应收敛到 common)
|
||
|
||
退出码:0 = 通过 / 非 0 = 有违规
|
||
|
||
用法:
|
||
python scripts/i18n_check_naming.py
|
||
python scripts/i18n_check_naming.py --strict # 任何 1 个违规即返回非 0
|
||
"""
|
||
import re
|
||
import sys
|
||
import json
|
||
from pathlib import Path
|
||
from collections import defaultdict
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
|
||
# 同时扫旧单文件 + 新多文件
|
||
LANG_FILES = [ROOT / 'lang' / 'en-us.js', ROOT / 'lang' / 'zh-cn.js']
|
||
LANG_DIRS = [ROOT / 'lang' / 'en-us', ROOT / 'lang' / 'zh-cn']
|
||
|
||
# 业务域白名单(一级 namespace)
|
||
NAMESPACES = {
|
||
'common', 'tabBar', 'customer', 'goods', 'cart', 'order', 'pay',
|
||
'member', 'coupon', 'point', 'hongbao', 'fenxiao', 'merch',
|
||
'login', 'register', 'find', 'article', 'topics', 'notice', 'help',
|
||
'chat', 'ai_chat', 'verification', 'recharge', 'diy',
|
||
'bargain', 'pintuan', 'groupbuy', 'seckill', 'combo', 'presale', 'bale',
|
||
'seal', 'storeclose', 'store', 'index', 'mall', 'public', 'vr',
|
||
'pageTitles', 'title',
|
||
# 历史命名空间(保留识别,不再推荐)
|
||
'img', 'photo', 'share', 'nav', 'tab',
|
||
}
|
||
|
||
# 已知特殊顶层 key(不需要业务域前缀的常量/函数名等)
|
||
TOP_LEVEL_ALLOWLIST = {
|
||
'currencySymbol', 'defaultLang', 'langList',
|
||
'title', # 兼容旧的根 title key
|
||
}
|
||
|
||
# 检测中文 key 段(含中文的正则)
|
||
CHINESE_RE = re.compile(r'[一-鿿]')
|
||
|
||
# 检测行号式 locational key(如 chat-message.l60.t0 或 ns-goods-sku.l100.t0)
|
||
LOCATIONAL_RE = re.compile(r'\.l\d+\.t\d+$')
|
||
|
||
# 检测合理 snake_case 段(仅允许英文数字下划线)
|
||
SNAKE_CASE_RE = re.compile(r'^[a-z][a-z0-9_]*$')
|
||
|
||
|
||
def parse_lang_file(path):
|
||
"""解析 lang 文件,返回 [(full_key, value, line_no), ...],支持嵌套 + 中文 key"""
|
||
items = []
|
||
text = path.read_bytes().decode('utf-8', errors='ignore')
|
||
stack = []
|
||
indent_stack = [-1]
|
||
for i, ln in enumerate(text.split('\n'), 1):
|
||
stripped = ln.lstrip()
|
||
if not stripped or stripped.startswith('//'):
|
||
continue
|
||
indent = len(ln) - len(stripped)
|
||
while len(indent_stack) > 1 and indent <= indent_stack[-1]:
|
||
indent_stack.pop()
|
||
stack.pop()
|
||
# 允许 key 含中文(含中文命名空间或纯中文 key 都需要检测出来)
|
||
m = re.match(r"""^['\"]?([\w一-鿿][\w一-鿿\.]*)['\"]?\s*:\s*(.*)$""", stripped)
|
||
if not m:
|
||
continue
|
||
key, rest = m.group(1), m.group(2).rstrip(',').rstrip()
|
||
full_key = '.'.join(stack + [key]) if stack else key
|
||
if rest.endswith('{'):
|
||
stack.append(key)
|
||
indent_stack.append(indent)
|
||
items.append((full_key, '<object>', i))
|
||
else:
|
||
v = rest.strip()
|
||
if (v.startswith("'") and v.endswith("'")) or (v.startswith('"') and v.endswith('"')):
|
||
if len(v) >= 2:
|
||
v = v[1:-1]
|
||
items.append((full_key, v, i))
|
||
return items
|
||
|
||
|
||
def collect_all_keys():
|
||
"""收集所有 lang 文件里的 (key, value, file, line)"""
|
||
out = []
|
||
sources = list(LANG_FILES)
|
||
for d in LANG_DIRS:
|
||
if d.exists():
|
||
sources.extend(sorted(d.glob('*.js')))
|
||
for f in sources:
|
||
if not f.exists():
|
||
continue
|
||
if f.name == 'index.js':
|
||
continue
|
||
for k, v, ln in parse_lang_file(f):
|
||
out.append((k, v, str(f.relative_to(ROOT)), ln))
|
||
return out
|
||
|
||
|
||
def detect_chinese_keys(items):
|
||
"""A. key 段含中文"""
|
||
issues = []
|
||
for k, v, f, ln in items:
|
||
if v == '<object>':
|
||
continue
|
||
for seg in k.split('.'):
|
||
if CHINESE_RE.search(seg):
|
||
issues.append({
|
||
'key': k, 'segment': seg, 'file': f, 'line': ln,
|
||
'reason': 'key 段含中文(违反 CLAUDE.md 1.2 铁律)',
|
||
})
|
||
break
|
||
return issues
|
||
|
||
|
||
def detect_flat_keys(items):
|
||
"""B. 扁平 key - 一级不是业务域"""
|
||
issues = []
|
||
seen_top = set()
|
||
for k, v, f, ln in items:
|
||
if v == '<object>':
|
||
continue
|
||
if '.' not in k:
|
||
if k in TOP_LEVEL_ALLOWLIST:
|
||
continue
|
||
if k in NAMESPACES:
|
||
continue
|
||
if k in seen_top:
|
||
continue
|
||
seen_top.add(k)
|
||
issues.append({
|
||
'key': k, 'file': f, 'line': ln,
|
||
'reason': f'扁平 key 无业务域前缀(顶层 namespace 不在白名单)',
|
||
})
|
||
return issues
|
||
|
||
|
||
def detect_locational_keys(items):
|
||
"""C. 行号式 locational key"""
|
||
issues = []
|
||
for k, v, f, ln in items:
|
||
if v == '<object>':
|
||
continue
|
||
if LOCATIONAL_RE.search(k):
|
||
issues.append({
|
||
'key': k, 'file': f, 'line': ln,
|
||
'reason': '行号式 locational key(如 chat-message.l60.t0),文件名/行号一变就失效(违反长期维护目标 4)',
|
||
})
|
||
return issues
|
||
|
||
|
||
def detect_cross_namespace_duplicates(items):
|
||
"""D. 不同业务域下定义相同语义路径(应收敛到 common)"""
|
||
# 收集所有"leaf 路径",按尾部 2 段分组
|
||
by_tail = defaultdict(list)
|
||
for k, v, f, ln in items:
|
||
if v == '<object>':
|
||
continue
|
||
parts = k.split('.')
|
||
if len(parts) < 3:
|
||
continue
|
||
tail = '.'.join(parts[-2:])
|
||
by_tail[tail].append((k, f, ln))
|
||
|
||
issues = []
|
||
for tail, occurrences in by_tail.items():
|
||
# 找出这些 key 的 namespace 各不相同
|
||
namespaces = set()
|
||
for k, _, _ in occurrences:
|
||
ns = k.split('.')[0]
|
||
namespaces.add(ns)
|
||
if len(namespaces) < 2:
|
||
continue
|
||
# 排除 common.* 自身
|
||
namespaces.discard('common')
|
||
if len(namespaces) < 2:
|
||
continue
|
||
for k, f, ln in occurrences:
|
||
issues.append({
|
||
'key': k, 'file': f, 'line': ln,
|
||
'reason': f'跨业务域同名 tail="{tail}",出现在 {sorted(namespaces)}(应收敛到 common)',
|
||
})
|
||
return issues
|
||
|
||
|
||
def detect_non_snake_case(items):
|
||
"""E. key 段不是 snake_case(含大写、连字符等)"""
|
||
issues = []
|
||
for k, v, f, ln in items:
|
||
if v == '<object>':
|
||
continue
|
||
for seg in k.split('.'):
|
||
if not SNAKE_CASE_RE.match(seg):
|
||
# 跳过纯数字段(如枚举值)
|
||
if seg.isdigit():
|
||
continue
|
||
issues.append({
|
||
'key': k, 'segment': seg, 'file': f, 'line': ln,
|
||
'reason': f'key 段 "{seg}" 不是 snake_case(必须小写字母开头 + 小写字母/数字/下划线)',
|
||
})
|
||
break
|
||
return issues
|
||
|
||
|
||
def main():
|
||
items = collect_all_keys()
|
||
print(f'扫描 {len(items)} 条 key/value 对...\n')
|
||
|
||
groups = [
|
||
('A. 中文 key', detect_chinese_keys(items)),
|
||
('B. 扁平 key', detect_flat_keys(items)),
|
||
('C. 行号式 locational key', detect_locational_keys(items)),
|
||
('D. 跨业务域同名 tail', detect_cross_namespace_duplicates(items)),
|
||
('E. 非 snake_case key 段', detect_non_snake_case(items)),
|
||
]
|
||
|
||
total = 0
|
||
for label, issues in groups:
|
||
n = len(issues)
|
||
total += n
|
||
marker = '[FAIL]' if n > 0 else '[OK]'
|
||
print(f'{marker} {label}: {n}')
|
||
if n > 0 and n <= 20:
|
||
for it in issues:
|
||
print(f' {it["file"]}:{it["line"]} {it["key"]} -- {it["reason"]}')
|
||
elif n > 20:
|
||
for it in issues[:10]:
|
||
print(f' {it["file"]}:{it["line"]} {it["key"]} -- {it["reason"]}')
|
||
print(f' ... 省略 {n - 10} 条')
|
||
print()
|
||
|
||
# 写报告
|
||
out = ROOT / 'docs' / 'i18n-naming-audit.json'
|
||
out.parent.mkdir(exist_ok=True)
|
||
out.write_text(json.dumps({
|
||
'total_issues': total,
|
||
'groups': {label.split('. ', 1)[1]: issues for label, issues in groups},
|
||
}, ensure_ascii=False, indent=2), encoding='utf-8')
|
||
|
||
print(f'Full report -> {out.relative_to(ROOT)}')
|
||
if total > 0:
|
||
print(f'\n[FAIL] i18n 命名规范违规 {total} 处')
|
||
return 1
|
||
print('\n[OK] 所有 key 符合命名规范')
|
||
return 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
sys.exit(main()) |