Files
lucky_shop/scripts/i18n_audit.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

160 lines
6.0 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.
"""
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()