pipeline-app/scripts/check_i18n.py

417 lines
18 KiB
Python
Raw Permalink 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.

#!/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/<lang>/msg.txt|i18n.json → scripts/merge_i18n.py →
wwwroot/i18n/<lang>/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-14merge 已重写为自动发现,旧版按源码文本
# 扫描 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())