# -*- coding: utf-8 -*- """只读审计:全仓"把源码交给 Python 解析器"的调用点是否都过了 Viper 软关键字层。 背景(为什么需要这个门禁) Viper 前端 = TPC 自研 vast(`lib/vast`)+ **唯一展开点** `lib/core/SymbolUtils.ExpandSoftKeywords`(`fn/fun/func/function` → `def`; `enum X:` 块 → `class X(CEnum):`,两者均同行替换、行号不变)。 凡把 **Viper 源码文本**交给 `ast.parse` / `vast.parse` 的入口都必须先过这一层; 漏过者的失败形态是**静默降级**,不是报错:`includes/ast/base.vp:168` 的 `enum ASTFlag:` 曾让整个 base.vp 的声明(含顶层 `_init_ast`)一个都不注册, 下游只报 `Undefined function: '_init_ast'`(浪费一轮排查,见 `Debugger/enum-semantics.md` §13)。判据 = `Grep "ast\\.parse\\("` 逐个确认 上游是否已有展开层,或该输入**必然无糖**。 分类(逐调用点,见 `Classify`) PASS 首实参文本含 `ExpandSoftKeywords`(同一表达式内已展开) EVAL `mode='eval'`:注解 / 表达式的**片段**解析,不是整份源码 ⇒ 无需 EXEMPT 命中下方冻结豁免表:输入必然是 `.vpi`/`.pyi` 无糖产物,或同一函数 上游(紧邻几行)已展开 ⇒ 已人工审计确认 SUSPECT 其余 ⇒ **需人工判定**;`--strict` 下 exit 1(门禁失败) 覆盖范围:仓库内全部 `*.py`(含根目录脚本、`MCP/**`、`TransPyC/Tools/**`), 跳过 `.git`/`__pycache__`/`output`/`temp`/`__vpcbuild__`/`dlls` 与解析器自身 (`lib/vast`、`lib/vast_c` 是前端**实现**,不是前端**消费者**)。 VPC 侧源码(`ViperC/**` 的 `.vp`)跑自己的前端,不在本脚本范围内。 用法(**只读,不写任何文件**) python TransPyC/Tools/AuditViperParseEntries.py # 打印审计表 python TransPyC/Tools/AuditViperParseEntries.py --strict # 有 SUSPECT 则 exit 1 """ import ast as pyast import os import sys ScriptDir = os.path.dirname(os.path.abspath(__file__)) RepoRoot = os.path.dirname(os.path.dirname(ScriptDir)) SkipDirs = {'__pycache__', '.git', '__vpcbuild__', 'dlls', 'output', 'temp', 'node_modules'} # 前端实现(vast 解析器本体;它自己就是"唯一许可入口"的实现层) SkipRelPrefixes = ('TransPyC/lib/vast/', 'TransPyC/lib/vast_c/') # ---- 冻结豁免表:(仓库相对路径, 首实参文本归一化) -> 豁免理由 ------------------ # 新增合法调用点时必须在此登记原因;否则 --strict 会把新调用点当作 SUSPECT 拦下。 # 表来源:本轮全仓穷举审计(见最终报告 B 节审计表)。 Exempt = { ('TransPyC/lib/Projectrans/Phase2Translator.py', 'f.read()'): '.vpi/.pyi 无糖产物(_ScanPyiFiles / _PyiCacheWorkerInitializer 仅扫 .vpi/.pyi)', ('TransPyC/lib/Projectrans/Phase2Translator.py', 'sig_content'): 'sig_files 全部来自 temp/*.vpi|*.pyi(Phase2Translator._LoadSha1Map:759)', ('TransPyC/lib/Projectrans/DeclarationGenerator.py', 'pyi_content'): 'generate(pyi_content, ...) 的入参来自 Phase1Generator 读 temp/{sha1}.vpi', ('TransPyC/lib/StubGen/Converter.py', 'PyContent'): 'convert() 首行 PyContent = ExpandSoftKeywords(PyContent)(Converter.py:68)', ('TransPyC/lib/Shell/TransPyC.py', 'content'): '同函数 :100 content = ExpandSoftKeywords(content)', ('TransPyC/lib/Shell/TransPyC.py', 'Content'): '同函数 :445 Content = ExpandSoftKeywords(Content)', ('TransPyC/lib/Shell/TransPyC.py', 'self.code'): 'Convert() :522 self.code = ExpandSoftKeywords(self.code)', ('TransPyC/lib/core/Translator/PythonParser.py', 'content'): 'ParsePythonFile() :76 content = ExpandSoftKeywords(content)', ('TransPyC/lib/core/Translator/AnnotationLoader.py', 'ann_content'): '三处调用点各自紧邻上一行 ann_content = ExpandSoftKeywords(ann_content)', ('TransPyC/lib/core/SymbolExtractor.py', 'code'): 'extract(FilePath) 的 FilePath 全部来自 LoadModuleSymbols → .vpi/.pyi', ('TransPyC/lib/core/Handles/HandlesImports.py', '_fh0.read()'): '.vpi(_TryLoadClassMembersFromPyi guard 分支补解析 class_parents)', ('TransPyC/lib/core/Handles/HandlesImports.py', 'f.read()'): '.vpi(_TryLoadClassMembersFromPyi 主路径 _pyi_cache 填充)', ('TransPyC/lib/core/Handles/HandlesExprCall.py', 'pyi_code'): '.vpi(_vtable_pyi_cache 填充,路径固定 temp/{sha1}.vpi)', } def Classify(Rel, Arg, Mode): """→ (类别, 理由)。""" if 'ExpandSoftKeywords' in Arg: return 'PASS', '首实参已过 ExpandSoftKeywords' if Mode.strip('\'"') == 'eval': return 'EVAL', "mode='eval'(注解/表达式片段,非整份源码)" Hit = Exempt.get((Rel, Arg)) if Hit: return 'EXEMPT', Hit return 'SUSPECT', '未过展开层且不在冻结豁免表中(需人工判定该输入是否必然无糖)' def IterCallSites(): """遍历仓库 .py,产出 (rel, line, arg, mode);无法按 CPython 解析的文件记入 Unparsed。""" for Root, Dirs, Files in os.walk(RepoRoot): Dirs[:] = sorted(d for d in Dirs if d not in SkipDirs) for Name in sorted(Files): if not Name.endswith('.py'): continue Path = os.path.join(Root, Name) Rel = os.path.relpath(Path, RepoRoot).replace(os.sep, '/') if Rel.startswith(SkipRelPrefixes): continue try: with open(Path, encoding='utf-8') as Fh: Src = Fh.read() Tree = pyast.parse(Src, Rel) except Exception as Err: yield ('', 0, Rel, type(Err).__name__) continue for Node in pyast.walk(Tree): if not isinstance(Node, pyast.Call): continue Func = Node.func if not isinstance(Func, pyast.Attribute) or Func.attr != 'parse': continue if not (isinstance(Func.value, pyast.Name) and Func.value.id in ('ast', 'vast')): continue Arg = pyast.get_source_segment(Src, Node.args[0]) if Node.args else '' Mode = '' for Kw in Node.keywords: if Kw.arg == 'mode': Mode = pyast.get_source_segment(Src, Kw.value) or '' yield ('site', Node.lineno, Rel, ' '.join((Arg or '').split()), ' '.join(Mode.split())) def Main(Strict): Rows = [] Unparsed = [] for Item in IterCallSites(): if Item[0] == '': Unparsed.append((Item[2], Item[3])) continue _, Line, Rel, Arg, Mode = Item Kind, Why = Classify(Rel, Arg, Mode) Rows.append((Kind, Rel, Line, Arg, Why)) Order = {'SUSPECT': 0, 'PASS': 1, 'EVAL': 2, 'EXEMPT': 3} Rows.sort(key=lambda R: (Order[R[0]], R[1], R[2])) Count = {} for Kind, _, _, _, _ in Rows: Count[Kind] = Count.get(Kind, 0) + 1 for Kind, Rel, Line, Arg, Why in Rows: print('%-8s %s:%d arg=%s — %s' % (Kind, Rel, Line, (Arg or '')[:60], Why)) print('-' * 100) print('sites=%d PASS=%d EVAL=%d EXEMPT=%d SUSPECT=%d unparsed=%d' % ( len(Rows), Count.get('PASS', 0), Count.get('EVAL', 0), Count.get('EXEMPT', 0), Count.get('SUSPECT', 0), len(Unparsed))) if Unparsed: print('无法按 CPython 解析(未审计): %s' % Unparsed) if Strict and Count.get('SUSPECT'): sys.stderr.write('[strict] 门禁失败: 有 %d 个未过展开层且未登记的解析入口\n' % Count['SUSPECT']) return 1 return 0 if __name__ == '__main__': sys.exit(Main('--strict' in sys.argv[1:]))