Files
lucky_shop/scripts/i18n_check_naming.py
ZF sun 1f1a642f99 fix(i18n): 修复审计脚本扫不到 modular 文件的盲区,接入命名规范检查到构建卡点
- 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 处
2026-06-25 10:26:55 +08:00

257 lines
8.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())