#!/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, '', 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 == '': 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 == '': 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 == '': 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 == '': 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 == '': 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())