专家复核 · 2026-09-10

一条修对的判据,如何长成 6,491 行的 bash 解释器

issue #439 / PR #443 的方向、架构、评审性质与下一步。结论基于本次会话对真实湖仓的独立复核,不引用 PR 自述。

xforceplus/ontoos_extract PR #443 · DRAFT 分支 worktree-ir439 · head d8b8408b 80 提交 · 47 轮评审 · 369 线程

00四个问题的答案

1

方向:前 5 个提交是对的,之后偏了 偏差成立

五条范围在第 5 个提交(a43d9233,1,096 行)就已全部落地。第 11 轮起转向「把 provenance.py 写成更完整的 bash 解释器」,代码涨到 6,491 行。本次实测:第 5 提交 / 第 10 轮 / 第 47 轮三个版本,对 10,733 条真实归档跑出逐条完全相同的归属结果——多出来的 5,395 行对产出零影响。

2

架构:接缝是干净的,接缝后面不是 局部合理

runner.py 只从 provenance 取 9 个名字,接口面窄且稳——这一层设计没问题。但接口后面 78% 的文件是一个没有内部边界的手写 shell 前端:最长单函数 661 行、单类 612 行,与 GitLab API 客户端、job↔镜像匹配挤在同一个 6,491 行的模块里。

3

评审意见:技术上多数为真,但三分之一落在真实数据 0 命中的面上 性质参半

369 条线程中 124 条(33.6%)讨论的 shell 语义(case/heredoc/trap/!/pipefail/coproc…)在 4 个真实批、约 13,000 条 job 脚本里出现 0 次。真有一条例外且值得修:解释器自己引入的组合爆炸(第 41 轮已加界)。

4

下一步:拆成两片,只保留有真实产出面的一片 可执行

A 片 = 归属判据重写(≤400 行,与 issue AC 一一对应);B 片 = Dockerfile 侧与 runner 侧的四条(约 1,300 行,已由 a43d9233 交付)。解释器整体丢弃,改为「文本级高召回登记 + 显式降级留痕」。三条闸同时立起来,否则下一个 PR 会重演。

01证据基线:本次复核做了什么

下面每个数字都是本次会话现场跑出来的,不是从 PR 自述转抄的。复核只有两条路径:读 PR 分支的代码(本地 worktree),和只读查询 0904 批的真实湖仓(经 121 跳板 ssh -L 15432)。

事实数字怎么得到的
0904 批 job 脚本3,260 条 / 338 种去重文本dev_ods.code_pipeline_jobsbatch_id LIKE '20260904%'(批 id 按源分片,共 1,559 片)
docker build 的脚本2 条正则扫全批;其中只有 1 条含 ≥2 次 build,正是 issue 里的 openclaw job
PR 建模的 shell 形态命中0heredoc / case / trap / pipefail / eval / coproc / 函数定义 / ! / 子壳 / sh -c / shopt / { …; } / timeout / env / exec / pushd / 进程替换 / 算术展开 —— 4 个批(0814/0826/0829/0904)全部为 0
PR 自带回放(新判据 vs 旧口径)10,733 行 → 变 1 条 / 错 0 条用 PR 的 parse_job_script + pick_dockerfile_for_imagecode_archives ⋈ code_pipeline_jobs
三个版本对比结果逐条相同a43d9233(1,096 行)/ a1f3ab56(1,853 行)/ d8b8408b(6,491 行)各跑一遍,均为 10,733 行 → 1 变 / 0 错
现场损坏(湖仓真值)10 个幽灵组件归档 e789b0c0da15ee534a18cb28archive_kind='frontend'(错),10 个组件 source_root_path 全 NULL、framework_kind='fe'detection_method='cicd-dockerfile'
错归属的 Dockerfile19 COPY / 13 带 --from湖仓 code_dockerfiles 里存的是 openclaw/Dockerfile(base-runtime);重复 dst:.×4、./×2、./patches×2
代码规模876 → 6,491 行(+5,615)顶层 def/class:29 → 122;测试文件 1,126 → 8,930 行;整 PR 14,578 插入 / 14 文件
CI 是否跑过从未每次 run 在 2–5 秒失败,steps == 0,注解原文:「The job was not started because recent account payments have failed…」
PR 上的人类评论0 条123 条顶层评论:ysatnaf 66、copilot-swe-agent 54(全是报错)、codex 3。第 31 轮「请拍板」没有人类收件人

02方向:判据用 5 个提交修完,剩下 75 个提交在造解释器

provenance.py 的规模随 PR 提交的漂移
纵轴=文件行数 · 横轴=PR 提交序号(共 80)· 悬停看每个提交在做什么
交付点在第 5 个提交(a43d9233):issue 的五条范围全部落地,此后 AC 守卫始终全绿。第 11 轮起曲线抬头,第 27 轮后加速——这一段对应的是「列表状态 / 帧栈 / 词法状态 / 函数体重放」这些 shell 语义机制。虚线为 main 上的基线 876 行。

为什么说「多出来的 5,395 行对产出零影响」

这不是推论,是同一批数据上跑出来的三个点:

版本 A  a43d9233   provenance.py 1,096 行  →  10,733 行回放:变 1 / 错 0
版本 B  a1f3ab56   provenance.py 1,853 行  →  10,733 行回放:变 1 / 错 0
版本 C  d8b8408b   provenance.py 6,491 行  →  10,733 行回放:变 1 / 错 0

三个版本唯一改变的那条,都是 issue 里那一个 job:
  xagent-openclaw-runtime:publish-image
  openclaw/Dockerfile  →  Dockerfile        (matched_by_tag = true, build_count = 2)

也就是说:从第 5 个提交到第 47 轮,35 轮评审、5,395 行代码、约 7,800 行测试,在一个 3,260 条脚本的真实批上,改变了 1 条产出——而那条产出在第 5 个提交就已经改对了。

这不是「历史数据太干净」的偶然

形态普查跨了三周、四个批,分布是稳定的:

形态0814082608290904
heredoc / case / trap / pipefail / eval / coproc0000
函数定义 / ! / 子壳 / sh -c / shopt0000
{ …; } / timeout / env / exec / pushd0000
进程替换 / 算术展开 / --target0000
docker build(脚本数)1222
job 总数3,2433,2413,2393,245

这个仓的 CI 是模板化的:约 3,240 条脚本收敛到 330 余种文本,绝大多数是 helm_package 包装的约定调用。docker build 全批只出现 1–2 次,正是本票要修的那类「一个 job 两次 build」的稀有形态。真实风险不是「脚本会写 case」,而是归属判据挑错 Dockerfile——那一点在第 5 个提交已经修好了。

反方论点,以及为什么它不成立

「今天是 0,明天可能有脚本用 heredoc——解释器是保险。」

保险的方向错了。面对一个真实出现率 0、而误判代价是「归属错一个 Dockerfile」的输入,正确的工程响应是「响亮降级」而不是「完整模拟」:登记到的 build 解析不出来,就留一条显式降级记录(PR 自己已经有这个机制:_log.warning、health 诊断、capability 的 reason),让下一批的校验能看见并归因。一个 5,000 行的 shell 前端不是保险——它把「我看不懂这段脚本」这个可观测的降级,换成了「我的解释器可能答错」这个不可观测的风险。第 41 轮那条组合爆炸正是这份风险的实例。

03架构:接口是对的,接缝后面不是

对的部分

runner.pyprovenance 只导入 9 个名字,没有反向依赖,也没有把内部状态漏出去:

from ontoos_extract.code_extract.provenance import (
    AmbiguousShaError, GitlabClient, Job, Pipeline, ParsedJob,
    find_pipeline_for_commit, list_jobs, parse_job_script,
    pick_dockerfile_for_image, pick_job_for_image, resolve_full_sha,
)

模块划分也基本正确:Dockerfile 的解析在 dockerfile_parser.py(+714 行),runner 的接线在 runner.py(+387 行),审计跟随者契约在 archive_health.py/status_audit.py。范围 2/3/4/5 落在该落的地方。另外 PR 有两条真的工程优点:守卫 + 变异两步验(450 条变异逐条转红)、以及与真 bash 逐条对照(PATH 里放假 docker 打印 -f 值)——后者不依赖作者自己对 bash 的判断,是本 PR 最强的收口手法。

不对的部分

维度观察
模块边界6,491 行里 约 5,070 行(78%)是同一个没有子边界的段落——段注释 # job script parsing 从 L606 一直管到 L5675。它同时装下了「GitLab API 客户端」「job↔镜像匹配」和一个完整的 shell 前端。
单元规模_parse_docker_builds 661 行、_BlockState 612 行、_ListState 318 行、_scan_segment 190 行。_BlockState 一个类就有 612 行。
引入的状态面静态集合 40+ 张(_SHELL_CONTROL_WORDS_COMMAND_WRAPPER_CHDIR_FLAGS_BUILD_SUBCOMMANDS_BY_TOOL…),状态类 5 个(_ListState/_BlockState/_Nesting/_HeldBodies/_LexState),外加 8 条常设差分自查脚本。
新故障模式解释器不是「读一段文本」,是「执行一段程序」——于是引入了执行语义才有的故障:组合爆炸、挂起、终止符分层、fork 边界。这些风险在本票之前不存在,是这次改动自己带进来的。
验证面全部验证都是自证:CI 从未运行(组织计费拦截),全量 pytest 在本机也不可得(229 failed / 338 errors,PR 自述已逐文件分类为环境性)。没有任何第三方门禁在这个 PR 上留下过一个绿/红结论。

作者自己在第 31 轮把这件事说得很准,措辞比本报告更直接:

方向已经偏移。 issue #439 的五条范围在第 ~10 轮就交付完毕,AC 守卫至今全绿;第 22 轮之后每一轮都在把 provenance.py 变成一个更完整的 bash 解释器。本轮 codex 的 13 条无一例外是更深的 shell 语义……新机制自己长出下一轮。PR #443 第 31 轮评论,2026-09-09 18:21

值得注意:这次自我诊断没有到达任何人类

第 31 轮明确写了「请拍板」,并给了三条出路(收口 / 划边界 / 拆分)。这条评论是本 PR 上唯一一次要求人类裁决的动作,而 PR 上的人类评论总数是 0。约 8 小时后(第 32 轮),作者以「重看之后那 13 条分成三个机制而不是十三种形态」为由自行撤回了这个判断,又继续了 15 轮。

撤回的技术理由可能成立,但流程上不该由同一方完成:一旦「要不要继续这条轴」被交出去,撤回必须由交出去的那一方或更上游的人来做,否则这个闸在任何一次「我重看了一下」面前都是零成本可撤销的。今天 14:25 转 draft 是正确的一步——但它比该发生的时间晚了 15 轮。

04评审意见:多数为真,但多数不是本票的

先说结论:这些意见在技术层面大多是站得住的 bash 语义。我抽查了最近 18 条线程,每一条都能在 bash 里复现问题场景(shopt -q 只查不改、子壳里的 set -o pipefail) 处还原、exec 之后父 shell 不再继续扫、管道末段是函数调用时 pipefail 的状态归属……)。codex 与 ysatnaf 都没有在编造问题。

问题不在真假,在相关性和代价。按主题分类这 369 条线程:

主题线程数真实数据命中
shell 语义 / 解释器(case、heredoc、trap、!、pipefail、引号、词法、帧栈…)124(33.6%)全 0
归属 / tag 匹配105(28.5%)真实命中
Dockerfile 解析(COPY / stage / source-dir)22(6.0%)真实命中
同根去重 / FE 派发8(2.2%)真实命中
runner 接线 / 审计 / health4(1.1%)真实命中
其他(含跨主题)106(28.6%)

也就是说:约三分之一的评审工作量,花在一个真实批里出现 0 次的输入面。这不是评审者的错——评审者只能按代码里写了什么来审;是代码先长出了这个面,评审随后被迫在里面自转。用 PR 正文自己的数字:45 轮里约 101 条意见是上一轮修复引入的回归,即约 28% 的评审产出是在给上一轮的修复擦屁股。这是「评审判据没有代价轴」的典型信号:如果每条意见都只问「这样会不会产出归属错」,反例是无穷的。

唯一一条性质不同的意见

第 41 轮 ysatnaf 报的那条值得单独拎出来:「解不开的派发释放全部寄存体」是阶乘级的,一条 job script 就能把整批抽取挂死。这一条与前 123 条有本质区别——

作者在第 41 轮加了界(fan_out_seen 去重 + fan_out_depth 作用域),第 42 轮又按「上限按名字不按深度」收了一版。这条修复应当保留——但它的存在本身就是对第 2 节结论的最好佐证:一个手写执行器需要的不是更多语义,而是它自己的复杂度预算。

05下一步建议

A 片:归属判据(≤400 行)——唯一必须交付的部分

只做 issue 的范围 1,即让 docker build 的目标镜像能挑对 Dockerfile:

判据已在 a43d9233 里存在且经过回放验证(1 变 / 0 错),可以直接摘取。

B 片:Dockerfile 侧与 runner 侧(约 1,300 行)——直接修复现场损坏

范围修什么湖仓可观测的验收
2 同 dst 去重同 dst 的 COPY 合并为一个组件,记 merged_copies10 个幽灵组件 → 收敛
3 同根去重FE 派发按解析后源码根去重;跟随组件记 shared-source-rootFE extractor cmd 同根 1 条(现在 13 条)
4 bare COPY 源目录组件真 Dockerfile 的 bare COPY → kind=source-dir目标 Dockerfile 的 source_root_path 不再恒 NULL
5 backend-node 值域无前端框架信号的 package.json 不再升级 frontendarchive_kind: frontend → backend-node

这四条才有可被下批验证的产出面——它们直接对应上面复核到的 10 条 NULL 根组件与一个错误的 archive_kind

丢弃:解释器(约 5,000 行 + 约 7,800 行测试)

不留分支、不做兼容、不逐条评估。它是为一个出现率 0 的输入面写的执行语义层,替换路径是降级留痕:解不出的形态在 health 诊断与 capability 的 reason 里响亮失败,让下一批的验收看得见。若将来真实批里真的出现 case 或 heredoc,那时手里会有真实的失败样本——按样本修,比按想象修便宜两个数量级。

三条闸:不立起来,下一个 PR 会重演

  1. 真实命中闸。任何新增判据,动代码之前先用一条 SQL 量它在真实批里的出现率。命中 0 的意见记入遗留清单,不修——这条比继续修任何一条都便宜。
  2. 轮次闸。评审超过 10 轮,或未解决数连续 3 轮不降,即停并 @ 人。一旦「要不要继续这条轴」被交给人类,作者不得自行撤回;撤回需要交出去的那一方或更上游的人点头。
  3. 代价闸。每轮先算「上一轮引入的回归 / 本轮新增意见」。超过 30% 则下一轮只允许删,不允许加。
另有一条与本票无关、但会持续污染所有判断的环境问题:组织侧的 GitHub Actions 计费失败,导致本 PR 的 CI 从未真正启动过(每次 2–5 秒、steps == 0)。在这个状态下,任何 PR 的合并都是无门禁合入——所有校验都只能由提交方自证。这需要在组织层面解决,而不是在单个 PR 里绕过。

顺带值得记的两条

06复现方式

# 1) 形态普查(只读湖仓,经 121 跳板)
ssh -L 15432:<PG_HOST>:5432 -J root@121.41.239.88 xf@172.25.17.43 -N
#    然后对 dev_ods.code_pipeline_jobs 按 batch_id LIKE '20260904%' 取 script 做正则统计

# 2) 三版本回放对比
git worktree add --detach /tmp/ir439-a43d9233 a43d9233
git worktree add --detach /tmp/ir439-a1f3ab56 a1f3ab56
git worktree add --detach /tmp/ir439-pr       origin/worktree-ir439
PYTHONPATH=<版本目录> python <回放脚本>   # 对 code_archives ⋈ code_pipeline_jobs 跑新旧归属

# 3) 现场损坏核对
SELECT component_path, framework_kind, source_root_path, detection_method
  FROM dev_ods.code_archive_components
 WHERE archive_id = 'e789b0c0da15ee534a18cb28';   -- 10 行,source_root_path 全 NULL

注:批 id 按源分片(0904 批族共 1,559 片),按前缀 LIKE 聚合才是完整的 3,260 条;用 ods_current 查历史批次会得到 0 行而不是报错。