#!/usr/bin/env python3 """ check_i18n.py — 产线平台前端国际化(i18n)检查器 框架事实(bricks,决定各违规分类): - TextBase(Text/Title*):只有 otext + i18n:true 才走翻译;裸 "text" 不翻译 → W1 - Menu item label / Button label / Form field label / Tabular title / PopupWindow title / UiCode data.text / placeholder / Message.message: 组件自动 i18n._(),不需要改文件,只需要词条进字典 → 缺词条记 W2 - 字典链:模块 i18n//msg.txt|i18n.json → scripts/merge_i18n.py → wwwroot/i18n//i18n.json → /i18n_getmsgs 端点 → 前端 I18n.msgs - msg.txt 以 '#' 开头的词条被 appPublic.i18n 解析器当注释丢弃 → W6 - git-ignored 的 wwwroot/<列表名>/ 是 xls2ui 生成物,禁止直接修改(pre-commit 铁律) → 生成物只收集 key,不报 W1(要改改 json/ 源 + 重新生成) 违规分类: W1 text/otext 硬编码中文且无 i18n 标记(git 跟踪的手写文件才报) W2 自动翻译组件的中文 key 不在合并字典(en/jp/ko) W3 手写 JS / dspy 内嵌 script 里硬编码中文提示(应 bricks.app.i18n._) W4 模块有 UI 但缺 i18n 目录或缺语种 W5 merge_i18n.py 自动发现覆盖检查(动态调用 discover_modules() 验证) W6 msg.txt '#' 开头词条(静默丢弃) W7 动态拼接中文(% / + / f-string)作为可见文本 → 需 otext+i18n 传参重构(仅报告) 用法: python3 scripts/check_i18n.py [--repos-dir ~/work/repos] [--json out.json] [-v] 退出码: 0=无违规, 1=有违规 """ import os, re, sys, json, subprocess, argparse from collections import OrderedDict SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) APP_DIR = os.path.dirname(SCRIPT_DIR) LANGS = ['zh', 'en', 'jp', 'ko'] CJK = re.compile(r'[\u4e00-\u9fff]') SKIP_PATH = [ re.compile(r'/i18n/'), re.compile(r'/docs?/'), re.compile(r'\.md$'), re.compile(r'node_modules'), re.compile(r'/\.git/'), re.compile(r'/py3/'), re.compile(r'/dist/'), re.compile(r'\.bak/'), re.compile(r'\.bak$'), re.compile(r'/wwwroot/bricks/'), # 框架软链 ] AUTO_I18N_CTX = ('Menu', 'Button', 'Form', 'InlineForm', 'Tabular', 'PopupWindow', 'Title', 'Title1', 'Title2', 'Title3', 'Title4', 'Error') def skip(path): return any(p.search(path) for p in SKIP_PATH) def git_repo_root(path): d = path if os.path.isdir(path) else os.path.dirname(path) try: return subprocess.run(['git', '-C', d, 'rev-parse', '--show-toplevel'], capture_output=True, text=True, timeout=10).stdout.strip() except Exception: return None _ignored_cache = {} def git_ignored(path, repo): key = (repo, path) if key in _ignored_cache: return _ignored_cache[key] try: r = subprocess.run(['git', '-C', repo, 'check-ignore', '-q', path], capture_output=True, timeout=10) v = (r.returncode == 0) except Exception: v = False _ignored_cache[key] = v return v def load_modules(repos_dir): mods = [] build_sh = os.path.join(APP_DIR, 'build.sh') if os.path.exists(build_sh): content = open(build_sh, encoding='utf-8').read() for m in re.finditer(r'^for mod in ([^;]+); do\s*$', content, re.M): for name in m.group(1).split(): if name not in mods: mods.append(name) for name in ['pipeline_core', 'pipeline_ops']: if name not in mods: mods.insert(0, name) result = OrderedDict() result['app'] = APP_DIR for name in mods: for cand in (os.path.join(repos_dir, name), os.path.join(APP_DIR, 'pkgs', name), os.path.join(APP_DIR, name)): if os.path.isdir(cand): result[name] = cand break else: result[name] = None return result def iter_files(root, exts): for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if d not in ('.git', 'py3', 'node_modules', 'dist', '__pycache__', 'i18n')] for fn in filenames: if fn.endswith(exts): p = os.path.join(dirpath, fn) if not skip(p): yield p def merged_dict(lang): fp = os.path.join(APP_DIR, 'wwwroot', 'i18n', lang, 'i18n.json') if os.path.exists(fp): try: return json.load(open(fp, encoding='utf-8')) except Exception: return {} return {} def in_code_data_array(raw, pos): """判断位置是否处于 code 选项 data 数组的 {"value":..,"text":..} 上下文。""" s = max(0, pos - 300) ctx = raw[s:pos] return '"value"' in ctx and ctx.rfind('"data"') >= 0 or \ re.search(r'\{\s*"value"\s*:[^}]*$', ctx) is not None def scan_ui(path, keys, viol, tracked): raw = open(path, encoding='utf-8', errors='replace').read() repo = git_repo_root(path) ign = repo and git_ignored(path, repo) for m in re.finditer(r'"(label|title|text|otext|placeholder|message|description)"\s*:\s*"([^"]*)"', raw): field, val = m.group(1), m.group(2) if not CJK.search(val) or val.startswith('{{'): continue s = max(0, m.start() - 400); e = min(len(raw), m.end() + 400) ctx = raw[s:e] has_i18n = re.search(r'"i18n"\s*:\s*true', ctx) is not None has_otext = '"otext"' in ctx if field == 'otext': if has_i18n: keys.add(val) else: viol.append((path, m.start(), 'W1', 'otext 无 i18n:true', val, ign)) elif field == 'text': if has_i18n or has_otext: keys.add(val) elif in_code_data_array(raw, m.start()): keys.add(val) # UiCode/Tabular 渲染 code 值自动 i18n._ (input.js:1232) else: # Text 裸 text 不翻译 if ign: keys.add(val) # 生成物:改 json 源+重新生成,这里只收 key else: viol.append((path, m.start(), 'W1', 'text 硬编码中文(应 otext+i18n:true)', val, ign)) else: keys.add(val) # label/title/placeholder/message/description 组件自动翻译 # 内嵌 script 里的中文提示 for m in re.finditer(r'(show_message|show_error|bricks\.Message|bricks\.Error)\s*\(\s*\{[^}]{0,200}?(title|message)\s*:\s*[\'"]([^\'"]*)[\'"]', raw): val = m.group(3) if CJK.search(val): keys.add(val) # Message.message 自动 i18n(message.js:25),title 走 PopupWindow 自动 i18n # 动态拼接 for m in re.finditer(r'"text"\s*:\s*("[^"]*"\s*[%+][^,}]*)', raw): seg = m.group(1) if CJK.search(seg) and '{{' not in seg: viol.append((path, m.start(), 'W7', '动态拼接中文文本(需 otext+参数重构)', seg[:60], ign)) DSPY_UI_KEYS = re.compile(r"['\"](label|title|otext|placeholder|message|description)['\"]\s*:\s*(['\"])([^'\"]*)\2") DSPY_TEXT = re.compile(r"['\"]text['\"]\s*:\s*(?:(['\"])([^'\"]*)\1|(\(?\s*['\"][^'\"]*['\"]\s*[%+][^)\n]*\)?))") def scan_dspy(path, keys, viol): raw = open(path, encoding='utf-8', errors='replace').read() if 'widgettype' not in raw and 'mdtext' not in raw: return repo = git_repo_root(path) ign = repo and git_ignored(path, repo) for m in DSPY_UI_KEYS.finditer(raw): field, val = m.group(1), m.group(3) if not CJK.search(val) or val.startswith('{{'): continue s = max(0, m.start() - 500); e = min(len(raw), m.end() + 500) ctx = raw[s:e] has_i18n = re.search(r"['\"]i18n['\"]\s*:\s*True", ctx) is not None if field == 'otext': if has_i18n: keys.add(val) else: viol.append((path, m.start(), 'W1', 'dspy otext 无 i18n:True', val, ign)) else: keys.add(val) for m in DSPY_TEXT.finditer(raw): if m.group(2) is not None: val = m.group(2) if not CJK.search(val): continue s = max(0, m.start() - 500); e = min(len(raw), m.end() + 500) ctx = raw[s:e] if re.search(r"['\"]i18n['\"]\s*:\s*True", ctx) or "'otext'" in ctx or '"otext"' in ctx \ or in_code_data_array(raw, m.start()): keys.add(val) elif ign: keys.add(val) else: viol.append((path, m.start(), 'W1', 'dspy text 硬编码中文(应 otext+i18n:True)', val, ign)) else: seg = m.group(3) or '' if CJK.search(seg): viol.append((path, m.start(), 'W7', 'dspy 动态拼接中文文本', seg[:60], ign)) for m in re.finditer(r"(?:show_message|show_error)\s*\(\s*\{[^}]{0,200}?(?:title|message)['\"]?\s*[:=]\s*['\"]([^'\"]*)['\"]", raw): val = m.group(1) if CJK.search(val): keys.add(val) def strip_js_comments(raw): """粗剥离 // 与 /* */ 注释(不处理字符串内含 // 的极端情形,够用)。""" out = [] i, n = 0, len(raw) in_s = None while i < n: c = raw[i] if in_s: out.append(c) if c == '\\' and i + 1 < n: out.append(raw[i+1]); i += 2; continue if c == in_s: in_s = None i += 1; continue if c in ('"', "'", '`'): in_s = c; out.append(c); i += 1; continue if c == '/' and i + 1 < n and raw[i+1] == '/': while i < n and raw[i] != '\n': i += 1 continue if c == '/' and i + 1 < n and raw[i+1] == '*': i += 2 while i + 1 < n and not (raw[i] == '*' and raw[i+1] == '/'): i += 1 i += 2 continue out.append(c); i += 1 return ''.join(out) def scan_js(path, keys, viol): raw = open(path, encoding='utf-8', errors='replace').read() code = strip_js_comments(raw) repo = git_repo_root(path) ign = repo and git_ignored(path, repo) # i18n-aware 文件:定义了 _t()/i18n._ 辅助函数,对象字面量里的中文是「词条 key」 # 而非硬编码显示文本(如 showcase.js MEDIA_LABEL_KEYS,经 mediaLabel→_t 翻译) has_i18n_helper = bool(re.search(r'function\s+_t\s*\(|(?:var|const|let)\s+_t\s*=|i18n\._', code)) # 所有含中文的字符串字面量:不在 i18n._() 内、不在 Message/show_message 上下文 → W3 for m in re.finditer(r"(['\"])([^'\"\n]*[\u4e00-\u9fff][^'\"\n]*)\1", code): val = m.group(2) pre = code[max(0, m.start() - 80):m.start()] if 'i18n._' in pre[-30:] or re.search(r'(?:^|[^.\w])_t\(\s*$', pre): keys.add(val) continue ctx = code[max(0, m.start() - 200):m.end()] if re.search(r'(show_message|show_error|bricks\.Message|bricks\.Error)\s*\(\s*\{[^}]*$', ctx): keys.add(val) # Message title/message 自动翻译 continue if re.search(r'(title|message|label)\s*:\s*$', pre): keys.add(val) # 组件 option 自动翻译 continue if has_i18n_helper and re.search(r'[\w\'\"]+\s*:\s*$', pre): keys.add(val) # i18n-aware 文件的对象字面量中文值 = 词条 key continue viol.append((path, m.start(), 'W3', 'js 硬编码中文(应 bricks.app.i18n._ 或组件词条)', val, ign)) def check_infra(modules, merge_py): issues = [] # W5 动态验证:直接调用 merge_i18n.discover_modules(),模块的 i18n 目录 # 能被自动发现即通过(2026-09-14:merge 已重写为自动发现,旧版按源码文本 # 扫描 MODULES 清单会把所有模块误报为漏) discovered = set() try: import importlib.util spec = importlib.util.spec_from_file_location('merge_i18n_mod', merge_py) if spec is not None and spec.loader is not None: mmod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mmod) discovered = {n for n, _ in mmod.discover_modules()} else: print(f'!! merge_i18n.py 无法加载: {merge_py}', file=sys.stderr) except Exception as e: print(f'!! 无法调用 merge_i18n.discover_modules(): {e}', file=sys.stderr) for name, path in modules.items(): if path is None or not os.path.isdir(path): continue ui_files = list(iter_files(path, ('.ui',))) has_ui = bool(ui_files) if not has_ui: n = 0 for f in iter_files(path, ('.dspy',)): raw = open(f, encoding='utf-8', errors='replace').read(4000) if 'widgettype' in raw: has_ui = True break n += 1 if n > 300: break i18n_dir = os.path.join(path, 'i18n') if has_ui: if not os.path.isdir(i18n_dir): issues.append((name, 'W4', '有 UI 文件但无 i18n/ 目录')) else: missing = [l for l in LANGS if not os.path.exists(os.path.join(i18n_dir, l, 'msg.txt')) and not os.path.exists(os.path.join(i18n_dir, l, 'i18n.json'))] if missing: issues.append((name, 'W4', 'i18n 缺语种: ' + ','.join(missing))) if name != 'app' and name not in discovered: issues.append((name, 'W5', 'i18n/ 未被 merge_i18n.discover_modules() 发现')) for lang in LANGS: fp = os.path.join(path, 'i18n', lang, 'msg.txt') if os.path.exists(fp): for i, line in enumerate(open(fp, encoding='utf-8', errors='replace'), 1): ls = line.strip() if ls and ls.startswith('#') and (':' in ls or '=' in ls): issues.append((name, 'W6', f'i18n/{lang}/msg.txt:{i} #词条被丢弃: {ls[:50]}')) return issues def main(): ap = argparse.ArgumentParser() ap.add_argument('--repos-dir', default=os.path.expanduser('~/work/repos')) ap.add_argument('--json', default=None) ap.add_argument('-v', '--verbose', action='store_true') args = ap.parse_args() modules = load_modules(args.repos_dir) dicts = {l: merged_dict(l) for l in LANGS} all_keys = {} violations = [] per_mod = OrderedDict() for name, path in modules.items(): if path is None or not os.path.isdir(path): per_mod[name] = {'status': 'missing'} continue keys, viols = set(), [] roots = [path] for f in iter_files(path, ('.ui',)): scan_ui(f, keys, viols, None) for f in iter_files(path, ('.dspy',)): scan_dspy(f, keys, viols) wr = os.path.join(path, 'wwwroot') if os.path.isdir(wr): for f in iter_files(wr, ('.js',)): scan_js(f, keys, viols) for k in keys: all_keys.setdefault(k, set()).add(name) violations.extend(viols) per_mod[name] = {'keys': len(keys), 'violations': len(viols)} missing_dict = {} for key, mods in sorted(all_keys.items()): for lang in ('en', 'jp', 'ko'): if key not in dicts[lang]: missing_dict.setdefault(lang, []).append({'key': key, 'modules': sorted(mods)}) infra = check_infra(modules, os.path.join(APP_DIR, 'scripts', 'merge_i18n.py')) cnt = {} for v in violations: cnt[v[2]] = cnt.get(v[2], 0) + 1 w4 = [i for i in infra if i[1] == 'W4'] w5 = [i for i in infra if i[1] == 'W5'] w6 = [i for i in infra if i[1] == 'W6'] print('=' * 62) print('产线平台 i18n 检查报告') print('=' * 62) print(f"模块数: {len([1 for p in modules.values() if p])}") print(f"抽取可见中文 key(去重): {len(all_keys)}") print(f"W1 硬编码未标i18n: {cnt.get('W1',0)} W3 js硬编码: {cnt.get('W3',0)} W7 动态拼接: {cnt.get('W7',0)}") print(f"W2 字典缺失: en={len(missing_dict.get('en',[]))} jp={len(missing_dict.get('jp',[]))} ko={len(missing_dict.get('ko',[]))}") print(f"W4 缺i18n目录/语种: {len(w4)} W5 merge漏模块: {len(w5)} W6 #词条: {len(w6)}") if w5: print(f" W5 漏: {[i[0] for i in w5]}") if w4: print(f" W4: {[(i[0], i[2][:30]) for i in w4]}") print('-' * 62) for name, st in per_mod.items(): if 'keys' in st: flag = ' <-- !' if st['violations'] else '' print(f" {name:24s} keys={st['keys']:4d} viol={st['violations']:3d}{flag}") if args.verbose: print('-' * 62) for v in violations: tag = ' [git-ignored]' if v[5] else '' print(f" [{v[2]}] {v[0]}@{v[1]}{tag}: {v[3]} | {v[4][:60]}") if args.json: json.dump({ 'summary': {'keys': len(all_keys), 'viol': cnt, 'W2': {l: len(missing_dict.get(l, [])) for l in ('en','jp','ko')}, 'W4': len(w4), 'W5': len(w5), 'W6': len(w6)}, 'violations': [{'file': v[0], 'pos': v[1], 'code': v[2], 'why': v[3], 'text': v[4], 'git_ignored': bool(v[5])} for v in violations], 'missing_dict': missing_dict, 'keys': {k: sorted(v) for k, v in sorted(all_keys.items())}, 'infra': [{'module': i[0], 'code': i[1], 'detail': i[2]} for i in infra], }, open(args.json, 'w', encoding='utf-8'), ensure_ascii=False, indent=1) print(f'JSON 报告: {args.json}') total = sum(cnt.values()) + sum(len(v) for v in missing_dict.values()) + len(infra) return 1 if total else 0 if __name__ == '__main__': sys.exit(main())