""" Robust $lang vs bundle audit (v2). Improvements over v1: 1. Comment/JSDoc-aware:SYNTAX_BUG 判定前置剥离注释(///*)后再判断 2. Format 占位符检查: 检测 bundle 里 translation 含 {name}/{0} 占位符, 但调用点没有提供参数(或参数不匹配)的情况 3. data() 内调用检测: 在 remote-only 模式下,data() 里直接调 $lang(key) 会 在 boot 完成前把空串缓存到 data 字段 —— 标记为风险点(使用 i18n-lazy mixin 的组件是安全的) 4. pages.json 硬编码 navigationBarTitleText 警告: 这些标题不随语言切换变化 (除非 lang.refresh() 被显式调用) 5. tabBar 翻译完整性: 检查 common.tabBar.{home,category,cart,member} 是否齐 Usage: python scripts/audit_lang_bundle_v2.py """ import json, os, re, subprocess from collections import defaultdict ROOT = r'D:\projects\shop-projects\frontend' BUNDLE_PATH = os.path.join(ROOT, 'lang-bundle.json') NODE_SCRIPT = os.path.join(ROOT, 'scripts', 'audit_lang_bundle.js') SKIP_DIRS = {'node_modules', '.unpackage', 'unpackage', '.git', 'dist', 'stub-app-projects'} all_files = [] for dirpath, dirnames, filenames in os.walk(ROOT): if any(s in dirpath for s in SKIP_DIRS): continue for fn in filenames: if fn.endswith(('.vue', '.js')): all_files.append(os.path.join(dirpath, fn)) # ---------- 1) Load bundle ---------- with open(BUNDLE_PATH, 'r', encoding='utf-8') as f: bundle = json.load(f) tree = bundle['data']['tree'] bundle_leaves = set() def walk(obj, prefix=''): if isinstance(obj, dict): for k, v in obj.items(): np = f'{prefix}.{k}' if prefix else k if isinstance(v, dict): walk(v, np) else: bundle_leaves.add(np) walk(tree) # ---------- 2) Strip comments ---------- # JS/Vue 有 // 与 /* */ 注释.我们在提取 $lang 调用之前先把它们剥离, # 避免注释里的 '$lang(\"xxx\")' 占位被误判为运行时调用 def strip_comments(text, is_vue=False): # 去掉块注释 /* ... */ out = re.sub(r'/\*[\s\S]*?\*/', lambda m: ' ' * (m.end() - m.start()) if not is_vue else ' ' * (m.end() - m.start()), text) # 但 vue 模板里也可能有 —— 不处理(那里面几乎不会有 $lang) # 去掉行注释 // ... (保留 \n 让行号不变) out = re.sub(r'//[^\n]*', lambda m: ' ' * len(m.group()), out) return out # ---------- 3) Extract $lang calls ---------- # 注意:PARAM_RE 之前用 `[^)]*` 抓参,但反模式 — `[^)]` 跨匹配换行, # 会一路吃到全文件末尾的下一个 ')'。改成只匹配到行尾: CALL_RE = re.compile(r"\$lang\(\s*(['\"])([^'\"]+)\1") PARAM_RE = re.compile(r"\$lang\(\s*(['\"])([^'\"]+)\1") # 用同一组做 key 抓取,params 改手动解析 def extract_calls(content, file): # 先剥离注释,再做正则 (注意:strip 后行号会变,但相对偏移我们记录原始位置即可) stripped = strip_comments(content) code_calls = [] for m in CALL_RE.finditer(stripped): # Skip matches inside remaining strings (rare) code_calls.append({'file': file, 'key': m.group(2), 'pos': m.start()}) return code_calls # Param extraction def extract_calls_with_args(content, file): stripped = strip_comments(content) results = [] for m in PARAM_RE.finditer(stripped): key = m.group(2) # Look for the corresponding raw text after this position in original # to extract params # Strip the regex capture; we instead scan AFTER the key quote pos = m.end() rest = stripped[pos:] # Stop at the matching close paren of $lang params = [] if rest.lstrip().startswith(','): rest = rest.lstrip()[1:] depth = 1 i = 0 in_str = None esc = False while i < len(rest) and depth > 0: ch = rest[i] if esc: esc = False i += 1 continue if in_str: if ch == '\\': esc = True elif ch == in_str: in_str = None i += 1 continue if ch in ('"', "'"): in_str = ch i += 1 continue if ch == '(': depth += 1 elif ch == ')': depth -= 1 i += 1 if depth == 0: break continue i += 1 args_text = rest[:i - 1] if depth == 0 else rest # exclude final ) # split by top-level commas params = split_top_level_commas(args_text) # Filter out string keys that appear inside comments - shouldn't happen now results.append({'file': file, 'key': key, 'params': params, 'pos': m.start()}) return results def split_top_level_commas(text): parts = [] depth = 0 in_str = None esc = False cur = [] for ch in text: if esc: cur.append(ch); esc = False; continue if in_str: cur.append(ch) if ch == '\\': esc = True elif ch == in_str: in_str = None continue if ch in ('"', "'", '`'): in_str = ch; cur.append(ch); continue if ch in '([{': depth += 1; cur.append(ch); continue if ch in ')]}': depth -= 1; cur.append(ch); continue if ch == ',' and depth == 0: parts.append(''.join(cur).strip()) cur = [] continue cur.append(ch) if cur: last = ''.join(cur).strip() if last: parts.append(last) return parts all_calls = [] all_calls_with_args = [] for fp in all_files: rel = os.path.relpath(fp, ROOT) try: with open(fp, 'r', encoding='utf-8') as fh: content = fh.read() except UnicodeDecodeError: continue for c in extract_calls(content, rel): all_calls.append(c) for c in extract_calls_with_args(content, rel): all_calls_with_args.append(c) unique_keys = sorted(set(c['key'] for c in all_calls)) print(f'[code] {len(all_calls)} $lang calls across {len(set(c["file"] for c in all_calls))} files') print(f'[code] {len(unique_keys)} unique keys') # ---------- 4) Match keys vs bundle ---------- found, missing = [], [] for k in unique_keys: if k in bundle_leaves: found.append(k) else: missing.append(k) print(f'[match] FOUND={len(found)} MISSING={len(missing)}') # ---------- 5) Local lang loading (zh-cn/en-us) ---------- print('Loading local lang files via Node...') def load_locale(locale): res = subprocess.run(['node', NODE_SCRIPT, locale], cwd=ROOT, capture_output=True, text=True, encoding='utf-8') if res.returncode != 0: return {} parsed = json.loads(res.stdout) cleaned = {} for full, langobj in parsed.items(): rel = os.path.relpath(full, os.path.join(ROOT, 'lang', locale)) rel_no_ext = rel[:-3] if rel.endswith('.js') else rel cleaned[rel_no_ext] = langobj return cleaned zh_cn = load_locale('zh-cn') en_us = load_locale('en-us') print(f' zh-cn: {len(zh_cn)} files; en-us: {len(en_us)} files') # ---------- 6) New checks ---------- # 6a) Format placeholder check: find keys containing {name} or {0} style placeholders # and check whether their call sites provide corresponding args PLACEHOLDER_RE = re.compile(r'\{([a-zA-Z_][\w]*)\}|\{(\d+)\}') placeholder_keys = {} # key -> set of placeholder names found in bundle leaf def walk_for_placeholders(obj, prefix=''): if isinstance(obj, dict): for k, v in obj.items(): np = f'{prefix}.{k}' if prefix else k if isinstance(v, dict): walk_for_placeholders(v, np) elif isinstance(v, str): mset = set() for m in PLACEHOLDER_RE.finditer(v): mset.add(m.group(1) if m.group(1) else f'_{m.group(2)}') if mset: placeholder_keys[np] = mset walk_for_placeholders(tree) print(f'[placeholders] {len(placeholder_keys)} keys with {{...}} placeholders') def extract_param_keys(arg_strs): """Given the param string list, extract named keys from {key: value} maps or position indices. Returns (set of names, count of positional args).""" names = set() positional = 0 for s in arg_strs: s = s.strip() if not s: continue # Object literal: { a: 1, b, c: 2 } — note JS shorthand `{ a }` is `{ a: a }` if s.startswith('{') and s.endswith('}'): inner = s[1:-1] # Standard explicit key: value for k in re.findall(r"['\"]?([a-zA-Z_]\w*)['\"]?\s*:", inner): names.add(k) # JS shorthand: identifier alone (with optional type-quote), e.g. `{ appName }` = `{ appName: appName }` # Match identifiers NOT followed by ':' (which means they were just `{ key }` shorthand) shorthand = re.findall(r"(? 127 for c in txt): HARDCODED_NAV_TITLES.append((prefix + node.get('path', ''), txt)) if 'root' in node and 'pages' in node: walk_pjson(node['pages'], prefix + node['root'] + '/') else: for v in node.values(): if isinstance(v, list): for item in v: walk_pjson(item, prefix) elif isinstance(node, list): for item in node: walk_pjson(item, prefix) walk_pjson(pjson) except Exception as e: print(f' [warn] failed to parse pages.json: {e}') print(f'[pages.json] {len(HARDCODED_NAV_TITLES)} pages have hardcoded navigationBarTitleText') # 6d) tabBar translation completeness tabbar_keys = ['common.tabBar.home', 'common.tabBar.category', 'common.tabBar.cart', 'common.tabBar.member'] tabbar_missing = [k for k in tabbar_keys if k not in bundle_leaves] print(f'[tabBar] missing translations: {tabbar_missing if tabbar_missing else "none"}') # ---------- 7) Bundle-vs-local drift ---------- local_only_keys = [] bundle_only_keys = [] for k in unique_keys: zh = zh_cn.get('common', {}) or {} if k in found: continue # check if exists in zh-cn local in ANY file as flat leaf exists_in_zh = False for fname, d in zh_cn.items(): if isinstance(d, dict): for fk, fv in d.items(): if fk == k and isinstance(fv, str): exists_in_zh = True break if exists_in_zh: break if exists_in_zh and k not in bundle_leaves: local_only_keys.append(k) print(f'[drift] {len(local_only_keys)} keys are in local but missing from bundle') # ---------- 8) Classify missing keys with stripped content ---------- # Re-classify SYNTAX_BUG using more robust heuristic SYNTAX_PATTERNS = [ r"^xxx$", # literal "xxx" placeholder r"^foo\.", # foo.* documentation placeholders r"\.\.\.\s*$", # ...-trailing r"^[a-z]+(\.[a-z]+)?\.\.\.$", # trailing-dot keys (e.g. status.) ] def classify_key(key, file): # Reject by pattern if re.match(r"^xxx$", key): return 'SYNTAX_BUG', "literal 'xxx' placeholder" if re.match(r"^key$", key): return 'SYNTAX_BUG', "function parameter named 'key'" if re.match(r"^title$", key) and file and 'lang.js' in file and not file.endswith('pages.json'): # Most "title" instances in lang.js itself are comments return 'SYNTAX_BUG', "short-key 'title' (JSDoc/parameter in lang.js)" if re.match(r"^(components|components-diy)\.[a-z]+\.(xxx|title|default)$", key): return 'SYNTAX_BUG', "document/example placeholder" if re.search(r'\.\s*$', key): # trailing dot in regex return 'SYNTAX_BUG', "trailing dot in concatenated template" # Check if in zh-cn/en-us local for fname, d in zh_cn.items(): if isinstance(d, dict) and last_path(d) == key: return 'LOCALLY_DEFINED', f"local {fname}: {key}" return 'TRULY_MISSING', 'not in bundle, not local' def last_path(d): """Walk dotted segments""" parts = d if isinstance(d, dict) else {} arr = d if isinstance(d, list) else [] return None # Better: just look for the key in any local file flat def in_local_flat(key): for d in zh_cn.values() + en_us.values(): if not isinstance(d, dict): continue for fk in d.keys(): if fk == key and isinstance(d[fk], str): return True return False # Classify all missing classification = defaultdict(list) for c in all_calls_with_args: key = c['key'] if key in found: continue cat, reason = classify_key(key, c['file']) classification[cat].append({'key': key, 'file': c['file'], 'reason': reason}) # Print summary print() print('=== Missing keys classification ===') for cat, items in classification.items(): print(f' {cat}: {len(items)}') # ---------- 9) Detailed report ---------- report = { 'bundle_version': bundle['data'].get('version'), 'bundle_lang': bundle['data'].get('lang'), 'bundle_leaves': len(bundle_leaves), 'code_calls_total': len(all_calls), 'code_unique_keys': len(unique_keys), 'found_in_bundle': len(found), 'missing_summary': {k: len(v) for k, v in classification.items()}, 'missing_classified': {k: v for k, v in classification.items()}, 'placeholder_issues': deduped_placeholder_issues, 'data_lang_issues': dict(data_lang_issues), 'hardcoded_navbar_titles': HARDCODED_NAV_TITLES, 'tabbar_missing': tabbar_missing, 'local_only_keys': local_only_keys, 'bundle_only_keys_in_unique': [], # placeholder for future } out_path = os.path.join(ROOT, 'scripts', 'lang_audit_report_v2.json') with open(out_path, 'w', encoding='utf-8') as f: json.dump(report, f, ensure_ascii=False, indent=2) print(f'Report saved to {out_path}')