- 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 处
160 lines
6.0 KiB
Python
160 lines
6.0 KiB
Python
"""
|
||
i18n audit script - scans lang/ for duplicate keys + cross-references with call sites.
|
||
Run: python scripts/i18n_audit.py
|
||
"""
|
||
import os
|
||
import re
|
||
from collections import defaultdict
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
# 自 2026-06-24 起:lang 包按业务域拆分到 lang/<locale>/<module>.js
|
||
# 运行时同时加载旧单文件 (lang/<locale>.js) 和新多文件 (lang/<locale>/index.js)
|
||
# 所以审计也要同时扫两边,否则会漏掉 60%+ 的 key
|
||
LANG_FILES = [ROOT / 'lang' / 'en-us.js', ROOT / 'lang' / 'zh-cn.js']
|
||
LANG_DIRS = [ROOT / 'lang' / 'en-us', ROOT / 'lang' / 'zh-cn']
|
||
# 跳过 lang/ 目录下的子目录扫描(已经被 LANG_DIRS 覆盖)
|
||
SKIP_LANG_SUBPATHS = True
|
||
SRC_GLOBS = ['**/*.vue', '**/*.js']
|
||
|
||
KEY_RE = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*[:=]')
|
||
CALL_RE = re.compile(r'\$lang\(\s*[\'"]([^\'"]+)[\'"]')
|
||
LANG_CALL_RE = re.compile(r'\$lang\(|\$t\(')
|
||
|
||
def walk_files(files):
|
||
"""扫所有 lang 源:旧单文件 + 新多文件聚合目录(lang/<locale>/<module>.js)
|
||
跳过 index.js 本身(它是聚合入口,导入的内容已被各模块文件覆盖)"""
|
||
out = []
|
||
for f in files:
|
||
if f.exists():
|
||
out.append(f)
|
||
for d in LANG_DIRS:
|
||
if not d.exists():
|
||
continue
|
||
for f in sorted(d.glob('*.js')):
|
||
# 跳过聚合入口,避免重复扫描(聚合内容 = 各模块 spread)
|
||
if f.name == 'index.js':
|
||
continue
|
||
out.append(f)
|
||
return out
|
||
|
||
def defined_keys(files):
|
||
"""Returns (per_file_keys, all_keys_set, in_file_dup, cross_file_dup).
|
||
Properly tracks nesting via indentation so 'common.activist' vs 'login.activist' are distinct keys."""
|
||
per_file = {}
|
||
in_file_dup = defaultdict(list)
|
||
key_to_files = defaultdict(set)
|
||
|
||
for f in files:
|
||
seen = set()
|
||
text = f.read_text(encoding='utf-8', errors='ignore')
|
||
indent_stack = [(-1, '')]
|
||
for ln in text.splitlines():
|
||
if ln.strip().startswith('//'):
|
||
continue
|
||
if not ln.strip():
|
||
continue
|
||
indent = len(ln) - len(ln.lstrip())
|
||
stripped = ln.lstrip()
|
||
m = re.match(r"""^['\"]?([A-Za-z_][A-Za-z0-9_]*)['\"]?\s*[:=]\s*(.*)$""", stripped)
|
||
if not m:
|
||
continue
|
||
key, rest = m.group(1), m.group(2).strip().rstrip(',')
|
||
while len(indent_stack) > 1 and indent_stack[-1][0] >= indent:
|
||
indent_stack.pop()
|
||
parent = indent_stack[-1][1]
|
||
full_key = f'{parent}.{key}' if parent else key
|
||
if rest.endswith('{'):
|
||
indent_stack.append((indent, full_key))
|
||
if rest.endswith('{'):
|
||
continue # 不把 object 容器当 key
|
||
if full_key in seen:
|
||
in_file_dup[str(f)].append(full_key)
|
||
seen.add(full_key)
|
||
key_to_files[full_key].add(str(f.relative_to(ROOT)))
|
||
per_file[str(f.relative_to(ROOT))] = seen
|
||
|
||
cross = {k: sorted(v) for k, v in key_to_files.items() if len(v) > 1}
|
||
return per_file, set(key_to_files.keys()), in_file_dup, cross
|
||
|
||
|
||
def called_keys():
|
||
used = set()
|
||
for f in ROOT.rglob('*'):
|
||
if not f.is_file():
|
||
continue
|
||
if not (str(f).endswith('.vue') or str(f).endswith('.js')):
|
||
continue
|
||
# 跳过整个 lang 目录(lang 文件不是调用方;它们的 key 在别处被消费)
|
||
if 'lang' in f.parts:
|
||
continue
|
||
if 'node_modules' in f.parts or 'unpackage' in f.parts:
|
||
continue
|
||
try:
|
||
text = f.read_text(encoding='utf-8')
|
||
except UnicodeDecodeError:
|
||
continue
|
||
if not LANG_CALL_RE.search(text):
|
||
continue
|
||
for m in CALL_RE.finditer(text):
|
||
used.add(m.group(1))
|
||
return used
|
||
|
||
|
||
def main():
|
||
files = walk_files(LANG_FILES)
|
||
print(f'Lang files scanned: {len(files)} (旧单文件 + 新多文件模块目录)')
|
||
print(f' - 旧单文件: lang/en-us.js + lang/zh-cn.js')
|
||
print(f' - 新多文件: lang/<locale>/<module>.js × {len(LANG_DIRS)} 个目录')
|
||
|
||
per_file, all_keys, in_file_dup, cross_dup = defined_keys(files)
|
||
used = called_keys()
|
||
|
||
print(f'\n=== Aggregate ===')
|
||
print(f' Defined keys (unique paths): {len(all_keys)}')
|
||
print(f' Called keys (unique paths): {len(used)}')
|
||
|
||
orphans_called = used - all_keys
|
||
orphans_defined = all_keys - used
|
||
print(f' Called but not defined (orphan calls): {len(orphans_called)}')
|
||
print(f' Defined but not called (dead keys): {len(orphans_defined)}')
|
||
|
||
print(f'\n=== In-file duplicates (top 30) ===')
|
||
flat = []
|
||
for f, ks in in_file_dup.items():
|
||
for k in ks:
|
||
flat.append((f, k))
|
||
for f, k in flat[:30]:
|
||
print(f' {f}: {k}')
|
||
|
||
print(f'\n=== Cross-file duplicates (defined in N files, top 40) ===')
|
||
cross_sorted = sorted(cross_dup.items(), key=lambda x: -len(x[1]))
|
||
for k, fs in cross_sorted[:40]:
|
||
print(f' {k} <- {len(fs)} files: {", ".join(fs[:3])}{" ..." if len(fs)>3 else ""}')
|
||
print(f'\n TOTAL cross-file duplicate keys: {len(cross_dup)}')
|
||
|
||
print('\n=== Orphan call keys (top 30 of %d) ===' % len(orphans_called))
|
||
for k in sorted(orphans_called)[:30]:
|
||
print(f' {k}')
|
||
|
||
print('\n=== Dead defined keys (top 30 of %d) ===' % len(orphans_defined))
|
||
for k in sorted(orphans_defined)[:30]:
|
||
print(f' {k}')
|
||
|
||
# Write machine-readable output
|
||
out = ROOT / 'docs' / 'i18n-audit.json'
|
||
out.parent.mkdir(exist_ok=True)
|
||
import json
|
||
out.write_text(json.dumps({
|
||
'defined_count': len(all_keys),
|
||
'called_count': len(used),
|
||
'orphan_calls': sorted(orphans_called),
|
||
'dead_keys': sorted(orphans_defined),
|
||
'in_file_dup': {f: ks for f, ks in in_file_dup.items()},
|
||
'cross_file_dup': cross_dup,
|
||
}, ensure_ascii=False, indent=2), encoding='utf-8')
|
||
print(f'\nFull report -> {out.relative_to(ROOT)}')
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main() |