#!/usr/bin/env python3 """ fix_i18n_w1_dyn.py — dspy 动态串 i18n 重构(AST 安全) 把 `"text": EXPR`(EXPR 含拼接/格式化)重构为: "otext": TEMPLATE, "text": TEMPLATE, "i18n": True, "i18n_params": {p0:..,p1:..} TEMPLATE 用 ${p0}/${p1} 占位;i18n_params 提供 JSON 可序列化的值。 依赖框架 widget.js 的 i18n_params 支持(TextBase.set_attrs → i18n._(otext, params))。 只处理 ast 能解析的 Add 链 / Mod 格式化 / 纯常量(多行隐式拼接); 解析失败的记 manual,绝不破坏文件。 用法: python3 scripts/fix_i18n_w1_dyn.py /tmp/i18n.json [--dry-run] """ import json, re, sys, ast from collections import defaultdict PRINTF_SPEC = re.compile(r'%(?:\(\w+\))?[-+ #0]*\d*(?:\.\d+)?[hlL]?[diouxXeEfFgGcrsa%]') def find_value_expr(raw, after_colon): """从 after_colon("text": 之后)深度扫描出值表达式 [start,end)。""" i = after_colon n = len(raw) while i < n and raw[i] in ' \t\r\n': i += 1 start = i depth = 0 in_s = None while i < n: c = raw[i] if in_s: if c == '\\': i += 2; continue if c == in_s: in_s = None i += 1; continue if c in '"\'': in_s = c; i += 1; continue if c in '([{': depth += 1; i += 1; continue if c in ')]}': if depth == 0: return start, i # 闭合外层结构 = 值结束 depth -= 1; i += 1; continue if c == ',' and depth == 0: return start, i i += 1 return start, n def src_of(node, expr_src): try: return ast.get_source_segment(expr_src, node) except Exception: return None def build_template_and_params(expr_src): """返回 (template_str, params_dict_src, ok, reason)。params_dict_src 是 Python 源码片段。""" expr_src = expr_src.strip() try: node = ast.parse(expr_src, mode='eval').body except Exception as e: return None, None, False, f'ast解析失败:{e}' # 纯常量字符串(含多行隐式拼接)→ 无参数 if isinstance(node, ast.Constant) and isinstance(node.value, str): return node.value, None, True, 'const' params = [] # Mod 格式化: "FMT" % args if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Mod) \ and isinstance(node.left, ast.Constant) and isinstance(node.left.value, str): fmt = node.left.value right = node.right if isinstance(right, ast.Tuple): arg_srcs = [src_of(e, expr_src) for e in right.elts] else: arg_srcs = [src_of(right, expr_src)] if any(a is None for a in arg_srcs): return None, None, False, 'mod参数源提取失败' # 走 fmt,把 spec 换 ${pN} out = [] ai = 0 last = 0 for m in PRINTF_SPEC.finditer(fmt): out.append(fmt[last:m.start()]) spec = m.group(0) if spec == '%%': out.append('%') else: pname = f'p{ai}' out.append('${' + pname + '}') if ai >= len(arg_srcs): return None, None, False, 'mod参数数不足' # 每个参数用自身 spec 重新格式化,保证显示一致 + JSON 可序列化 params.append((pname, f'str({spec!r} % ({arg_srcs[ai]},))' if spec[-1] in 'diouxXeEfFgG' else f'str({arg_srcs[ai]})')) ai += 1 last = m.end() out.append(fmt[last:]) template = ''.join(out) if ai != len(arg_srcs): return None, None, False, f'mod参数数不匹配 spec={ai} args={len(arg_srcs)}' pd = '{' + ', '.join(f'"{k}": {v}' for k, v in params) + '}' if params else None return template, pd, True, 'mod' # Add 拼接链 parts = [] def flatten(n): if isinstance(n, ast.BinOp) and isinstance(n.op, ast.Add): flatten(n.left); flatten(n.right) else: parts.append(n) flatten(node) template_pieces = [] pi = 0 has_dyn = False for p in parts: if isinstance(p, ast.Constant) and isinstance(p.value, str): template_pieces.append(p.value) else: s = src_of(p, expr_src) if s is None: return None, None, False, 'add操作数源提取失败' pname = f'p{pi}'; pi += 1 template_pieces.append('${' + pname + '}') params.append((pname, f'str({s})')) has_dyn = True if not has_dyn: # 全是字符串常量拼接 → 合并 return ''.join(template_pieces), None, True, 'const-concat' template = ''.join(template_pieces) pd = '{' + ', '.join(f'"{k}": {v}' for k, v in params) + '}' if params else None return template, pd, True, 'add' def main(): report = json.load(open(sys.argv[1], encoding='utf-8')) dry = '--dry-run' in sys.argv viols = [v for v in report['violations'] if v['code'] == 'W1' and not v['git_ignored'] and v['file'].endswith('.dspy')] by_file = defaultdict(list) for v in viols: by_file[v['file']].append(v) fixed = manual = 0 manual_list = [] keys_added = [] for fp, vs in sorted(by_file.items()): raw = open(fp, encoding='utf-8').read() changed = False for v in sorted(vs, key=lambda x: -x['pos']): pos = v['pos'] mk = re.compile(r'''(['"])(text|otext)\1\s*:''').match(raw, pos) if not mk: manual += 1; manual_list.append((fp, pos, 'key定位失败', v['text'][:40])); continue keyname = mk.group(2); q = mk.group(1) vstart, vend = find_value_expr(raw, mk.end()) expr_src = raw[vstart:vend] tmpl, pd, ok, why = build_template_and_params(expr_src) if not ok: manual += 1; manual_list.append((fp, pos, why, expr_src[:50])); continue # 转义模板里的双引号/反斜杠进 JSON 字符串 tmpl_json = tmpl.replace('\\', '\\\\').replace('"', '\\"') if keyname == 'otext' and pd is None and '不可退费' in tmpl: # 仅缺 i18n:True ins = f', {q}i18n{q}: True' seg_new = raw[pos:vend] + ins else: params_part = f', {q}i18n_params{q}: {pd}' if pd else '' seg_new = (f'{q}otext{q}: "{tmpl_json}", {q}text{q}: "{tmpl_json}", ' f'{q}i18n{q}: True{params_part}') if not dry: raw = raw[:pos] + seg_new + raw[vend:] keys_added.append(tmpl) fixed += 1; changed = True if changed and not dry: open(fp, 'w', encoding='utf-8').write(raw) print(f'{"DRY-RUN " if dry else ""}fixed={fixed} manual={manual} files={len(by_file)}') for mm in manual_list: print(' MANUAL', mm[0].split('/repos/')[-1], f'@{mm[1]}', mm[2], '|', mm[3]) if dry: print('--- 模板预览(将进字典的key) ---') for k in keys_added: print(' ', k[:70]) if __name__ == '__main__': main()