Files
lucky_shop/scripts/audit_lang_bundle.py
ZF sun 2d7d7f731c fix(remote lang tree): 修复命名空间被基本类型值覆盖的bug
修复remote-only模式下语言包归一化的历史bug:当页面leaf顶层存在与命名空间同名的字符串键时,会覆盖整个namespace导致子key失效。通过对齐normalizePageLangPack的两层合并逻辑来修复:
1. 先将页面语言包按路径段转为命名空间结构再合并
2. 额外平铺leaf的基本类型键到根节点以兼容短key调用
2026-07-04 09:25:01 +08:00

232 lines
8.3 KiB
Python

"""Full $lang vs bundle audit.
Steps:
1. Load /api/lang/bundle (lang-bundle.json) tree
2. Extract every $lang('xxx'[, ...]) first-arg string from .vue/.js files
3. Locate each key in bundle tree (and a small set of structural fallbacks)
4. Classify: FOUND / MISSING / SYNTAX_ERROR
5. For MISSING: also check local lang files (zh-cn, en-us) to determine
whether the key exists locally but bundle missed it
"""
import json, os, re, subprocess
from collections import defaultdict, Counter
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')
# ---------- 1. Load bundle ----------
with open(BUNDLE_PATH, 'r', encoding='utf-8') as f:
bundle = json.load(f)
tree = bundle['data']['tree']
print(f'[bundle] version={bundle["data"].get("version")} lang={bundle["data"].get("lang")} source={bundle["data"].get("source")}')
# Flatten bundle keys (leaf string values)
bundle_leaves = set()
bundle_nodes = set() # intermediate nodes too, for fallback lookups
def walk(obj, prefix=''):
if isinstance(obj, dict):
if prefix:
bundle_nodes.add(prefix)
for k, v in obj.items():
nk = f'{prefix}.{k}' if prefix else k
if isinstance(v, dict):
walk(v, nk)
else:
bundle_leaves.add(nk)
elif prefix:
bundle_leaves.add(prefix)
walk(tree)
print(f'[bundle] {len(bundle_leaves)} leaf keys, {len(bundle_nodes)} intermediate nodes')
# ---------- 2. Extract $lang() calls ----------
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))
# Match $lang('key'[, ...]) - first string arg, single OR double quotes
# $lang() can span many lines but first arg is generally on same line
call_re = re.compile(r"\$lang\(\s*(['\"])([^'\"]+)\1")
all_calls = []
for fp in all_files:
rel = os.path.relpath(fp, ROOT)
with open(fp, 'r', encoding='utf-8') as fh:
try:
content = fh.read()
except UnicodeDecodeError:
continue
for m in call_re.finditer(content):
all_calls.append({'file': rel, 'key': m.group(2), 'pos': m.start()})
print(f'[code] {len(all_calls)} $lang calls across {len(set(c["file"] for c in all_calls))} files')
unique_keys = sorted(set(c['key'] for c in all_calls))
print(f'[code] {len(unique_keys)} unique keys called')
# ---------- 3. Classify ----------
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)}')
# ---------- 4. For MISSING keys: check local lang files too ----------
print()
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:
print(f' node {locale} stderr: {res.stderr[:200]}')
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')
def lookup_full(key, data):
"""Walk dotted segments in nested map."""
cur = data
for seg in key.split('.'):
if not isinstance(cur, dict) or seg not in cur:
return None
cur = cur[seg]
return cur if isinstance(cur, str) else None
def local_lookup(key, locale_map):
arr = key.split('.')
last = arr[-1]
fname_segments = '/'.join(arr[:-1])
matches = []
# 1) Short key in flat page file: e.g. fenxiao/bill.js exports {billNoPrefix}
for fname, data in locale_map.items():
if not isinstance(data, dict):
continue
if (fname.endswith('/' + fname_segments) or fname == fname_segments) and \
last in data and isinstance(data[last], str):
matches.append(('short:' + last, fname, data[last]))
# 2) Full dotted in any nested file
for fname, data in locale_map.items():
if not isinstance(data, dict):
continue
v = lookup_full(key, data)
if isinstance(v, str):
matches.append(('nested:' + key, fname, v))
break
return matches
def classify_missing(key):
"""Returns (category, evidence). category in {LOCALLY_DEFINED, SYNTAX_BUG, TRULY_MISSING}"""
# Syntax error heuristics
if key.endswith('.') or key in ('key', 'title', 'xxx'):
return 'SYNTAX_BUG', 'trailing dot or generic identifier'
if 'xxx' in key.lower() and not (key.startswith('xxx') and key.count('.') == 0):
# 'components.foo.title' / 'components-diy.xxx' style placeholders in mixin
return 'SYNTAX_BUG', "placeholder/example string (e.g. 'components.foo.title')"
# Check local files
zh = local_lookup(key, zh_cn)
en = local_lookup(key, en_us)
if zh or en:
# Capture the actual found path in local file for evidence
ev = ''
if zh:
ev += f'zh-cn: {zh[0][1]} {zh[0][0]}={zh[0][2]!r}'
if en:
ev += (' | ' if ev else '') + f'en-us: {en[0][1]} {en[0][0]}'
return 'LOCALLY_DEFINED', ev
return 'TRULY_MISSING', 'not in zh-cn / en-us / bundle'
cat_counts = Counter()
locally_defined = []
syntax_bugs = []
truly_missing = []
for k in missing:
cat, ev = classify_missing(k)
cat_counts[cat] += 1
usage = [c for c in all_calls if c['key'] == k]
entry = {
'key': k, 'category': cat, 'evidence': ev,
'usage_count': len(usage), 'files': sorted(set(c['file'] for c in usage))[:6],
}
if cat == 'LOCALLY_DEFINED':
locally_defined.append(entry)
elif cat == 'SYNTAX_BUG':
syntax_bugs.append(entry)
else:
truly_missing.append(entry)
print()
print('=== Classification of missing keys ===')
for cat, n in cat_counts.most_common():
print(f' {cat}: {n}')
# ---------- 5. Inspect a few interesting key paths in bundle ----------
# Find for each "locally defined" key, the actual structure in bundle (closest match)
def closest_in_bundle(key):
"""Return bundle path segment that contains parts of key."""
parts = key.split('.')
cur = tree
best = tree
path = []
for p in parts:
if isinstance(cur, dict):
cand = cur.get(p)
if cand is None:
# Try camelCase
cc = re.sub(r'[-_]([a-z])', lambda m: m.group(1).upper(), p)
cand = cur.get(cc)
if cand is not None:
path.append(cc)
cur = cand
continue
if cand is not None:
path.append(p)
cur = cand
else:
return '.'.join(path), type(cur).__name__
else:
return '.'.join(path), type(cur).__name__
return '.'.join(path), type(cur).__name__
print()
print('=== For LOCALLY_DEFINED keys: real bundle structure check ===')
for e in locally_defined[:10]:
actual = closest_in_bundle(e['key'])
print(f' $lang({e["key"]})')
print(f' call: {e["files"][0]}')
print(f' bundle partial walk stopped at: {actual[0]} ({actual[1]})')
# ---------- 6. Save report ----------
report = {
'bundle_version': bundle['data'].get('version'),
'bundle_lang': bundle['data'].get('lang'),
'bundle_source': bundle['data'].get('source'),
'bundle_leaves': len(bundle_leaves),
'code_calls_total': len(all_calls),
'code_unique_keys': len(unique_keys),
'found_in_bundle': len(found),
'missing_summary': dict(cat_counts),
'missing_locally_defined': locally_defined,
'missing_syntax_bugs': syntax_bugs,
'missing_truly_missing': truly_missing,
}
out_path = os.path.join(ROOT, 'scripts', 'lang_audit_report.json')
with open(out_path, 'w', encoding='utf-8') as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(f'\nReport saved to {out_path}')