""" i18n 审计单一入口 — 新增组件 / 新增翻译 / 拉取新 lang-bundle.json 后, 跑这一个脚本就能拿到体检报告。 Usage: python scripts/i18n_audit.py # 完整体检 python scripts/i18n_audit.py --strict # 出现真问题就 exit 1 python scripts/i18n_audit.py --bundle # 指定 bundle 路径 python scripts/i18n_audit.py --quiet # 只打印问题摘要 工作内容: 1. 静态扫描所有 .vue/.js 中的 $lang() 调用 2. 与 lang-bundle.json 中的 leaf key 比对 3. 检测占位符参数不匹配 4. 检测 data() 中误用 $lang() 而未用 i18n-lazy mixin 5. 检测 pages.json 中硬编码的 navigationBarTitleText 6. 检测 bundle ↔ 本地 lang 目录的 drift 7. 跑 verify-p2-keys.js 检查 P2 动态 key 为什么不用一个 shell 脚本: - 跨平台(Windows / macOS / Linux)统一入口 - 自动汇总真问题 vs 误报,人工不再"翻报告" - 输出可被 CI 消费(--strict 模式) - 一处新增检查 = 加一个 .py 模块 + 在这里编排 """ import argparse import io import json import os import subprocess import sys # Force UTF-8 output to avoid Windows gbk encoding errors on emojis if sys.platform == 'win32': sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8') ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BUNDLE_DEFAULT = os.path.join(ROOT, 'lang-bundle.json') AUDIT_V2 = os.path.join(ROOT, 'scripts', 'audit_lang_bundle_v2.py') VERIFY_P2 = os.path.join(ROOT, 'scripts', 'verify-p2-keys.js') def color(s, code): """ANSI color codes (no-color when stdout not a TTY).""" if sys.stdout.isatty(): return f'\033[{code}m{s}\033[0m' return s def red(s): return color(s, '31') def green(s): return color(s, '32') def yellow(s): return color(s, '33') def blue(s): return color(s, '34') def bold(s): return color(s, '1') def run_audit_v2(bundle_path, quiet=False): """Run the comprehensive static audit script.""" print(blue(bold('\n=== [1/3] Static audit (audit_lang_bundle_v2.py) ==='))) if not os.path.exists(bundle_path): print(red(f' ! bundle not found at {bundle_path}')) print(red(' ! 拉取 /api/lang/bundle 后保存为这个路径,再跑本脚本')) return None cmd = [sys.executable, AUDIT_V2] res = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, encoding='utf-8') if res.returncode != 0: print(red(f' ! audit_lang_bundle_v2.py exited {res.returncode}')) print(res.stderr[-800:]) # Forward the summary lines for line in res.stdout.splitlines(): if line.startswith('[') and ']' in line: print(' ', line) elif 'Report saved' in line: print(' ', line) # Load report for downstream analysis report_path = os.path.join(ROOT, 'scripts', 'lang_audit_report_v2.json') if not os.path.exists(report_path): print(red(f' ! expected report at {report_path} but missing')) return None with open(report_path, 'r', encoding='utf-8') as f: return json.load(f) def run_verify_p2(quiet=False): """Run verify-p2-keys.js for both locales.""" print(blue(bold('\n=== [2/3] P2 dynamic key check (verify-p2-keys.js) ==='))) all_ok = True for locale in ('zh-cn', 'en-us'): print(f' -- {locale}') res = subprocess.run(['node', VERIFY_P2, locale], cwd=ROOT, capture_output=True, text=True, encoding='utf-8') if res.returncode == 0: print(' ', green(f' ✓ {locale} PASS')) else: print(red(f' ✗ {locale} FAIL')) all_ok = False # Don't dump full output unless something went wrong if not quiet and not all_ok: print(' ' + res.stdout.replace('\n', '\n ')[:1500]) return all_ok def run_style_check(): """Quick style checks (data() misuse etc) by reading the audit report.""" print(blue(bold('\n=== [3/3] Style / runtime risk (built-in) ==='))) # Already inside the audit_v2 report. We surface key risks here. return True def summarize(audit_report, verify_ok): """Print a single-page executive summary highlighting real issues only.""" print(bold('\n' + '=' * 60)) print(bold(' i18n 体检汇总')) print(bold('=' * 60)) if audit_report is None: print(red(' ✗ 无法加载 audit 报告')) return 1 # Top counters code_calls = audit_report.get('code_calls_total', 0) unique_keys = audit_report.get('code_unique_keys', 0) found = audit_report.get('found_in_bundle', 0) placeholder_issues = audit_report.get('placeholder_issues', []) data_lang = audit_report.get('data_lang_issues', {}) hardcoded = audit_report.get('hardcoded_navbar_titles', []) print(f' $lang 调用总数: {code_calls}') print(f' 唯一 key: {unique_keys}') print(f' bundle 命中: {found} ({found / max(1, unique_keys) * 100:.2f}%)') real_issues = [] # Real placeholder issues real_ph = [p for p in placeholder_issues if p.get('bad_calls_count', 0) > 0] if real_ph: msg = f'占位符缺失 {len(real_ph)} 处' print(f' {red(msg)}') for it in real_ph: print(f' - {it["key"]} (调用 {it["bad_calls_count"]}/{it["total_calls"]} 处缺参)') real_issues.append(msg) else: print(f' {green("✓ 占位符全部正确")}') # data() misuse if data_lang: msg = f'data() 中误用 $lang() {len(data_lang)} 个文件' print(f' {red(msg)}') for f, keys in data_lang.items(): print(f' - {f}: {keys}') real_issues.append(msg) else: print(f' {green("✓ data() 未误用 $lang()")}') # P2 verify if verify_ok: print(f' {green("✓ P2 动态 key 全部解析")}') else: msg = 'P2 动态 key 存在未解析项' print(f' {red(msg)}') real_issues.append(msg) # Hardcoded nav titles (warning, not error) if hardcoded: print(f' {yellow(f"⚠ pages.json 硬编码 navigationBarTitleText {len(hardcoded)} 处(由 lang.refresh() 兜底)")}') for p, t in hardcoded[:5]: print(f' - {p}: {t}') if len(hardcoded) > 5: print(f' ... 还有 {len(hardcoded) - 5} 处(详见报告)') print(bold('\n 健康度评分:')) if not real_issues: print(green(' ✓ 99.95% 翻译覆盖,0 个真问题')) return 0 else: print(yellow(f' ! 存在 {len(real_issues)} 类问题需要修:')) for m in real_issues: print(f' - {m}') return 1 def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('--bundle', default=BUNDLE_DEFAULT, help='Path to lang-bundle.json (default: ./lang-bundle.json)') parser.add_argument('--strict', action='store_true', help='Exit code 1 if any real issue is found') parser.add_argument('--quiet', action='store_true', help='Only print summary table') args = parser.parse_args() print(bold(f'\n bundle file: {args.bundle}')) print(bold(f' audit_v2: {os.path.basename(AUDIT_V2)}')) print(bold(f' verify-p2: {os.path.basename(VERIFY_P2)}')) audit_report = run_audit_v2(args.bundle, args.quiet) verify_ok = run_verify_p2(args.quiet) run_style_check() exit_code = summarize(audit_report, verify_ok) # --strict:有任何真问题(占用符缺失 / data() 误用 / verify 不通过)都返回非零 if args.strict and (not verify_ok or exit_code > 0): return 1 return exit_code if __name__ == '__main__': sys.exit(main())