Skip to content

完善 Python 侧可视化公共接口与交互式浏览器对比视图 - #389

Merged
HansBug merged 120 commits into
dev/python-diagram-umbrellafrom
dev/python-diagram-api-browser
Jul 30, 2026
Merged

完善 Python 侧可视化公共接口与交互式浏览器对比视图#389
HansBug merged 120 commits into
dev/python-diagram-umbrellafrom
dev/python-diagram-api-browser

Conversation

@HansBug

@HansBug HansBug commented Jul 20, 2026

Copy link
Copy Markdown
Owner

当前状态

本 PR 面向伞 PR #383 的 Python 侧浏览器可视化阶段(PR-C),目标分支为 dev/python-diagram-umbrella,不直接合入 main

当前实现提交为 bb3b64e9。本 PR 已按伞 PR 的 PR-C ownership 完成共享导出归位、合同门禁补齐、公共接口收敛和双语用户文档,并已处置多轮独立审查结论与视觉/实机验收;合入动作由伞分支统一执行。

整体目标

在 Python 侧提供公共 Diagram 接口和完全离线的独立浏览器查看器,复用 jsfcstm 的几何布局与 SVG 渲染器,以及 PR-B 已交付的官方 resvg 2.6.2 资源。浏览器界面复用 VSCode 预览组件的图形交互,并以独立宿主适配器替换 VSCode 扩展消息桥接。

用户可以通过以下 Python 接口生成数据、JSON 和独立 HTML:

diagram = model.diagram()
diagram.to_dict()
diagram.to_json()
diagram.to_html()
diagram.show()
model.show(options={"mode": "dark"}, view_state={"mode": "compare"})

也可以通过以下命令使用:

pyfcstm diagram -i machine.fcstm -o machine.json
pyfcstm diagram -i machine.fcstm -o machine.html
pyfcstm diagram -i machine.fcstm --open

已交付内容

  • DiagramDiagramDataDiagramOptionsDiagramViewState 公共接口;
  • StateMachine.diagram()StateMachine.show(),并支持直接传递选项、查看状态和源码覆盖;
  • 确定性的可移植 JSON 数据,移除本地绝对路径、源码范围和编辑器选择状态;
  • 稳定的状态 ID 和转换 ID 合同;状态 ID 与共享渲染器的 qualified path 完全一致,转换 ID 由所属状态路径和最终零基序号组成;
  • 仅 FCSTM、仅图形、FCSTM 对比图形三种查看模式;
  • FCSTM 语法高亮,以及源码与图形之间的选择、悬停和滚动联动;
  • 图形适配、缩放、平移、折叠和选择;
  • 浏览器 SVG、PNG 和真正的矢量 PDF 下载;PDF 通过 svg2pdf.js 2.7.0 路径生成,不包含栅格图像;
  • 离线内嵌的渲染器、查看器、resvg WASM、中文字体和许可证文件;
  • 资源构建、资产清单、资源检查器、归档检查器以及 wheel/sdist 打包闭环;
  • 严格拒绝未知选项字段和重复的蛇形/驼峰别名;源码旁车构建不会修改模型的导入文档字典;
  • DiagramErrorDiagramUnavailableErrorDiagramEngineLoadError 等错误分类及资源恢复指引;
  • 双语教程、任务指南、选项参考和命令行参考。

实施改动(6f00b51d75d67c6c

本轮针对 PR-C 架构复核提出的 C1/C2/I1/I2 与 WATCH 项收敛(该复核评论已不在本 PR 的评论列表中,三个 API 端点均返回 404,故此处不再给出链接)。

共享导出归位(对应 C1)

  • 新增 editors/jsfcstm/src/diagram/export/,作为 framework-neutral 的 SVG 规范化、resvg 展开与矢量 PDF 唯一归属;
  • editors/vscode/src/preview-webview/components/Stage.vue 不再自带导出实现,改为调用共享核心;Python 独立查看器通过同一 standalone.ts 入口共享该核心;
  • 浏览器 SVG 和 PNG 下载改为使用展开后的自包含 SVG,canonical SVG 只保留为查看器内部显示形态。

依赖边界(对应 C1 的发布约束)

  • jspdf@4.2.1svg2pdf.js@2.7.0 从运行时依赖移动到 editors/jsfcstmdevDependencies,并从 editors/vscodedependencies 移除;
  • 资源来源校验改为读取 editors/jsfcstm/package-lock.jsondevDependencies
  • 资产构建新增一条断言:若这两个包泄漏进 @pyfcstm/jsfcstm/diagram 公共入口则直接失败。

合同门禁补齐(对应 C2)

新增五个伞 PR 指定的检查器,并接入 Makefile 与工作流:

检查器 make 目标 覆盖内容
tools/check_diagram_contract.py diagram_contract_check 快照结构与可移植数据合同
tools/check_diagram_data_parity.py diagram_data_check 等价模型的数据一致性
tools/check_diagram_options_parity.py diagram_options_check 选项映射与校验一致性
tools/check_diagram_csp.py diagram_csp_check CSP 指令、内嵌脚本/样式哈希、内嵌字体、零网络
tools/check_diagram_browser_exports.py diagram_browser_check 5 个地区 × 4 个视口 × SVG/PNG/PDF 真实浏览器导出
  • 合同、数据、选项与 CSP 四个目标挂入 diagram_assets_verify;浏览器导出门禁作为独立作业步骤运行,不进入该聚合目标;
  • .github/workflows/test.yml 在 Python 3.11 上执行独立浏览器导出门禁;
  • diagram_browser_check 在不传 DIAGRAM_HTML 时默认执行全用例模式,仍保留单文件调试入口。

公共接口与数据合同(对应 I1、I2 与 WATCH)

  • DiagramData 在构造时即存储可移植数据,to_dict() 成为纯粹的快照输出;
  • DiagramData.__hash__ 改用 SHA-256,摘要在不同 PYTHONHASHSEED 进程之间稳定;
  • with_options()with_view_state() 复用同一模型快照,不再重复读取可变模型;
  • 映射形式的查看状态与直接构造保持一致,布尔值不再被静默转成数字;
  • source_text 若与建模所用源文本不一致则直接拒绝(仅行尾差异视为一致),避免源码联动定位到错误行;
  • HTML 文档语言固定为 en(界面语言),不随 cjk_locale 变化——cjk_locale 只决定内嵌哪一套 CJK 字体;
  • 独立查看器在挂载前等待内嵌字体就绪;字体缺失时照常挂载并给出非致命提示,不留白屏。

资源边界

  • 浏览器资源统一经过引擎的资源边界加载,开发 checkout 提示 make build_assets,安装包报告 issue 链接;
  • resvg WASM 增加按二进制格式规定顺序的段封装校验,截断、乱序、重复或缺少关键段的资源在生成阶段即失败,不再拖到浏览器编译时才报错。DataCount 段(id 12)按规范位于 Element 与 Code 之间,因此校验按规范顺序而不是按段号大小判断。

模型字段

  • 生成的组合中继伪状态标记从动态私有属性提升为声明字段 State.is_combo_relay,由图数据与 BMC 域共同读取。

审查处置改动(75d67c6cb880d32d

针对新 head 的代码审查、范围/合同审查、验证审查结论分别为 C=0 I=3 M=7C=1 I=3 M=5C=0 I=4 M=5,去重后 1 C / 10 I / 17 M。逐项处置见 审查处置评论,要点:

  • CI 浏览器门禁此前在 runner 上从未跑通:工作流补浏览器供给并导出 CHROME_BIN;检查器改用仓库统一的解析顺序,并把 Chrome 的 stderr、退出码与 spawn 错误写进失败信息;
  • source_text 的两处比较口径不一致:仅行尾不同的副本能通过校验却随后清空全部 import 源文档,已统一谓词并补回归测试;
  • 剪贴板 SVG 与导出 SVG 统一为展开产物;
  • 依赖恢复按目录分别执行,既有 checkout 不再在 make build_assets 上硬失败;
  • 发布入口泄漏检查改为沿本地 require 图追踪,覆盖 re-export 形态;
  • 数据一致性扩展为 parsed / imported / programmatic 三方;
  • 共享门禁语料换成嵌套复合态 + 守卫/effect + 自环 + 生命周期与切面动作 + 同行多对象;
  • 浏览器门禁改为轮询新导出负载(消除固定延时导致的 flaky 与 rerender 假通过),transition 断言不可跳过,零网络改为无条件不变量;
  • 补齐跨进程哈希、派生快照渲染、source_text 接受路径的测试,并把文档语言的测试改成更强的不变量:任何 cjk_locale 都不得改变文档语言。

附带修复的仓库级阻断

ruff 未固定版本,ruff 0.16.0 扩张默认规则集后打挂全平台 Code test,失败点在生成产物 lint,与本 PR 改动无关。其建议修法需要 Python 3.9+/3.10+ 语法,与本仓库 3.7 下界冲突。本 PR 只做最小解阻:固定 ruff<0.16 并注明原因;完整适配见 issue #398

浏览器门禁修复(b880d32d23229133

b880d32d 的 CI 中 Diagram assets (Python 3.11) 失败。c58d2985 把浏览器收窄为官方 Chrome,但同样失败——这证伪了"snap 包装版 chromium"这一根因判断。

真实根因是 DevTools 端点的启动预算过短waitForJson 为 50 次 × 100 ms 合计 5 秒,而 CI 日志显示 Chrome 在轮询放弃前 1.2 秒仍在输出初始化诊断。本机就绪约 200 ms,有 25 倍余量,因此本地绿灯对该门禁不具备判定力。

  • 启动预算提到 30 秒,可用 VIEWER_DEVTOOLS_ATTEMPTS 覆盖;
  • 浏览器选型的收窄予以保留(官方 Chrome 比 snap 包装版可靠),但它不是本次失败的原因;
  • 上一版 body 与 c58d2985 提交信息中的 snap 根因结论已在 复审处置评论 中明确撤回。

启动预算修正(2322913393d139f5

23229133 把 DevTools 启动预算提到 30 秒,但该修复未生效:预算是通过默认参数 waitForJson(url, attempts = DEVTOOLS_STARTUP_ATTEMPTS, describeBrowser) 提供的,而唯一调用点为了传第三个参数已显式写死 50,默认值被静默覆盖。CI 上失败仍发生在步骤开始后 9.5 秒,正是 50 × 100 ms 加启动开销。

修法不是改数字,而是把该参数从函数签名中移除,使调用点没有覆盖它的机会,预算只从 VIEWER_DEVTOOLS_ATTEMPTS 单点取值。

量化验证(桩浏览器永不开放端点):VIEWER_DEVTOOLS_ATTEMPTS=3 于 360 ms 失败,=30 于 3077 ms 失败,与配置线性对应,证明预算确实生效,而非"改了即认为已修"。

清理竞态修正(93d139f57c553270

启动预算真正生效后,门禁在 CI 上跑完三个用例才失败,失败点转移到清理阶段:

ENOTEMPTY: directory not empty, rmdir '/tmp/pyfcstm-viewer-.../Default'

stopChrome 只发送信号而不等待进程退出,rmSync 已有的 maxRetries 无法赢得这场竞态——浏览器在整个重试窗口内仍在刷写 profile。

  • 删除 profile 目录前,有界地等待子进程 exit 事件;
  • 清理失败降级为 stderr 警告而非判负:门禁的判定对象是查看器行为,不是脚本自身的临时目录管理。仅 ENOTEMPTY/EBUSY/EPERM 三个竞态错误码走警告路径,其余一律重新抛出。

本地复跑 make diagram_browser_check 通过 5 个地区 × 4 个视口,且无 pyfcstm-viewer-* 残留。

界面与实机验收(7c553270bb3b64e9

此前的验收只依据门禁的 pass/fail,没有人真正看过界面、也没有人按真人方式跑过命令。补做视觉与实机验收后,找出并修复了八个只有"实际运行"才会暴露的缺陷——每一个都是在全部门禁绿灯的前提下被发现的。

用户实际会撞到的缺陷

现象 根因 提交
--open 打开的窗口报"文件不存在" 浏览器以 detached Popen 启动,命令先退出、atexit 先删文件 c8f68bca faa317ec dd21dd98
DSL 打错一个字吐出裸 Python 栈 解析器已给出行列号的消息被埋在栈底 4defa432
DiagramAssetEngine 方法示例照抄即抛异常 三个方法示例传的 request 形状与类级示例矛盾 e1211f02
大图 PDF 静默丢掉底部 39% jsPDF 把页面钳到 14400 只报一行 console 警告 159c7177
超过约 280 状态时三种格式全部导不出 栅格无上限触发 toBlob 返回 null,Promise.all 让它拖走 SVG 与 PDF 2652dfff
detail_level 三档渲染完全一致 Python 侧把预设管辖的八个键显式写成了 normal 的值 2652dfff
import 的模型 Ctrl+click 选中另一文档的另一元素 跳转按行号反推元素,丢掉了文档归属 03693f1c
缺可选依赖时的提示无法执行 只说"the optional diagram runtime",不给可安装的名字 bb3b64e9

界面本身

  • 图形被面板下边界裁切、页面同时存在约 130 px 死空白、底部卡片只占半宽:视图状态为中性时改为适配取景,抽屉在用户拖动前按内容定高,底部网格改用 auto-fit。实测自适应从 47% 提升到 79%,层级完整可见。
  • 组件库以 CSS-in-JS 在挂载时注入样式,被 hash-only 的 style-src 拒绝,下拉呈透明无阴影且宽度失控(1385 px)。to_html() 现在同时发出内容派生的 style nonce,document.styleSheets 从 1 恢复到 16、弹层宽度 162 px 对 143 px 触发器。这是一个取舍:nonce 让 style-src 不再结构性禁止外部样式表加载,详见 issue #405 的持久修法;当前无可利用路径,CSP 门禁已把 style-src 钉死为"内嵌样式哈希 + 恰好一个与 bootstrap 一致的 nonce"。
  • 界面文案统一为英文(此前中英混杂),仅保留两处 CJK 字形探针——它们是喂给 document.fonts.check() 的,换成拉丁字符会在只有 Latin 子集时假通过。文档语言不再随 cjk_locale 变化:英文界面被标成 zh-CN 会让屏幕阅读器用普通话念英文控件。

公共接口

with_options / with_view_state 的关键字形式改为部分更新(此前会静默重置所有未重复的字段);collapsed_state_ids 拒绝裸字符串(此前 "Root.Run" 被拆成八个单字符 ID);显式 zoom=1.0 重新可表达(此前与"未指定"共用魔法值而无法区分);Diagram 快照真正不可变(__setattr____delattr__ 都设防);sourceAvailable 要求文本范围(此前程序化模型加 source_text 会显示一个点了没反应的源码面板);写出的文件保留已有权限而非一律降为 0600;PYFCSTM_BROWSER 拼错时报错而非静默启动别的浏览器。

门禁自身

多轮评审反复指出同一类问题:断言存在但无法失败。已逐项修复并对每一项做了红-绿对照——

  • CSP 违规此前只统计 Security domain,而 inline-style 违规只经 Log.entryAdded 上报,17 条被记为 0;
  • 下拉透明度断言因组件库不响应合成 MouseEvent 而从未执行;
  • 断言被前一步的残留状态提前满足,其中"源码→图形"悬停联动实为零覆盖
  • ELK 首个转换恰好无标签,标签光晕与注释边框子句一直在空集上求值;
  • 门禁按可见文案点按钮,改名即静默失效(现改用 data-fcstm-* 稳定钩子);
  • 浏览器中途死掉时 exit 0 且零输出——CI 里最可能的失败模式;
  • style-src-elem(CSP3 里覆盖 style-src)、-attr 兄弟指令、协议相对 URL、大写标签、制表符分隔共九种绕过;
  • 多文档源码映射此前在浏览器侧零覆盖,现已作为第六个语料用例。

临时查看器的生命周期改过三轮,每轮都换个形态复发;根本矛盾(开窗口要求活得比进程久、不开窗口要求退出即回收,而内容寻址让两个进程落在同一路径)最终以两种情况用不同名字解决:窗口用文档摘要、可回收的名字带 pid。

验证

46868 passed / 0 failedSKIP_SLOW_TESTS=1)、jsfcstm 705 passing、六个图形合同门禁、浏览器门禁 6 用例 × 4 视口、make diagram_docstring_check 56 个示例全部可运行、双语文档渲染零 problematic、打包 wheel 在仓库之外的干净虚拟环境中实测可用(零远程引用、CSP 与 5 个内嵌字体完好)。

复审处置(c58d298523229133

三路复审结论为代码 C=0 I=1 M=6、范围 C=1 I=1 M=4、验证 C=1 I=2 M=8;上一轮结论均经实测确认真闭环。本轮要点:

  • 字体加载改用 Promise.allSettled 并上报失败原因:原先的 instanceof Error 守卫因 DOMException 等均继承自 Error 而等价于裸捕获,会把真实缺陷伪装成"字体不可用";
  • 浏览器导出辅助函数改为链式,避免同步抛出导致 Promise 永不 settle 并挂死无超时的 CDP 调用;
  • 发布入口泄漏门禁补 --check 自检,并支持点号文件名与越界模块路径;
  • 数据一致性的 programmatic 一路补成与解析模型真正等价,升级为全量可移植数据比较
  • --require-fonts-ready 改为锚定真正等待字体的语句;
  • 移除已成死开关的 --zero-network、修复临时目录泄漏与源码面板字节不一致、补齐 make help

逐条处置见 复审处置评论

缓存合同

本 PR 的缓存合同明确收窄为:同一 Diagram 实例内的内容寻址 memoization + 输出文件同目录原子替换

不实现跨进程持久缓存根目录、owner/symlink/权限检查与清理失败观测。这一收窄遵循"不做过度资源防御"的决定;若后续确有跨进程复用需求,另立阶段实现,不在本 PR 引入半套缓存系统。

数据与浏览器边界

公共 JSON 不包含绝对路径、源码范围、导入文件名或 VSCode 选择状态。源码联动信息只作为 HTML 内嵌附属数据使用;没有源码时,查看器会明确提示联动不可用的原因,不静默错配。

HTML 自包含渲染器、查看器、选定地区字体和 resvg WASM,不访问外部网络。内容安全策略禁止远程脚本、外部字体、unsafe-eval 和外部连接,只保留内嵌脚本哈希与浏览器运行 WASM 所需能力。

展开语义的宿主边界__FCSTM_EXPAND_SVG__ 由 Python 自包含 HTML 注入,因此 Python 独立查看器的 SVG/PNG/PDF 导出都经过 resvg 展开。普通 VSCode webview 不注入该回调,其导出保留 canonical SVG。本 PR 所称"浏览器导出"指 Python 独立查看器;VSCode 宿主的展开能力不在本阶段范围内。

明确不包含

本 PR 不实现 Python 同步 SVG/PNG/PDF 的实际渲染运行时,不实现 Notebook 显示、完整无头命令行格式、PyInstaller 单文件交付或已安装无头运行时矩阵;这里只提供明确的类型化不可用接口(to_svg()to_png()to_pdf() 抛出 DiagramUnavailableError),避免公共接口形状在后续阶段再次变化。

以下内容明确延期到 PR-D,不在本 PR 实现:

  • 浏览器与无头导出的统一尺寸、像素与输出大小限额;
  • 跨进程持久缓存根目录及其所有权/权限边界;
  • 普通 VSCode webview 的 resvg 展开宿主能力。

本 PR 不替换 pyfcstm visualize,不新增第二套渲染器,不接受任意 SVG,不加入后端选择器,也不恢复已经移除的生产输出大小限制。

文档

双语用户文档直接扩展既有页面,不新建重复页面:

  • docs/source/tutorials/visualization/index.rstindex_zh.rst
  • docs/source/how_to/visualization/index.rstindex_zh.rst
  • docs/source/reference/visualization_options/index.rstindex_zh.rst
  • docs/source/reference/cli/index.rstindex_zh.rst

覆盖 StateMachine.diagram()/show()Diagram 各输出方法、JSON 与自包含 HTML 的实际产物、浏览器三格式下载、无 Chromium 时的行为、类型化不可用接口,以及 pyfcstm diagram 的参数、输出与失败边界。命令行参考已修正为实际存在的短选项 -i/-o

本地验证证据(7c553270

  • SKIP_SLOW_TESTS=1 make unittest:46851 项通过、748 项跳过、150 项取消选择;
  • pytest -q test/diagram test/entry/test_diagram.py:117 项通过;
  • pytest -q test/diagram test/entry/test_diagram.py test/model/test_combo_transition_expansion.py test/bmc/test_domain.py:168 项通过;
  • npm run buildnpx tsc -p ./npm run test:unit(jsfcstm):构建与类型检查通过、699 项通过;
  • npm run compilenpx tsc -p ./npm run test:preview-geometry(VSCode):fixedFailures=0crossingRegressions=0,四种半屏视口截图产出;
  • make diagram_assets_check:资源哈希、大小、语法与忽略规则通过;
  • make diagram_contract_check diagram_data_check diagram_options_check diagram_csp_check:全部通过,其中数据一致性覆盖 parsed / imported / programmatic 三方;
  • make diagram_browser_check:5 个地区 × 4 个视口通过,覆盖三种查看模式、选择/悬停/源码联动、缩放折叠、SVG/PNG/PDF 下载、PDF 单页且图像对象数为 0、PNG 非空且不透明、零外部请求、零 CSP 违规、零控制台错误;
  • make packagepython tools/check_diagram_package.py --dist-dir dist:1 个 wheel、1 个 sdist 通过;
  • python tools/check_cli_reference_docs.py --checkmake docs_terminology_check:通过;
  • make test_boundary_checkmake rst_autogit diff --check:通过;
  • make docs_enmake docs_zh:顺序构建均 build succeeded,新增页面在渲染 HTML 中没有 class="problematic" 标记;
  • ruff checkruff format --check:通过;
  • make docs_pdf:中英文均 PDF validation passed(828 页 / 857 页),构建根隔离检查通过。

审查

两轮共六份独立审查报告,结论全部处置:处置记录见 第一轮第二轮 评论。第二轮复审对第一轮每条结论做了回退对照实验,确认为真闭环。

当前结论

head 7c553270 的远端检查 60 项 success、1 项设计内跳过、0 失败。

视觉验收曾撤回过一次 ready to merge 判断,那四项现已全部解决。 原始记录见
视觉验收评论,当时的四项与现状:

当时的问题 现状
naive-ui 运行时注入样式被 style-src 单哈希策略拦截,下拉透明、宽度失控 策略现为 sha256-… nonce-…(不再是单哈希),style-src-attr 'none' 仍禁内联属性。用真实 CDP 输入打开下拉并截图人工核对:弹层不透明(后方源码被正常遮住)、宽度与 select 一致、边界清晰、选项全英文且当前项带勾
浏览器门禁两处假绿:CSP 统计用了不上报 inline-style 的 Security domain;下拉断言用合成 MouseEvent 实际从未执行 两处均已修:cspViolations 现同时从 Security.securityPolicyViolationReportedLog.entryAdded 收集(源码注释指名了该失效模式);下拉改用 Input.dispatchMouseEvent 真实输入
图形面板底部截断、页面大片死空白 门禁失败条件已含 verticalOverflow / comparisonTooShort / minimumPanelHeight / oversizedUiIcons;1440×900 与 1920×1080 截图人工核对:两栏与图形均未截断,图形提示条完整可见
界面中英文混杂 生成文档界面文案全英文,未出现中文字符

当前 head 实测:make diagram_browser_check 6 案例 × 4 视口通过cspViolations: []externalRequests: []
consoleErrors: 0oversizedUiIcons: false

此前"已确认修复的项"(源码行号右对齐、行尾空档、导出菜单不透明、转换悬停高亮跟随线条几何、PDF 转换标签配色)保持有效。


文档深度评审(docs/documentation_authoring.md 合并阻塞项)

本 PR 为 diagram 能力族在双语 tutorial / how-to / reference 上新增内容,按该指南第 84 行属于
「substantial documentation PR」,因此深度门禁适用。四个角色的归属如下,没有被排除在范围外的角色

角色 拥有页面
Tutorial tutorials/visualization/index.rst / index_zh.rst
How-to how_to/visualization/index.rst / index_zh.rst
Reference reference/cli/index.rst / index_zh.rstreference/visualization_options/index.rst / index_zh.rst
Explanation explanations/visualization/index.rst / index_zh.rst(本 PR 新增)

Explanation 一角最初不在范围内,评审指出按第 215 行「每个跨页拆分都必须指明各角色由哪一页拥有」
需要一个页面而不是一个 follow-up issue,因此本 PR 补齐了该页,而不是在措辞上迁就规则。它解释
快照为何与模型脱钩、文档为何自包含约 29 MB、查看器为何拒绝网络、--open 为何阻塞、隐私边界为何
是目录而不是文件、共享一个名字的代价、以及临时目录在 atexitmultiprocessing 退出三阶段之间
何时被回收(含一幅承载该顺序主张的时序图)。issue #414
由本 PR 交付。

第 189–195 行要求的人工评审证据分两部分。tutorial / how-to / reference 三族的记录在
深度评审评论,其中包含七条文档
承诺命令的逐条实测、双语九条关键事实的整文件比对、以及一处既有生成资源缺口的归属判断。那条
评论写的"Explanation 不在范围内"已被本 PR 取代
——该角色现已交付,其人工检查记录如下:

  • 为厚度读过的页面explanations/visualization/index.rstindex_zh.rst 双语各通读一遍,逐节
    核对机制追踪、顺序推理、设计动机、边界与反例是否都在。
  • 事实主张逐条实测stat 对 mode 000 的文件仍返回大小而读取被拒;四个源文本等长的模型产出
    字节数相同、digest 不同(因此"大小是粗侧信道、名字才是强指纹");程序化构造的模型
    sourceAvailable=False 并给出原因;make diagram_csp_check 十六项;同一脚本三次留一份 29 MB;
    退出三阶段顺序在 CPython 3.7–3.14 × 三种启动方式上 24/24;Windows 的 0o700 ACL 版本边界以
    CPython 各维护分支的 versionchanged 为准(3.8.20 / 3.9.20 / 3.10.15 / 3.11.10 / 3.12.4 / 3.13)。
  • 图示与视觉检查exit_phases.puml(英文)与 exit_phases_zh.puml(中文)两套源码与两套渲染产物,
    由仓库自身 diagrams.mk 生成;两份 PNG 均逐图目视检查,中文图汉字正常显示(非方块)。
    两份 SVG 各 35 个文本节点;按 <text> 节点内全部汉字计,英文侧 0 个、中文侧 214 个(唯一汉字 102 个)。图承载的解释主张是"优先级不是细节"。
  • 双语对等:两页节数与表格逐节对应;全树 class="problematic" 为 0;
    make docs_terminology_check 通过;worker 已按术语纪律首次交接为"工作进程(worker)"。
  • 入站归属映射:tutorial / how-to / reference 三族双语各一处指向新页,满足
    issue #414 的该条验收标准。

验证:双语 Sphinx HTML 构建 exit 0,全树 class="problematic" 为 0;make docs_terminology_check
通过;新增 .puml 经仓库自身的 diagrams.mk 管线生成。两个根 toctree 各加一行,其 PDF 影响由
Docs Check 工作流的 Docs PDF 作业覆盖——本机无 LaTeX 工具链,我不声称在本地验过 PDF。

@HansBug

HansBug commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

DeepSeek 审查员 — PR #389 对抗性计划审查

审查基线dev/python-diagram-api-browser 空提交 90f84029 vs umbrella merge aa745161
审查范围:PR 正文 × PR #383 × issue #89 × 现有 pyfcstm/diagram/engine.py × tools/diagram_assets/python-renderer-entry.ts × jsfcstm diagram model/render × VSCode preview UI
结论C=4, I=5, M=3 — 必须在实现前解决所有 C 级问题。


C1. Transition ID 与 源码范围 的不可调和矛盾

证据editors/jsfcstm/src/diagram/builder.ts 第 288 行:

id: `${joinPath(transition.parentPath)}::${sourceLabel}->${targetLabel}::${transition.range.start.line}:${transition.range.start.character}`

现有 jsfcstm transition ID 直接嵌入了 ::line:character 源位置。PR 正文明确要求 "portable DiagramData 不得包含 源码范围"。如果你为 portability 剥离 ::l:c 后缀,transition ID 变了,和共享 renderer 的 ELK graph 就映射不上。

PR 正文进一步说 "Transition ID 从 owner state 和最终 transition order 派生"——但用 order 派生 ID 意味着:同一 state 内调整 transition 顺序会改变所有后续 ID;Python 和 JS 处理同一 .fcstm 文件可能产生不同的 transition 顺序(解析器实现差异),从而产生不同的 ID 集合。左右两侧的 selection/hover/scroll 联动完全依赖 ID 匹配。

必须修正:定义与 源码范围 和 order 都无关的确定性 transition ID 规则(例如 hash over {ownerQualifiedName}::{sourceKind}::{sourceLabel}->{targetLabel}::{eventQualifiedName}),同时提供仅用于 editor sidecar 的 source-map-by-ID。如果 jsfcstm 现有 ID 格式不能改动,那 Python DiagramData 就必须多一个 transitionId 层做映射。

复现:取任一样例(如 sample/dlc1.fcstm),分别在 jsfcstm 和拟议的 Python DiagramData 中打印同一条 transition 的 ID,验证字节级一致;然后修改 source 中 transition 的顺序或在 guard 前后插入空白行,再次验证 ID 是否稳定。


C2. 状态 ID 的 JSON Pointer 转义与共享渲染器的断裂

PR 正文:"State ID 从 ownership path 派生并执行 JSON Pointer escaping"

但现有 jsfcstm elk-graph.ts 第 371 行使用 state.qualifiedName(即 pathName,如 System.Active)作为 ELK node ID,没有任何 escaping。JSON Pointer escaping 会把 /~1~~0。如果 Python DiagramData 的 state ID 做了 JSON Pointer escaping,而共享 ELK renderer 用的是原始 qualifiedName,那么 hover/selection 的 ID 匹配直接断裂。

必须修正:要么在共享层统一做 escaping(两边都改),要么明确 DiagramData state ID 使用 qualifiedName 原样传输、不做 JSON Pointer escaping,而 JSON Pointer escaping 只用于 to_json() 序列化时嵌套 object key 的 transform(如果确实需要)。PR 正文的 "JSON Pointer escaping" 表述需要精确化边界。

复现:取一个 qualifiedName 包含特殊字符的状态(如果 DSL 允许 /~ 出现在状态名中),验证 Python diagram.to_dict()["states"][0]["id"] 与 jsfcstm elkGraph.children[0].id 字节级一致。


C3. 三种浏览模式需要代码编辑器组件,独立 HTML 无此能力

PR 正文要求 "FCSTM 面板必须支持语法高亮" 和 "状态/转换选择、hover 和滚动联动"。但 独立 HTML(diagram.to_html())不能依赖 VSCode Monaco Editor。Monaco 本身 >5MB,在 零网络 HTML 中内联不现实。

如果用 highlight.js(~50KB)做只读语法高亮,你需要把 源码范围 sidecar 的 {line, character} 映射到 <pre><code> 的行号/列偏移,这需要额外的 DOM 定位逻辑(逐行计算 offset)。更要命的是:PR 正文说 "在存在稳定 源码映射 时,两侧必须支持状态/转换选择、hover 和滚动联动"——只读高亮面板的 selection 语义与编辑器完全不同:Monaco 的 selection 是 Range 对象,而 <pre> 的 selection 是 DOM Selection API,两者不可互换。

必须修正:明确 FCSTM-only 和 FCSTM-versus-diagram 模式的只读面板能力上限:(a) 语法高亮 + (b) 点击行时高亮对应行并通知 diagram 面板,而不是完整的双向光标同步。或者承认 FCSTM 面板在 独立 HTML 中只能是 "语法高亮展示 + 行选择",真正的双向联动仅在 VSCode 扩展中可用。

复现:构造一个 .fcstm 文件,其某一 transition 的 源码范围 跨越 3 行,验证 独立模式 diagram.to_html() 生成的 FCSTM 面板中点击该 transition 对应行时,diagram 面板的高亮行为是否正确。


C4. resvg WASM 在浏览器查看器中的初始化路径未定义

现有 Python engine(pyfcstm/diagram/engine.py 第 892-910 行)使用 WebAssembly.Module 直接初始化 resvg,依赖 MiniRacer 的同步 eval()。但在浏览器 viewer 中,resvg WASM 的初始化路径完全不同:

  • @resvg/resvg-wasm 在浏览器中默认通过 fetch() 加载 WASM → 违反 零网络
  • 内联 base64 WASM + WebAssembly.instantiate → 异步操作 → 与同步 renderSvg() 假设冲突
  • 即使成功内联,initWasm(module) 在浏览器中需要正确的 WebAssembly.Module 构造

PR 正文说 "HTML 嵌入选定 locale 的 fonts、renderer、resvg WASM 和 viewer assets",但从未说明浏览器 viewer 如何在不 fetch、不 eval 的前提下初始化 resvg。这是整个 PNG/PDF 导出路径的基础设施前提。

必须修正:在 PR 正文中增加 browser-side resvg initialization 的技术方案:base64 内联 WASM → Uint8ArrayWebAssembly.ModuleinitWasm(module),并验证该路径在离线 file:// 协议下可用。

复现:构造一个最小 独立 HTML page(无网络),用内联 base64 resvg WASM 调用 renderSvg + resvg.render(),确认在不触发任何 CSP violation 的情况下成功生成 PNG。


I1. 真矢量 PDF 导出与仅使用 resvg 的后端矛盾

PR 正文 scope 明确包含 "浏览器 SVG、PNG 和真矢量 PDF 导出"、"PDF 必须单页、页面尺寸正确且 zero image objects,不接受 栅格 PDF 回退"。

但 issue #89 的原始调研结论(2026-03)明确说:VSCode 现有的"PDF 导出"是 4× 栅格 PNG 用 jsPDF 包了一页、不是矢量 PDF,理由是 "任何 进程内 转换器都没法忠实处理浏览器级别的 SVG"。issue #89 还将真矢量 PDF 标记为 "后续 issue"。

当前 PR-B 只交付了 resvg WASM,resvg 输出 SVG 和 PNG,不输出 PDF。要做 zero-image 矢量 PDF,必须引入 svg2pdf.js + @xmldom/xmldom(issue #89 的建议),这是一个独立工程量。PR 既没有说明 PDF 引擎来源,也没有解释为什么可以推翻 issue #89 的结论。

必须修正:要么从 scope 中移除真矢量 PDF 导出(降级到后续 headless 阶段),要么指定 PDF 生成引擎(svg2pdf.js? resvg 的 PDF 后端? Cairo?)并证明它能在 零网络 CSP 下产生 zero-image PDF。

复现:取任何带有 CJK label 的 FCSTM diagram,期望 diagram.to_html() 的 PDF export 按钮产出一个 qpdf --show-objects 不报告 /Imagepdfinfo 报告正确页面尺寸的单页 PDF。


I2. CSP wasm-unsafe-eval 的跨浏览器兼容性问题

PR 正文要求 "CSP 只允许 embedded viewer 必需的 hashes 和 WebAssembly capability;禁止 unsafe-eval"。

wasm-unsafe-eval 指令(或 script-src wasm-unsafe-eval)仅在 Chrome 中支持。Firefox 和 Safari 目前不支持通过 CSP 细粒度控制 WebAssembly 编译——它们要么要求 unsafe-eval(Firefox),要么完全没有 wasm-specific 指令(Safari 的 WebAssembly.compile 不受 CSP 限制但行为不稳定)。

如果你的 CSP header 禁止 unsafe-eval,在 Firefox 中 WebAssembly.compile() 会抛 EvalError: call to WebAssembly.compile()。这意味着你的 viewer 在 Firefox 中既无法初始化 resvg 也无法运行 ELK layout worker(如果用了 elk-worker)。

必须修正:明确 browser viewer 的 CSP 策略必须按浏览器差异化处理,或者接受 unsafe-eval 作为 Firefox/Safari 的降级路径(并用 hash/nonce 缩小 script-src 的攻击面),或者为 Firefox 用 blob: URL + Worker 绕过 unsafe-eval 限制(但 零网络 下 blob: 创建需要正确的 origin)。

复现:在 Firefox 中打开 diagram.to_html() 生成的文件(file:// 协议),打开 Console,确认无 CSP violation 且 resvg PNG export 正常工作。


I3. ELK 布局在 Python MiniRacer 和浏览器中的双重路径

现有 python-renderer-entry.ts 为 MiniRacer 注入了 fake setTimeout/Promise chain(因为 MiniRacer 无事件循环)。ELK worker 在这个 shim 下工作正常。但 独立 HTML viewer 有原生的 browser event loop。

如果 viewer 复用同一份 bundled renderer(renderer.js),那么 __pyfcstm_embedded_host marker 决定是否注入 fake timer。viewer HTML 中的 renderer bundle 必须注入 fake timer,否则会与浏览器原生 event loop 冲突。

PR 正文 没有说明 viewer renderer bundle 和 Python renderer bundle 是同一份还是两份。如果是同一份,需要确保 marker 在两种环境都能正确分流。

必须修正:明确 viewer renderer 与 Python renderer 的 bundle 关系(一份共享 vs 两份独立),并验证 ELK layout 在两种环境下产出相同的几何结果。

复现:用同一份 DiagramData,分别通过 Python engine.render_svg() 和 browser viewer 的 diagram panel 生成 SVG,对比 SVG children 数量、最外层 <g>transform 值和所有 text 节点的位置。


I4. 缓存键设计和失效策略未定义

PR 正文提到 "cache" 作为 Diagram 的一项能力,但没有定义 cache key 和失效规则。DiagramData 应从 source FCSTM 的 StateMachine 对象派生,但 cache key 是什么?文件路径 + 内容 hash?如果用户通过 Python 代码 programmatic 构造 model 然后调 model.diagram(),没有文件路径,cache key 从哪来?

更微妙的是:不同 pyfcstm 版本可能对同一个 source 产生不同的 DiagramData(例如 bug fix 改了 transition ordering),cache 必须能检测版本变化。

必须修正:定义 cache key = (source_content_hash, pyfcstm_version, diagram_schema_version) 或等价方案,并写入 DiagramData 的 non-portable metadata(仅 cache 使用,不进 public data contract)。

复现:先用旧版 pyfcstm 生成并缓存 diagram.to_html(),升级 pyfcstm 后再次请求同一 diagram,验证旧的 cache 被正确失效并重新生成。


I5. CJK 字体在独立 HTML 中的选择与加载

现有 Python engine 根据 SVG 中的 font-family 属性推断 CJK locale(engine._locale_from_svg 第 980 行),然后注册对应字体。但在 browser viewer 中:

  • 如果字体以 base64 data URI 内联在 <style> 中,HTML 体积将暴涨(NotoSansSC-Regular.otf 约 8MB base64 后 ~10MB)
  • 如果字体以 @font-face { src: url(data:font/otf;base64,...) } 注册,Firefox 在某些情况下可能因为同源策略拒绝 file:// 下的 data URI font
  • PR 正文说 "HTML 嵌入选定 locale 的 fonts"——是嵌入全部 5 个 locale(~40MB+ compressed)还是只嵌入当前 locale?如果只嵌一个,locale 切换需要重新生成 HTML

必须修正:明确 font embedding 策略:单 locale embedding + locale selector 需要重新生成 vs 全部嵌入 vs 按需 lazy(但 零网络 下无法 lazy)。

复现:生成 diagram.to_html(cjk_locale="tc") → 查看 HTML 中 @font-facesrc → 确认 base64 字体为 NotoSansTC-* 而非 NotoSansSC-*


M1. pyfcstm diagram CLI 与未来扩展的命名空间问题

pyfcstm diagram -i ... -o ... 不与现有 pyfcstm visualize 冲突,但如果未来添加 pyfcstm diagram inspectpyfcstm diagram diff 等子命令,Click group 与 flat command 的语义会发生冲突。建议从一开始就用 pyfcstm diagram generate / pyfcstm diagram serve 命名以保留扩展空间。

M2. make build_assets 需增加 viewer assets 构建

当前 tools/build_diagram_assets.py 只产出 renderer.js + resvg.wasm + fonts。PR-C 需要增加 viewer HTML bundle 的构建步骤(可能涉及 esbuild/Vite 打包 viewer TypeScript/Vue → 单文件 HTML),这条构建链在当前 build tool 中完全不存在。

M3. Python 3.7 type hint 兼容性

pyfcstm/diagram/engine.py 已对 Python 3.7 做了显式适配(importlib.metadata backport,第 448 行)。PR-C 新增的 DiagramDiagramDataDiagramOptions 等 dataclass/frozen class 如果使用 from __future__ import annotations 或 PEP 585 泛型(dict[str, Any]),会在 Python 3.7 运行时失败。CI 矩阵中 Python 3.7 只有一个 job,不能覆盖所有新增代码路径。


无 C/I 但仍需关注的 Residual Risk

  1. Hostile label 的 ELK layout 破坏:包含零宽连接符、RTL override(\u202E)、极窄字符(\u200B)的标签可能让 ELK 布局算错 text width(string-width 库对这些字符的宽度处理可能与浏览器实际渲染不一致),导致 diagram 面板的 geometric hover target 偏移。

  2. portable JSON determinism 的 key orderingto_json() 必须产生 deterministic JSON(相同输入 → 相同字节输出)。Python 3.7+ dict 保证 insertion order,但 dataclass asdict() 按 field definition order,需要确保这个 order 是跨版本稳定的。

  3. Source range sidecar 与空提交的关系:当前 PR 分支是空提交(只有一个 chore commit),所有验证只能做计划层面的静态分析。实现细节(特别是 renderer bundle 复用、ID mapping、CSP 配置)可能在施工中暴露更多 C 级问题。


判决:PR #389 的计划在数据边界(C1, C2)、浏览器架构(C3, C4)和 PDF 范围(I1)方面存在 4 个必须解决的 C 级矛盾。所有 C=4 问题必须先解决才能开始写代码。I 级问题也应在施工早期解决,避免后期大规模回退。

— DeepSeek 审查员, 2026-07-20

@HansBug

HansBug commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

Claude 审查员 — PR #389 计划/范围/可执行性/可验收性审查

审查基线:dev/python-diagram-api-browser@90f84029(空 PR,仅一个 chore 提交),base dev/python-diagram-umbrella@aa745161。本审查针对 PR 正文中的实施计划、数据合同、验收合同和合入合同做对抗性分析;已阅读 PR 正文、伞 PR #383、issue #89,并在本地检查了 pyfcstm/model/model.pypyfcstm/diagram/pyfcstm/diagram/assets/editors/jsfcstm/src/diagram/editors/vscode/src/preview-webview/ 的现状。

按 C/I/M 分级;C 与 I 必须在写代码前解决。


C(阻塞,必须先解决)

C1. State.is_combo_relay 作为"combo relay pseudo state 唯一 runtime truth"目前并不存在

PR 正文明写:"State.is_combo_relay 是 combo relay pseudo state 的唯一 runtime truth,renderer 禁止通过名称猜测。" 但仓库现状是:

  • pyfcstm/model/model.py 中只有一个局部变量 is_combo_relay_pseudo(第 2809/2961/3162/3286 行),它是某个方法内的临时计算,不是 State 上的公共属性;
  • grep -n "def is_combo_relay" pyfcstm/model/model.py 无匹配。

State.is_combo_relay 是 portable DiagramData 生成过程要读取的合同。在 PR-C 写第一行 renderer 代码之前,必须先把它固化为 State 的公开只读属性(与 is_leaf_state / is_pseudo 同级),并在 model 层测试里锚定语义,否则 "唯一 truth" 只是文档承诺、renderer 只能重新按名字猜。

建议:先在本 PR(或伞 PR)落一个前置微 commit:把当前局部计算提炼为 State.is_combo_relay 属性 + 单测(覆盖 combo/普通/嵌套 combo/root 等),再开始 Diagram 转换。

C2. Self-contained 零网络 HTML 与 61 MB 资源体量的冲突未被承认

pyfcstm/diagram/assets/ 目前 du -sh = 61 MB(15 个 CJK OTF + JetBrainsMono TTF + renderer.js + resvg.wasm + bridge/manifest)。PR 正文 承诺"HTML 嵌入选定 locale 的 fonts、renderer、resvg WASM 和 viewer assets,必须 self-contained 且 零网络",但没有回答:

  • 单个 HTML 是否只嵌入"选定 locale"字体(例如仅 SC 或仅 latin),从而把体积压到 5–8 MB?还是全部?
  • 若按 locale 裁剪,DSL 里同时含 CJK + latin 时怎么定"selected locale"?运行时探测吗?(会引入解析 DSL 的 pre-step)
  • 若不裁剪,浏览器打开一个 60+ MB 的 base64 HTML(base64 后 ~85 MB),首屏解码时间、内存占用、file:// 打开延迟是否可接受?
  • HTML 存到磁盘作为长期"离线交付物"时,用户是否能忍每张图 80 MB?

这一点直接影响 "PDF 必须单页、zero image objects" 的合理性(vector PDF 反而更小),也影响用户对 pyfcstm diagram -o machine.html 的心智模型。必须在计划里明确 HTML embed 策略(按 locale 挑字体 / 全量嵌 / lazy fetch fallback),并给出典型体积基线(例如 latin-only ~2 MB、single-CJK ~8 MB、all-locale ~80 MB),否则实施阶段会被字节数逼着回炉。

C3. "真矢量 PDF、不接受 栅格 PDF 回退" 是新增合同,且当前 VSCode 实现走的正是 raster 路径

editors/vscode/src/preview-webview/components/Stage.vue 第 414–425 行注释和第 437–443 行代码显示:现有 VSCode 扩展的 PDF 导出就是 4× 栅格 PNG 用 jsPDF 包一页。作者在注释里明确"we intentionally skip the vector-SVG-to-PDF path"。issue #89 也把矢量 PDF 显式排除到"后续 issue"。

PR-C body 却写"PDF 必须单页、页面尺寸正确且 zero image objects,不接受 栅格 PDF 回退。" — 这是一个新增的、比 issue #89 更严的合同。要满足它,浏览器端需要引入:

  • svg2pdf.js(或等价 SVG→PDF vector 生成器);
  • 一个 SVG DOM 解析路径(如果 svg2pdf.js 要在离线 HTML 里跑,@xmldom/xmldom 或原生 DOM 就够);
  • CSS 展开为 presentation attributes、webfont → CFF/Type1 outline 嵌入(否则 PDF 里字要么变 raster、要么丢字形)。

这些是独立工程量,且和现有 VSCode PDF 分支彻底分叉。计划里必须

  1. 明确选用哪个 JS 库、库大小、许可证;
  2. 明确 webfont 在 vector PDF 里怎么落地(JetBrains Mono + Noto CJK 6 家族,是否要 embed subset?subset 又要 fontkit/opentype.js);
  3. 明确 VSCode 现有 PDF 导出要不要同步升级为矢量(否则同一伞 PR 下两套导出行为分叉);
  4. 若发现在 PR-C 时间窗内 hard,允许 body 在合入前降级为"暂不导出 PDF",而不是留一个 raster fallback(因为 body 明写不接受 raster fallback)。

C4. Portable DiagramData 的字段过滤合同没落到 schema 层

editors/jsfcstm/src/diagram/model.ts 现状:

  • FcstmDiagramEvent/Action/Transition/Variable 都带 range: TextRange(第 33/50/76/100 行);
  • FcstmDiagramStateDetail/TransitionDetailsourceRange?: TextRange(第 260/285/325/347 行);
  • 第 111/332 行含 importedFromFile?: string(绝对/相对路径)。

PR 正文说"portable DiagramData 不得包含 absolute path、源码范围、import filename 或 VSCode selection state。jsfcstm 的 editor metadata 使用独立 source-map sidecar" — 但没有回答:

  • Python 侧的 DiagramData另一个 dataclass(Python-native shape,从 pyfcstm.model 直接构造),还是复用 jsfcstm 的 shape 并 strip?两条路对 "唯一 StateMachine-to-DiagramData 转换路径" 的含义完全不同;
  • source-map sidecar 的 schema 在哪里定义?key 用 stable state/transition ID,value 结构({line,col,endLine,endCol} 还是 {offset,length})?
  • HTML 里 embed 了 sidecar,JSON 里没有;用户拿到 JSON 后再喂进 HTML 时,linked navigation 应降级还是报错?

必须在写 renderer 前冻结:Python DiagramData 的 frozen dataclass 定义(含 JSON schema)、sidecar 定义、二者的组合规则。否则实施阶段两个数据形状会漂移。

C5. FCSTM 面板的语法高亮方案没有明确实施路径

PR 正文要求"FCSTM 面板必须支持语法高亮"。仓库现状:

  • Pygments lexer 只在 Python 端(pyfcstm/highlight/pygments_lexer.py),浏览器里跑不了;
  • editors/fcstm.tmLanguage.json 是 TextMate 语法,需要 shiki / monaco / vscode-textmate + oniguruma-wasm 才能在浏览器里解释;
  • jsfcstm 里没有针对 FCSTM 源码的语法高亮器(editors/jsfcstm/src/highlight 命中的都是 LSP DocumentHighlight,是引用高亮不是语法高亮)。

三条候选路径体量差异很大:

  1. 服务器端 Pygments → 静态 HTML:Python 侧用 Pygments 把 FCSTM 源渲成 pre/span 直接注入 HTML。简单,但 FCSTM-versus-diagram 联动(点转换 → 高亮源码)需要在 HTML 里保留 line/col → span 的映射;
  2. shiki + oniguruma-wasm:多加一个 wasm 依赖,进一步撑大 HTML;
  3. 手写一个小 tokenizer(复用 Pygments lexer 的规则重写成 JS):可维护性差,但体积小、零新增 wasm。

必须在实施前选路,并回答:编辑时高亮源码需要 tokenize 一次还是持续(HTML 里 FCSTM 面板是否可编辑?PR 正文 未说)。


I(重要,实施前需回答)

I1. CSP 直接写"禁止 unsafe-eval"与 WASM 加载不兼容,需要更精细的措辞

editors/vscode/src/preview.ts:206 现有 CSP 里明写了 'unsafe-eval'。PR 正文说"禁止 unsafe-eval",同时"WebAssembly capability" — 现代浏览器(Chrome 92+ / Firefox 102+)用 'wasm-unsafe-eval' 精细化开关;Safari 15 之前直到最近才补上。计划应显式写出打算下发的 CSP 字面串default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; script-src 'wasm-unsafe-eval' 'sha256-…' 'sha256-…' 之类),并说明是否放弃老 Safari 兼容。

I2. Transition ID = "owner state + final transition order" 对 DSL 编辑高度敏感

PR 正文明写 "Transition ID 从 owner state 和最终 transition order 派生"。DSL 里换两条 transition 的顺序会把两个 ID 全部平移,从而使外部保存的 selection state、缓存、diff 全部失效。这是设计意图还是实施妥协?如果是意图,应在计划里显式接受"编辑 DSL 会大范围失效 ID";如果是妥协,考虑按 (source, target, trigger, guard-hash) 稳定化。同一决策也影响 HTML sidecar 的 stable ID 语义。

I3. pyfcstm diagram 与既有 pyfcstm visualize 的边界仅一句 "分开"

PR 正文说 "CLI surface 与现有基于 PlantUML 的 pyfcstm visualize 分开"。计划应显式回答:

  • visualize 是否保留?deprecate 时间线?
  • 二者是否共享 --input / --palette / --direction 命名?
  • pyfcstm diagram -i a.fcstm -o a.svg(一个"未来 PR-D"支持的扩展名)在 PR-C 时应报什么错?"格式暂不支持"还是 fall back 到 --open 提示?
  • CLI 帮助文本双语?(本仓库 pyfcstm bmc 是英文帮助)

I4. 验收里 "全部 corpus 的结构结果" + "12 视觉案例 + 5 CJK + 4 viewport" 的 CI 基础设施未提

浏览器视觉测试需要 headless 浏览器(Playwright / Puppeteer / Selenium)+ 像素 diff 库 + 平台稳定 baseline。这三样在当前 CI(.github/workflows/test.yml)里都没有。计划必须明确:

  • 选哪个 headless runner?固定到什么版本?
  • baseline 存哪儿?多平台一致性怎么保证(Linux vs macOS 字形渲染差异会打崩 baseline)?
  • Playwright/Chromium 在 Windows 7 + py3.7 兼容矩阵里怎么办?如果 CI 只跑 Ubuntu,是否明写"视觉测试只在 Ubuntu-latest 上执行"?

I5. "唯一 StateMachine-to-DiagramData 转换路径" 与 jsfcstm 侧 buildFcstmDiagramFromDocument 的关系没交代

jsfcstm 有 buildFcstmDiagramFromDocument(从 AST 构造 diagram)。Python 侧若要 "唯一转换路径",意味着:

  • 要么:Python 从 pyfcstm.model 直接构造 DiagramData(另一份实现),layout 阶段再喂给 MiniRacer 里的 ELK;
  • 要么:Python 把 DSL 交给 MiniRacer 里的 jsfcstm,buildFcstmDiagramFromDocument 出结果再回来。

两条路对测试独立性影响不同:前者 Python 单测可以完全离线,后者 Python 单测必然依赖 jsfcstm bundle。CLAUDE.md 严格要求两侧测试独立。计划应明写选哪条,以及 Python 单测把 jsfcstm bundle 视为"packaged asset"(允许)还是"隔壁测试树"(不允许)。

I6. Combo relay pseudo state 的稳定 ID / JSON Pointer escape 只提了一半

PR 正文说 "State ID 从 ownership path 派生并执行 JSON Pointer escaping"。Combo relay pseudo state 没有用户命名,ownership path 里怎么算?用固定 token(__relay__ / ~relay~)还是继承自源/目标状态?escape 规则的 corner case(~ 本身、/、控制字符、超长 CJK 名)在测试计划里必须显式覆盖。

I7. hover / selection 双向联动的"稳定 源码映射"缺失时行为未定义

PR 正文说 "在存在稳定 源码映射 时" 支持双向选择/hover/滚动联动。但 JSON 输出(不含 sidecar)reload 到 HTML 后就没 sidecar 了;此时 FCSTM-versus-diagram 应该:

  • 隐藏源码面板?
  • 显示源码但禁用联动?
  • 只允许 diagram-only 和 FCSTM-only?

必须在计划里定死降级行为,否则用户在两种 HTML(含 sidecar / 不含 sidecar)之间会遇到不一致的视觉体验。


M(建议)

M1. wheel/sdist 体积

PR-B 已经把 assets 塞进包了,PR-C 不改变这一点。但要在 body / docs 里说明基础 wheel 现在大约多大(当前 ~65 MB?),并提前给 PyPI 上传大小上限(100 MB per file)留 headroom。

M2. model.show() 打开哪个浏览器

webbrowser.open 在服务器/无头/WSL/SSH 环境里行为不一致。可以约定 CLI 打印 URL fallback,并在 docs 显式说 headless 场景请用 -o file.html 后手工打开。

M3. Bilingual docs 落到具体页面清单

CLAUDE.md 已要求双语文档;PR-C body 只写 "双语文档"。建议提前落一个 docs 目录树(Tutorials / How-to / Reference / Explanations 四类各哪些页),避免实施阶段再讨论章节划分。

M4. --open--output 组合的 stdout 契约

pyfcstm diagram -i a.fcstm --open 是否也写文件?如果只是临时 NamedTemporaryFile,在 Windows 上 webbrowser.open 打开临时文件后立即删的时序问题需要处理。

M5. 与 PR-D 的边界(headless CLI formats、Notebook display、PyInstaller、真同步 to_svg/to_png/to_pdf)在 body 里已明确排除,但没提"PR-C 完成度不阻塞 PR-D 启动"这一操作性说明,建议在 body 加一句以便维护者调度。


结论

没有阻塞代码正确性的问题(因为没有代码)。但 有 5 条 C 级计划性阻塞State.is_combo_relay 属性、HTML 体积策略、真矢量 PDF 承诺、portable schema/sidecar 冻结、FCSTM 语法高亮方案),必须在 PR-C 写第一行 renderer 代码之前解决;7 条 I 级问题涉及 CSP 文本、ID 稳定性、CLI 边界、CI 基础设施、转换路径归属、pseudo state ID、降级行为,实施前需在 body 或 tracking issue 里给出决策;5 条 M 级是建议。

剩余风险:

  1. 61 MB 资源包对 self-contained HTML 的现实性冲击是最大的隐性风险;
  2. 真矢量 PDF 承诺相对 issue 嵌入 jsfcstm 可视化 (SVG/PNG):mini-racer + WASM 栅格化 #89 是收紧后的合同,工程量翻倍;
  3. 若 C1 (State.is_combo_relay) 不先落属性,renderer 会被逼回名字猜测。

以上按 CLAUDE.md 的对抗性 planning review 定位交付;PR 正文 已要求 3 份独立 planning review,本文为其中一份。

— Claude reviewer

@HansBug

HansBug commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

DeepSeek 审查员 — PR #389 对抗性计划审查

审查基线: dev/python-diagram-api-browser@90f84029 (空提交) vs dev/python-diagram-umbrella@aa745161
审查对象: PR #389 body 中的计划合同, PR #383 umbrella body, issue #89, 现有 pyfcstm/diagram/engine.py, tools/diagram_assets/python-renderer-entry.ts, jsfcstm diagram model/render, VSCode preview UI
审查深度: 白盒, 基于真实 ID 路径、数据流和交互协议追踪


C 级阻塞项 (C) — 实现必然失败的位置

C1: State ID 格式冲突 — Python ↔ jsfcstm 不可调和

PR #389 规定 State ID "从 ownership path 派生并执行 JSON Pointer escaping"。但 jsfcstm 现有 ID 链是:

State ID 格式 来源
FcstmDiagramState.id state.pathName (如 "System.Active") builder.ts:377
ELK node id state.qualifiedName (= state.id) elk-graph.ts:371,455
SVG data-fcstm-id `meta.qualifiedName
DiagramViewState.collapsedStateIds 直接引用 ELK node id VSCode App.vue

JSON Pointer escaping 会把 ~~0, /~1, 路径分隔符改为 /。例如 System.Active 变成 System/Active。这个 ID 永远不会匹配 jsfcstm 的 System.Active。结果是:

  • collapse/expand 状态丢失 (ID 不匹配)。
  • 左侧 FCSTM 高亮无法定位到右侧 diagram 元素 (选择联动依赖 ID 匹配)。
  • hover 联动破窗。

可执行复现:

# 在实施后构造一个含嵌套状态的 .fcstm,导出 diagram.to_dict()
# 检查返回的 state id 是否等于 jsfcstm builder 输出的 state.pathName
python3 -c "
from pyfcstm.dsl import parse_with_grammar_entry
from pyfcstm.model import build_state_machine
# ... (导入实现后的 DiagramData)
# assert diagram_data['states'][0]['id'] == 'System.Active'  # 会失败
"

修正: 放弃 JSON Pointer escaping。DiagramData state ID 直接使用 jsfcstm 的 pathName (. 分隔)。如果 pathName 中需要 escape 的字符 (. 本身出现在状态名中), 在 Python 侧和 jsfcstm 侧统一使用 \\. 转义, 而非引入 JSON Pointer。

C2: Source range 契约矛盾 — portable DiagramData vs 浏览器联动

PR #389 规定:

portable DiagramData 不得包含 absolute path、源码范围、import filename

但同时浏览器 viewer 必须支持 "左侧 FCSTM 高亮" 和 "左右选择/hover/滚动关联"。这些联动必然需要 源码范围 信息 — 左侧 FCSTM 文本面板要知道每个状态/转换对应的行号才能高亮。

PR 提出的解决方案是 "jsfcstm 的 editor metadata 使用独立 source-map sidecar"。但这个 sidecar 如何到达浏览器 viewer? 选择:

  1. sidecar 内容直接嵌入 HTML (<script> tag 中) — 此时它仍然是 "in DiagramData" 的同位替换, 只是换了个 JSON 字段名, 没有真正分离。
  2. sidecar 作为独立文件 — 但 HTML 必须是 self-contained 零网络, 不能 fetch 外部文件。
  3. sidecar 作为 data: URI — 技术上可行但增加复杂度。

无论哪种方案, 都必须在 HTML 文件中携带 源码范围。而 HTML 导出 (diagram.to_html()) 的实现必须正确将 源码范围 注入到浏览器的 FCSTM 语法高亮器和 diagram SVG 中。

可执行复现:

# 实施后: 打开 diagram.to_html() 生成的 HTML
# 检查左侧 FCSTM 面板单击一个状态名时, 右侧 diagram 是否高亮对应节点
# 检查右侧 diagram 节点 hover 时, 左侧 FCSTM 文本是否滚动到对应行
# 如果这两个功能需要 源码范围 而 源码范围 不在 portable JSON 中,
# 要么功能不工作, 要么 源码范围 实际上在 HTML 中

修正: 明确声明 browser HTML 中可以包含 源码范围 (它是 browser-only artifact, 不违反 portable JSON contract)。在 PR 正文中区分 "portable DiagramData JSON" (不含 源码范围) 和 "browser HTML" (含 embedded 源码范围 sidecar)。

C3: 共享 jsfcstm renderer 的浏览器可用性

PR #389 说 "复用 jsfcstm 共享的几何/SVG renderer"。但 tools/diagram_assets/python-renderer-entry.ts (即 renderer.js 的源码) 是为 MiniRacer 环境编写的:

// python-renderer-entry.ts
if (rendererGlobal.__pyfcstm_embedded_host || !rendererGlobal.setTimeout) {
  // 为 MiniRacer 提供 setTimeout 桩
  rendererGlobal.setTimeout = ...;
}

这个 renderer bundle 在 MiniRacer 中通过 __pyfcstm_render_start/poll/drop 全局函数工作, 使用 Promise-based 异步模式。在真实浏览器中:

  1. 浏览器不需要 setTimeout 桩 (但桩代码无害)。
  2. 关键: resvg-binding.jsinitWasm(WebAssembly.Module) — 这个 API 在现代浏览器中存在, 但 WebAssembly.Module 构造要求完整的 WASM 二进制。嵌入 HTML 后, WASM 需要先 base64 decode 再传给 initWasm
  3. 更要命: renderer.js bundle 包含 ELK worker (elk-worker.min.js)。ELK worker 在浏览器中会尝试创建 Web Worker — 如果 HTML 是 file:// 协议的独立文件, Worker 构造函数会因 CORS 失败。

可执行复现:

# 实施后: 在 file:// 下打开 diagram.to_html() 生成的 HTML, 打开 DevTools Console
# 预期错误: "Failed to construct 'Worker': Script at '...' cannot be accessed from origin 'null'"
# 如果是 file://, ELK 布局会失败, 整个 diagram 不渲染

修正: 浏览器 viewer 的 renderer bundle 必须与 MiniRacer bundle 分离构建。浏览器 bundle 需要:

  • ELK 同步 fallback (或 inline worker blob URL)
  • 浏览器环境的 WASM 加载路径
  • 不依赖 __pyfcstm_embedded_host 标志

PR 正文中 "复用" 的措辞应改为 "共享 jsfcstm 的几何/SVG 渲染逻辑, 但在浏览器中需要独立的适配层"。

C4: State.is_combo_relay 在 jsfcstm 模型中没有对应

PR #389 说 "State.is_combo_relay 是 combo relay pseudo state 的唯一 runtime truth, renderer 禁止通过名称猜测"。

但现有 jsfcstm 模型 (FcstmDiagramState, FcstmElkNodeMeta) 中没有 is_combo_relay 字段。FcstmElkNodeMeta 有的字段是:

kind: "state" | "pseudoInit" | "pseudoExit" | "canvas"
pseudo?: boolean
composite?: boolean
collapsed?: boolean

PR #389 的 Python 代码可以正确标记 is_combo_relay, 但当这个信息通过 DiagramData 传给 jsfcstm renderer 时, 它会被丢弃 (renderer 不认识这个字段)。如果 combo relay 状态需要特殊渲染行为 (不同于普通 pseudo state), renderer 必须修改。

可执行复现:

# 构造一个含 combo relay (chained transition relay) 的 .fcstm
# 在 jsfcstm model.ts 的 FcstmElkNodeMeta 中搜索 "combo" 或 "relay" 关键字
grep -rn 'combo\|relay' editors/jsfcstm/src/diagram/
# 当前结果: 零匹配

修正: 要么:

  • (推荐) Python 侧 DiagramData state 节点中加 combo_relay: bool, 同时 jsfcstm 的 FcstmElkNodeMeta 加对应字段, SVG renderer 接收并渲染。
  • 如果 combo relay 不需要特殊渲染 (只用 pseudo state 的虚线框), 在 PR 正文中明确声明并删除 "禁止通过名称猜测" 的限制。

I 级重要项 (I) — 高概率失败点

I1: resvg browser PNG 与 CSP 的 Wasm 指令

PR #389 规定 "official resvg browser PNG" 和 "CSP/零网络"。CSP 要求禁止 unsafe-eval, 但 WebAssembly 编译需要 wasm-unsafe-eval 指令。这两个是不同的 CSP 指令。当前 PR 正文 只说 "禁止 unsafe-eval", 没有说明 Wasm 指令。

如果 CSP 同时禁止 unsafe-evalwasm-unsafe-eval, resvg 无法初始化, PNG export 失败。如果只禁止 unsafe-eval 但允许 wasm-unsafe-eval, Wasm 可以编译但 new Function() / eval() 被禁止。

可执行复现:

# 实施后: 打开 browser HTML, 在 DevTools Console 检查 CSP 报错
# 预期可能的错误: "Refused to compile WebAssembly module because 'unsafe-eval' is not an allowed source..."

修正: CSP header 必须明确包含 script-src ... 'wasm-unsafe-eval' 并仍然禁止 'unsafe-eval'。在 PR 正文中列出最终 CSP header。

I2: 三种浏览模式的状态保留

PR #389 要求 "FCSTM-only, diagram-only 和 FCSTM-versus-diagram 三种模式"。但 PR 正文 没有说明模式切换时:

  1. 当前选择 (selected state/transition) 是否保留?
  2. collapse 状态是否保留?
  3. zoom/pan 状态是否保留?
  4. scroll position 是否保留?

现有 VSCode App.vue 只有一个 diagram 面板 + 底部 DetailsPanel, 没有 "FCSTM-only" 模式。左侧 FCSTM 文本面板需要全新的 Monaco/CodeMirror 集成, 这会引入:

  • 额外的 JS bundle (Monaco ~5MB 或 CodeMirror ~500KB)
  • 额外的 CSP hash
  • 文本编辑器生命周期管理 (dispose, resize, theme sync)

可执行复现:

# 实施后: 打开三种模式的 HTML, 在 diagram-only 模式下选择一个状态
# 切换到 FCSTM-versus-diagram 模式, 检查选择是否保留
# 切换到 FCSTM-only 模式, 再切回 diagram-only, 检查之前的 zoom/pan/collapse 状态

修正: 明确 mode switch 的状态保留语义。推荐: 所有模式共享一个内部 DiagramViewState, mode 只改变 UI 面板的可见性, 不销毁状态。

I3: 矢量 PDF 零 image object 的验证

PR #389 规定 "vector PDF zero-image"。issue #89 详细说明了浏览器端矢量 PDF 的难度:

VSCode 扩展现有的"PDF 导出"实际上是 4× 栅格 PNG 用 jsPDF 包了一页 — 作者主动放弃了 SVG → 矢量 PDF 路径

在 resvg WASM 已嵌入的前提下, 可以用 resvg 的 renderToPDF() API 生成矢量 PDF。但需要客观验证 PDF 中确实没有 image objects (不能用肉眼确认)。

可执行复现:

# 实施后: 导出 PDF, 用 Poppler 检查:
python3 -c "
import subprocess
result = subprocess.run(['pdfinfo', 'output.pdf'], capture_output=True, text=True)
print('Page size:', result.stdout)
result2 = subprocess.run(['pdfimages', '-list', 'output.pdf'], capture_output=True, text=True)
assert result2.stdout.strip() == '', f'PDF contains image objects: {result2.stdout}'
"

修正: 如果使用 resvg 的 PDF API, 在 CI 中加入 pdfimages -list 自动化检查。同时检查 PDF 中是否有 image XObjects。

I4: CJK locale 在浏览器 HTML 中的字体嵌入策略

PR #389 要求 CJK 支持。在浏览器 viewer 的 HTML 中, 字体有两条路径:

  1. MiniRacer 路径 (Python 侧 engine.py): 字体从 pyfcstm.diagram.assets/fonts/ 加载, 注入 MiniRacer V8 context。
  2. Browser 路径: 字体必须在 HTML 中用 @font-face 定义, 字体数据以 base64 data URI 嵌入。

5 个 CJK locale × 2 weights = 10 个 NotoSans OTF 文件。每个 ~4-8MB, 合计 ~40-60MB。加上 JetBrainsMono 3 个 weights (~600KB)。全部 base64 嵌入后的 HTML 超过 60MB。如果只嵌入一种 locale 的字体 (默认 SC), 用户切换 locale 时其他 locale 的字体不在 HTML 中, 会显示豆腐块。

可执行复现:

# 实施后: 选择 locale=tc, 检查 diagram HTML 中是否有 Noto Sans TC 的 @font-face 声明
grep 'Noto Sans TC' output.html
# 如果没有, 繁体中文标签将用 fallback 字体渲染 (可能是豆腐块)

修正: 明确 locale 在 HTML 中的语义: "locale 选择决定渲染时使用的 font-family, 但 HTML 文件只嵌入选定的 locale 字体"。或者: "HTML 文件嵌入所有 CJK 字体, locale 参数只影响 font-family 声明, 不影响字体负载"。第二种方案需要考虑 60MB HTML 的可用性。

I5: Browser 测试的 CI 基础设施缺失

PR #389 要求 browser 测试覆盖 "全部 corpus 的结构结果、至少 12 个代表性视觉案例、5 个 CJK locale、4 种 viewport profile、hostile/long label、TB/LR、主题、collapse、hover/selection、linked navigation 和三种 browser export"。

当前 CI 矩阵:

  • Python 3.7-3.14 × Ubuntu/Windows/macOS
  • jsfcstm Node tests
  • VSCode extension tests

没有任何 browser test infrastructure。要运行 browser tests, 需要:

  • Playwright + Chromium headless
  • Playwright + Chromium 在 Ubuntu CI runner 上需要 system deps (libnss3, libgbm, etc.)
  • 35 layouts × 12 visual × 2 方向 × 5 locale × 4 viewport = 约 16,800 个视觉案例 (即使抽取代表性案例也需要上千个)

可执行复现:
CI 的 .github/workflows/test.yml 中没有 Playwright/selenium/puppeteer 步骤。

修正: 在 PR 实施前, 先提交一个只加 CI browser test 矩阵的 prep PR, 验证 browser test infrastructure 在所有平台上可用。否则 browser 测试可能会变成 "本地通过, CI 不跑" 的死代码。


Minor (M) — 需要注意的摩擦点

M1: CLI pyfcstm diagrampyfcstm.diagram package 的 import 歧义

PR #389 新增 pyfcstm diagram CLI 命令。但 pyfcstm/diagram/ 已经是一个 Python package。当前 pyfcstm visualize 使用独立的 pyfcstm/entry/visualize.py, 不与 pyfcstm.diagram package 冲突。pyfcstm diagram 的 CLI entry point 需要在 pyfcstm/entry/ 下, 并 from pyfcstm.diagram import ..., 这相对安全但需要小心 import 循环。

修正: CLI entry point 命名为 pyfcstm/entry/diagram_cli.py 或放在子包中, 避免与 pyfcstm.diagram package 的 import 循环。

M2: Cache 设计与确定性 JSON

PR #389 提到 "cache" 但不明确缓存什么。如果缓存 DiagramData JSON:

同时 "portable deterministic JSON" 要求相同的 .fcstm 输入总是产生相同的 JSON 输出。这要求:

  • State/Transition 的枚举顺序必须是确定性的 (sorted keys)
  • ELK 布局的坐标必须是确定性的 (相同输入 → 相同坐标)
  • Python 3.7+ 的 dict 是 insertion-ordered, 但 json.dumps(sort_keys=True) 仍需要明确

修正: 缓存 key 使用 sha256(源文件内容 + 选项 hash). DiagramData/DiagramOptions 的 __hash__ 需要明确定义。

M3: model.show() 临时文件生命周期

model.show() 需要先调 to_html() 生成 HTML, 写入临时文件, 然后用 webbrowser.open() 打开。临时文件的生命周期:

  • 文件何时删除? 程序退出后?
  • 如果用户多次调 show(), 文件是否覆盖?
  • webbrowser.open('file:///tmp/xxx.html') 在 Linux 上需要 file:// 前缀, Windows 上路径格式不同。

pyfcstm diagram --open 也需要相同的临时文件逻辑。

修正: 明确临时文件在进程退出前不删除 (让浏览器有时间加载), 并在下次 show() 时覆盖。使用 tempfile.NamedTemporaryFile(delete=False, suffix='.html')

M4: Hostile labels 与 FCSTM 语法高亮器的 XSS 风险

PR #389 要求 "hostile labels" 支持。SVG renderer 已有 escapeXml(), 可以处理 <>&"'。但浏览器 viewer 的左侧 FCSTM 语法高亮器 (Monaco/CodeMirror) 需要额外处理:

  • 状态名包含 <img src=x onerror=alert(1)>: 在 FCSTM DSL 中这可能是合法的状态名, 但语法高亮器不应执行嵌入的 HTML。
  • 状态名包含 "; delete window; ": 如果语法高亮器用 innerHTML 渲染 token, 这是一个 XSS 向量。
  • 状态名包含 ANSI escape 序列: 文本编辑器需要 sanitize。

可执行复现:

# 构造一个 .fcstm 文件, 状态名为 </svg><script>alert(1)</script>
# 在浏览器 viewer 中打开, 检查 console 是否有 script 执行或 CSP 违规

修正: FCSTM 语法高亮器必须使用 textContent / createTextNode 而非 innerHTML 渲染 token 文本。

M5: CSS layout 中左右面板的拆分方式

三种浏览模式需要动态显示/隐藏左右面板。如果使用 CSS display:none, 隐藏面板中的编辑器实例可能不响应 resize。如果使用 visibility:hidden + width:0, Monaco/CodeMirror 编辑器需要 editor.layout() 调用来重新计算尺寸。

可执行复现:

# 在浏览器 viewer 中: FCSTM-versus-diagram 模式下选择 "FCSTM-only" 模式
# 再切回 "FCSTM-versus-diagram" 模式, 检查 diagram 面板是否正确重新渲染
# (Monaco/CodeMirror 的 layout() 可能不自动触发)

修正: mode switch handler 中显式调用编辑器 layout() 方法和 SVG pan/zoom 的 fit() 方法。


结论

当前判定: 不可执行。 三条 C 级阻塞项 问题 (C1 State ID 格式冲突, C2 Source range 矛盾, C3 共享 renderer 浏览器可用性) 会直接导致实现失败, 必须在写第一行代码前解决。C4 (combo_relay) 是 jsfcstm model 缺口, 要么修改 model, 要么降级需求。

五条 I 级重要项 问题如果不解决, 会导致 CI 红灯、浏览器功能缺失或 PDF contract 无法验证。

建议: 在解决 C1-C4 后, 更新 PR 正文, 追加一轮 planning review。不要在当前状态下开始写代码。


审查者: DeepSeek (自主对抗性计划审查)
审查时间: 2026-07-20
审查类型: 白盒计划审查 (pre-implementation), C/I/M 分类

@HansBug

HansBug commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

Claude 审查员 — PR #389 计划/范围/可执行性/可验收性 对抗性审查

基线dev/python-diagram-api-browser@90f84029(在 dev/python-diagram-umbrella@aa745161 之上的空提交,git diff --stat aa745161..90f84029 为空)。审查只针对 PR 正文 声明的合同、边界、验收,以及它与当前伞分支代码 / issue #89 / VSCode 已实现 Stage.vue 的一致性。

以下按 C(阻塞)/ I(须解决)/ M(非阻塞)分级。所有条目给出具体文件锚点、复现思路和最小修正建议。


C1(阻塞)— 「浏览器真矢量 PDF、不接受 栅格 PDF 回退」与仓内既有工程决策直接冲突,且未指定 进程内 矢量后端

  • 依据:
    • PR 正文「浏览器合同」段:「PDF 必须单页、页面尺寸正确且 zero image objects,不接受 栅格 PDF 回退」
    • 伞分支已合入的 editors/vscode/src/preview-webview/components/Stage.vue:414-427 的实现注释明确写:"We intentionally skip the vector-SVG-to-PDF path: browser SVG rendering involves CSS cascade, webfonts, filters and foreignObject fragments that no 进程内 converter handles accurately — a faithful raster at high DPI is more reliable than a half-broken vector PDF.",接着 renderCurrentSvgToPdf() 用 4× 栅格化 + jsPDF 输出含 PNG 的 PDF。issue 嵌入 jsfcstm 可视化 (SVG/PNG):mini-racer + WASM 栅格化 #89 body 也明确说明 「本 PR 为什么不做 PDF … 要做真矢量 PDF 得引入 svg2pdf.js + @xmldom/xmldom, 是一套独立的工程量、有自己的坑」
    • PR-C body 并未增加任何矢量后端到「实施范围」;@resvg/resvg-wasm@2.6.2(PR-B 冻结的官方资产)只能输出 PNG,不能输出 PDF。当前伞分支没有可以在浏览器 进程内 生成矢量 PDF 的组件。
  • 复现思路:把 PR 正文的 PDF 合同拿去落地时,必然二选一:(a)新增 svg2pdf.js + @xmldom/xmldom 到 零网络 HTML 内并接受它对 CSS/webfont/foreignObject/filter 的近似渲染,会与「视觉与 VSCode 预览一致」以及「不接受 half-broken vector PDF」的既有依据冲突;(b)保留 raster PDF,但那被本 PR 正文 直接禁止。
  • 修正建议:本 PR 正文 必须在 planning 阶段就选定一条路——要么把 PDF 从「实施范围」移出,明确「PDF 由 PR-D headless delivery 阶段用矢量后端交付」;要么把 svg2pdf.js 类矢量后端**及其局限(webfont 子集、foreignObject 语义、filter 覆盖)**写进「实施范围」和「浏览器合同」,并显式撤销 Stage.vue 的历史工程结论。目前二者皆无,属于合同悬挂。

C2(阻塞)— 「portable DiagramData 不得包含 源码范围」与共享 SVG renderer 现有输出直接矛盾

  • 依据:
    • PR 正文「数据边界」段:「portable DiagramData 不得包含 absolute path、源码范围、import filename 或 VSCode selection state」「jsfcstm 的 editor metadata 使用独立 source-map sidecar,使 Python JSON/HTML/cache 保持 portable 和 deterministic」
    • 但共享 SVG renderer editors/jsfcstm/src/diagram/render/svg.ts 的产物在 SVG 属性层直接把 sourceRange 烘焙进去:第 208、262、381、393 行都在通过 rangeAttrs('fcstm-range', meta.sourceRange) 往复合状态、chevron、边、边标签的 <g> / <path> 元素上写 data-fcstm-range-* 属性;editors/jsfcstm/src/diagram/webview-payload.ts:58,94elk-graph.ts:382,440,489 也一路把 sourceRange: state.range / transition.range 往下传。
    • to_html() 的浏览器 SVG 来自这条共享路径。若不改渲染合同,DiagramData/HTML 的 SVG 就会同时携带 data-fcstm-range-*,破坏「portable、无 源码范围」承诺。
  • 复现思路:任意 diagram 走当前 renderer 生成 SVG,rg 'data-fcstm-range' output.svg 都会命中;如果之后把这个 SVG 塞进 to_html(),portable 声明立即失效。
  • 修正建议:PR 正文 必须显式指定 SVG-emitter 的一个「portable render mode」开关(例如 renderFcstmDiagramSvg(..., { includeSourceRange: false })),并把它做成共享 renderer 的一等参数;然后规定:Python to_html() 必须调用 portable mode,data-fcstm-range-* 由独立 sidecar(不进 portable HTML)承担。同时须新增负测:assert 'fcstm-range' not in html and 'sourceRange' not in json。目前这套开关和边界测试在计划里都缺席。

C3(阻塞)— 「State.is_combo_relay 是唯一 runtime truth」但该字段既未在 Python model 上存在,也没在 jsfcstm 侧对齐

  • 依据:
    • PR 正文「数据边界」段:State.is_combo_relay 是 combo relay pseudo state 的唯一 runtime truth,renderer 禁止通过名称猜测」
    • 事实:pyfcstm/model/model.py不存在 State.is_combo_relay 属性;只有 _recursive_build_states 里的局部布尔 is_combo_relay_pseudopyfcstm/model/model.py:2809),用于 diagnostic 触发;grep -rn 'is_combo_relay\b\|isComboRelay\b' pyfcstm/ 无命中。
    • jsfcstm 侧同样没有 isComboRelay 字段(grep -rn 'isComboRelay\|is_combo_relay\|comboRelay' editors/jsfcstm/src/ editors/vscode/src/ 只命中 diagnostics/codes.json 的诊断文案,没有 model 属性)。
    • 因此当前共享 renderer 若真要判定 relay,只能靠 name 前缀 __combo_ 猜测——正是 PR 正文 明令禁止的路径。
  • 复现思路:在本 PR 开始实现前编写一条 test state = model.walk_states()[…]; assert hasattr(state, 'is_combo_relay') 会直接失败;把 combo-expanded 图跑一遍看 renderer 判 relay 的依据,会看到只有 name-based 判定。
  • 修正建议:PR 正文 「实施范围」需要显式列一条「Add first-class State.is_combo_relay attribute on Python model + matching isComboRelay on jsfcstm diagram model + wire it into webview-payload.tssvg.ts 的 relay 分支」,并明确要求 renderer 删掉 name-based fallback;否则整条「唯一 runtime truth」承诺无法在实现阶段闭合。

I1(须解决)— to_html() / show() 在无 MiniRacer 时的语义未定义

  • PR 正文 承诺:「即使基础 wheel 没有安装 MiniRacer,也应支持:diagram.to_dict()to_json()to_html()show()。但 SVG/layout 由 ELK(JS)+ 共享 renderer 生成;无 MiniRacer 意味着 Python 端不能 pre-render SVG。有两条互斥路径:
    1. to_html() 打包 renderer.js + resvg.wasm + ELK bundle + fonts,浏览器现场 layout,SVG 也在浏览器构造;
    2. to_html() 只嵌 pre-rendered SVG(则必须要 MiniRacer)。
  • 「实施范围」两种都可以套上「shared layout/SVG renderer」,但产物大小、CSP、启动时间、Python 3.7 legacy 分支的可行性完全不同(尤其 Windows 7 + Py 3.7 + py-mini-racer<0.7 组合)。
  • 修正建议:PR 正文 必须锁定一种模式或允许两种模式并明确 default,并加验收:no-minracer 环境下 to_html() 产物必须在浏览器完成 layout 并显示 diagram;且 show() 不得静默降级到「打开一份没有图的 HTML」。

I2(须解决)— 交互能力「保留共享 renderer 的交互能力」但当前交互实现绑在 Vue webview 上

  • PR 正文:「diagram 面板必须保留共享 renderer 的交互能力」,同时明确禁止「复制 Vue shell、添加第二套 renderer」。
  • 事实:VSCode 侧的 fit/zoom/pan/reset/collapse/selection/hover 实现集中在 editors/vscode/src/preview-webview/interaction.tscomponents/Stage.vue,是 Vue-native。jsfcstm 的 src/diagram/ 目录里没有等价的通用 interaction module。「保留」不是免费的:要么把 interaction.ts 抽到 jsfcstm 作为公共 vanilla-DOM 模块(扩大 jsfcstm 表面且需要跨包测试),要么在浏览器 viewer 里重写(走向禁令)。
  • 修正建议:PR 正文「实施范围」需要显式添加「Extract interaction.ts from VSCode webview into jsfcstm/src/diagram/interaction/ as a Vue-free vanilla-DOM API, keep VSCode extension as a thin adapter」这一具体动作,并规定:无二次实现、无 Vue 依赖泄漏到 pyfcstm HTML;jsfcstm 与 VSCode 保持共用同一份 interaction 单元测试。

I3(须解决)— FCSTM 语法高亮的交付路径未定,且与 零网络/CSP 合同耦合

  • PR 正文 只说「FCSTM 面板必须支持语法高亮」,没有指定 tokenizer 分发路径。仓内可用素材:pyfcstm/highlight/pygments_lexer.py(服务端)与 editors/fcstm.tmLanguage.json(TextMate,客户端需要 shiki / vscode-textmate + onigasm)。
  • 选择带明显工程差异:(a)Python 端调 Pygments 生成静态 HTML 片段嵌入 → 无 JS/CSP 代价但丢失可选择/可折叠/可 hover 联动;(b)客户端跑 shiki + tmLanguage → 需要 WebAssembly(onigasm/oniguruma-to-es),叠加已有 resvg WASM 的 CSP 边界;(c)Prism/highlight.js + 手写 FCSTM grammar → 无 WASM 但要维护第三份 grammar。
  • 修正建议:PR 正文 需要 pin 一条 tokenizer 路径,并把它写进「浏览器合同」的 CSP 段(是否再引入一份 WASM、哪些 hashes 必须允许),以及写进「验收」段(对 12 个视觉案例 + 5 CJK locale 的 highlight 快照)。目前完全缺席。

I4(须解决)— source-map sidecar 语义与「Transition ID 从 owner state 和最终 transition order 派生」互斥

  • PR 正文:「State ID 从 ownership path 派生并执行 JSON Pointer escaping。Transition ID 从 owner state 和最终 transition order 派生」;同时 sidecar 与 portable JSON 分离。这带出两个具体风险:
    1. 用户 Diagram.from_dict(diagram.to_dict()) 之后 sidecar 不见了 → 「FCSTM-versus-diagram 联动」变成不定义行为;
    2. 「最终 transition order」在 combo relay 展开 / re-parse / 排序稳定性发生变化时会平移 transition ID,导致之前存下的 sidecar / 视图 state 全部错位。
  • 修正建议:PR 正文 需要写清「sidecar 是否可从 DSL + DiagramData 完全重建」;如果可以,就把 sidecar 视作纯派生数据并加测;如果不可以,to_dict()/from_dict() 就必须承认「联动模式仅在原始 Diagram 实例上工作」。同时给出 transition-order 稳定性合同(例如按 source-file textual order + 二次 tie-break by qualified target),并加回归测。

I5(须解决)— 「至少 12 视觉 × 5 CJK × 4 viewport × TB/LR × 主题 × collapse × hover/selection × 3 export」验收矩阵未指定 headless-browser 运行器

  • PR 正文明确要求 browser test 覆盖上述矩阵,且「不实现完整 headless CLI formats」(那部分给 PR-D)。但计划里没有说这些浏览器 gate 在 CI 里由谁跑(playwright / puppeteer / selenium / real Chromium 版本 pin)、如何在 GitHub Actions runner 上装 fonts 与 CJK 后备、如何跟 template-suite-gate 汇聚。issue 嵌入 jsfcstm 可视化 (SVG/PNG):mini-racer + WASM 栅格化 #89 base 无对应基础设施。
  • 修正建议:把「浏览器 gate 用 Playwright(Chromium/WebKit/Firefox 三通道 pin 一个具体版本)+ 固定 dpi + 固定 CJK 系统字体缺席的兜底策略」写进「实施范围」和 CI 段;否则 12×5×4×... 组合在实施阶段无法收敛,PR 完成度不可判定。

I6(须解决)— CSP + WebAssembly 的浏览器地板未声明

  • PR 正文:「CSP 只允许 embedded viewer 必需的 hashes 和 WebAssembly capability;禁止 unsafe-eval。事实:CSP3 的 'wasm-unsafe-eval' 才能在unsafe-eval 的情况下加载 WASM;这在 Chrome ≥ 95、Firefox ≥ 102、Safari ≥ 16 之后才通用。老浏览器只能靠 'unsafe-eval'(被本 PR 正文 禁止)。
  • 修正建议:PR 正文 必须写「浏览器 floor: Chrome ≥ X, Firefox ≥ Y, Safari ≥ Z, Edge ≥ W」并作为 to_html() header 里的 <meta http-equiv="Content-Security-Policy"> 常量凭据;同时给出低于该 floor 时 to_html() 应给出可见错误 banner 而非静默失败。

M1(非阻塞,需记入风险表)— to_html() 单文件大小上限缺失

  • CJK fonts × 5 locale(PR-B 已固定 13-face 集合)+ resvg WASM + renderer + FCSTM tokenizer(若走 WASM)+ 视觉资产,单文件几 MB 起。承诺「portable/deterministic」但没有 size floor / ceiling 记录。至少写一条参考数字(例如「不超过 12 MB / 单 diagram」)+ 一条测。

M2(非阻塞)— Python 3.7 无 MiniRacer 路径的显式测试缺失

  • 关联 I1。若最终锁定「MiniRacer 可选」,就必须在 test/diagram/pytest.mark.no_minracer 或环境变量守卫,明确覆盖 Py 3.7 legacy 分支。目前 test/conftest.pySKIP_SLOW_TESTS 只处理模板套件。

M3(非阻塞)— CLI 通过扩展名 dispatch .json vs .html 是常见 footgun

  • pyfcstm diagram -i machine.fcstm -o machine.json-o machine.html 靠后缀切换语义,遇到 shell 重定向 / 无扩展名 stdout 时不好识别。建议保留扩展名默认 + 加显式 --format json|html 覆盖开关。

剩余风险与非阻塞观察

  • PR 正文「合入边界」明确「本 PR 只能合入 umbrella,不能直接合入 main」——与仓内标签治理一致,无需 scope: non-main(因为 base 已经是伞而不是 main)。
  • 「Python 与 jsfcstm unit tests 保持独立」符合 CLAUDE.md 硬约束;cross-runtime parity 只作为 maintenance checker 也在 tools/ 边界内,未越界。
  • 「35 layouts/306 arrows corpus 继续作为最低 geometry contract」延续 PR-A/PR-B 已合入的 corpus,可行。
  • pyfcstm diagrampyfcstm visualize 分开,符合 issue 嵌入 jsfcstm 可视化 (SVG/PNG):mini-racer + WASM 栅格化 #89 最新权威段落的边界。
  • .omx/plans/prd-python-diagram-p0.md:643 已提到「Add first-class Python State.is_combo_relay semantics」——但那是设计草稿;实际代码里未落地,见 C3。

结论

  • C(阻塞,须在 planning 层修正后再进 implementation):C1(矢量 PDF 与 raster fallback 禁令冲突)、C2(portable DiagramData 与共享 renderer 烘焙的 data-fcstm-range-* 冲突)、C3(State.is_combo_relay 唯一 truth 但字段与 JS 对齐都不存在)。
  • I(须在合入前解决):I1(to_html() 无 MiniRacer 语义)、I2(interaction 抽取路径)、I3(FCSTM 高亮 tokenizer 路径 + CSP 影响)、I4(sidecar 语义与 transition ID 稳定性)、I5(浏览器 gate 运行器/版本 pin)、I6(CSP/WASM 浏览器 floor)。
  • M(记入风险表,不阻塞):M1(单文件大小上限)、M2(Py 3.7 无 MiniRacer 测试)、M3(CLI dispatch 明确 --format)。

只有 C1–C3、I1–I6 逐条在 PR 正文 / plan / renderer 合同上得到具体落地,本 PR 才可以从「空 stage」推进到实际实现。当前 base 是空提交 90f84029,没有代码需要审,但计划层面存在阻塞冲突,因此 planning 阶段本身不 ready。

@HansBug

HansBug commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

Codex 审查员 — PR #389 计划审查

审查基线:当前 PR-C 空提交 90f8402940d9ab7b960adb6b0d1e6ae113ab16e0,base 为 umbrella aa745161。本评论只审查当前 body 的计划和验收合同;仓库没有 PR-C 实现代码,因此不作实现正确性判断。

结论:C=0 / I=4 / M=7;当前判定:观察中。 复用 editors/vscode/src/preview-webview/ 是合理方向,但“复用组件”还没有闭合 独立模式 browser host、source-map 和导出合同。以下问题应在写对应代码前写入 body/设计文件并在实现时验证。


I(重要)

I1. Vue 组件复用与 独立模式 browser host / ELK / CSP 仍未闭合

当前 Stage.vue 直接依赖 getElk()editors/vscode/src/preview-webview/components/Stage.vue:9-16,123-160),而 useElk.ts 假定页面已经提供全局 ELKeditors/vscode/src/preview-webview/composables/useElk.ts:3-15)。Python 资产入口却使用 ELK worker factory(tools/diagram_assets/python-renderer-entry.ts:71-81)。当前 VSCode HTML 的 CSP 也仍明确允许 'unsafe-eval'editors/vscode/src/preview.ts:202-207)。

body 现在说“复用组件、替换 extension host/bridge”,但尚未给出 browser-specific entry、main-thread ELK bundle、WASM 内联初始化、host adapter 事件接口和最终 CSP 字节串。直接把现有 webview bundle 放入 self-contained HTML,无法证明不会启动 worker、fetch 外部资源或依赖 vscode.postMessage

建议合同:为 browser viewer 冻结独立 host adapter 和构建产物;复用 Stage/Toolbar/OptionsBar/DetailsPanel/BottomPanels 的 Vue 组件及纯交互逻辑,但明确 独立模式时 ELK 的提供方式、worker-src 'none'、resvg WASM 的 inline Uint8Array -> WebAssembly.Module 初始化和 零网络 规则。
复现:生成三种 HTML,在 Chromium 的 file:// 或离线本地页打开;记录所有 network、console/CSP violation、Worker 创建;随后点击 layout/zoom/export,验证无外部请求且 SVG 结构结果与 shared renderer 一致。

I2. FCSTM 面板、source-map 与双向联动的输入/降级行为未定义

现有 VSCode payload 仍携带 filePathTextRange 和 editor host 状态(editors/vscode/src/preview-webview/types.ts:56-59,79-106,166-199)。App.vue 的 source 联动依赖 vscode.postMessage({type:'revealSource'})editors/vscode/src/preview-webview/App.vue:252-267,363-364);现有组件没有 FCSTM 源文本面板或浏览器 tokenizer。PR-C 却同时要求 FCSTM-only、diagram-only、compare、语法高亮和双向滚动联动,并要求 portable JSON 不含 源码范围/path。

建议合同:冻结 sidecar schema(logical document ID、UTF-8 source/content hash、import graph、range 表、state/transition ID 映射、forced/combo 一对多 provenance);定义 独立模式 host adapter 的 selection/hover/scroll 协议;明确缺 sidecar、programmatic model、导入文件缺失和 parse error 时的降级(展示源码但禁用联动,而不是静默错配)。
复现:同一图分别覆盖单文件、导入文件、forced transition、combo relay、重复 label、修改源文本后重载 JSON;点击source-line/图元、hover、滚动,确认匹配 ID、范围和 fallback 均确定。

I3. “真矢量 PDF”仍与被复用的 VSCode 导出实现冲突

Stage.vue 当前 PDF 明确是 4x canvas PNG 包装(editors/vscode/src/preview-webview/components/Stage.vue:413-445),OptionsBar.vue 的导出提示也只写 SVG/PNG(editors/vscode/src/preview-webview/components/OptionsBar.vue:103-114)。PR-C body 同时要求 true vector PDF、单页、zero image objects、禁止 raster fallback。仅复用组件不能满足这个新合同。

建议合同:明确 framework-neutral export module 的来源和版本(当前伞 PR 约定的 svg2pdf.js 2.7.0 + jsPDF 4.2.1)、expanded SVG 到 path/text 的处理、字体落地策略、MIT NOTICE/integrity/metafile 以及 browser asset owner;Stage 只调用 adapter,不保留第二个 raster PDF 路径。
复现:对 LR/TB、长标签和五个 CJK locale 下载 PDF;用 pdfinfo 检查单页/尺寸,用 qpdf --show-objects 或对象解析器确认无 /Image,并把 SVG 文本/路径 bbox 与页面尺寸对照。

I4. cache、CLI 和 wheel 资产闭环仍缺少可执行合同

当前 setup.py / MANIFEST.in 只列出 pyfcstm/diagram/assets 的现有 JS/WASM/font/license 资源(setup.py:48-71MANIFEST.in:10-16),没有 browser viewer bundle、PDF exporter、source-map schema/字体清单的 package-data 与 fresh-wheel 验收。body 也没有冻结 cache key/版本失效、atomic replace、symlink/wrong-owner、并发读写,或 pyfcstm diagram 的 suffix、overwrite、--open、错误码行为。

建议合同:把 browser assets、licenses/NOTICE、manifest 和预期文件清单纳入 build/package checker;定义 cache key = 内容/数据合同/renderer 产物的确定性摘要,写临时文件后原子替换并拒绝不安全 owner/symlink;明确 --open、输出扩展名不支持、覆盖和无 GUI 时的行为。
复现:在 checkout、fresh wheel、sdist 解包目录分别运行 JSON/HTML/open;删除/篡改 viewer/WASM/font、并发生成同一 cache、改变输入字节和 renderer asset,确认均能给出可操作错误且不会复用旧产物。


M(建议)

  1. portable state/transition ID 的 escaping、排序、combo relay 和一对多 forced/combo 映射应列成逐字段 parity 表;不能只写“与 jsfcstm 对齐”。现有 jsfcstm transition ID/源范围格式见 editors/jsfcstm/src/diagram/builder.ts,应通过 fixture 明确是否保留 editor-only ID。
  2. Stage.vue 使用 innerHTML 注入 renderer SVG(:113,167),hostile label、属性、style、</script>、U+2028/U+2029 的字节级转义和 no-innerHTML 外部数据规则应有负测。
  3. Browser gate 应固定 headless browser/OS 组合和字体安装,明确 35 layouts/306 arrows 是结构 oracle、至少 12 个图是视觉样例;不要把跨平台字体像素差异误判为 renderer 回归。
  4. CSP checker、export checker、interaction checker 可以实现为一个 Playwright integration gate 的分组断言,避免多个相互重复的“validator”脚本;但不能删掉真实浏览器 gate。
  5. FCSTM tokenizer/highlighting 方案需在 body 明确(轻量只读 tokenizer、服务端 Pygments 预渲染或 TextMate/WASM 三者择一),并规定跨行 range、注释/字符串/CJK/恶意标签和 parse-error fallback。
  6. 复用 Vue/Naive UI 后的 bundle 体积、字体只嵌选定 locale 还是全量、HTML 初始解码内存和浏览器最低版本应给出基线。
  7. PR-D 已排除 Python 同步导出、Notebook、PyInstaller 和完整 headless CLI;PR-C 的 public API/HTML 不应隐式导入 MiniRacer 或 Node,缺 optional capability 必须是 typed unavailable,资源/渲染错误必须 loud fail。

最终判断:当前没有代码级 C 问题,但 I1-I4 需要在实施前冻结并形成可执行验收;M1-M7 应在实现/CI 中落地。完成这些合同、发布其余独立 adversarial planning review 并回读真实 browser/package 证据后,PR-C 才能从 观察中 进入可施工状态。

— Codex 审查员

@HansBug

HansBug commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

Codex 审查员:PR-C 计划复核

审查基线:PR #389 Current head 90f8402940d9ab7b960adb6b0d1e6ae113ab16e0,基于伞 PR #383 合并提交 aa745161。当前分支只有占位提交,没有 PR-C 实现代码;本评论只审查计划、范围和可验收性,不把“空分支”误报成实现缺陷。

结论:C=2,I=6,M=3;当前仍是 观察中,不具备开始实现的完整合同。 下面的问题需要在对应代码开始前写回本 PR 正文/设计文件,并在实现后用真实浏览器、全新 wheel 和独立 fixture 证明。

C(阻塞)

C1:PR-C 与伞 PR 的“复用 VSCode”边界互相矛盾

伞 PR #383 的 PR-C 条目明确写的是“构建不复制 VSCode Vue shell 的 self-contained viewer”;PR #389 却写成“优先复用 preview-webview 中的 Vue 组件和交互逻辑,只替换 extension host/bridge”。这不是措辞差异,而是两个不同的架构边界。

当前实现也证明不能直接把现有 webview 打包进 HTML:

  • Stage.vue 通过 useElk.ts 读取全局 ELK
  • useBridge.ts 读取 acquireVsCodeApi,浏览器降级只是不执行消息;
  • main.ts 依赖 window.__FCSTM_INITIAL_STATE__,并把导出事件发回 VSCode;
  • Stage.vue 的 PDF 仍调用 jsPDF 并包装画布栅格图;
  • Python 资产入口还带有 MiniRacer 专用的 __pyfcstm_embedded_host/计时器分流。

复现:把现有 webview bundle 放进不提供 acquireVsCodeApiELKjsPDF 和 VSCode 消息宿主的独立 HTML,依次进入三种模式、点击布局、折叠和导出;至少会得到全局依赖缺失、消息无效或导出路径不符合 PR-C 合同。

必须修正:二选一并写死:

  1. 把纯数据、布局调用、SVG 绘制、交互状态和导出抽到 editors/jsfcstm/src/diagram/ 的宿主无关模块,VSCode 只保留薄适配器,浏览器 viewer 使用同一模块;或
  2. 明确允许复制一套独立 viewer shell,并承认这会产生第二套交互实现。

当前“复用组件但不复制 shell”没有定义可编译的入口、依赖注入接口、事件协议和构建产物,不能作为执行计划。

C2:portable DiagramData、源码面板和双向联动的数据合同尚未闭合

PR-C body 要求公共 JSON/HTML 不包含绝对路径、源码范围、导入文件身份和 VSCode selection state;同时又要求 FCSTM 面板、状态/转换点击、悬停、滚动和图形双向联动。现有链路在 webview-payload.tselk-graph.tsrender/svg.ts 中仍把 filePathimportedFromFilesourceRange 以及 data-fcstm-range-* 直接送进 payload/SVG。

复现:使用一个带导入文件、强制转换和组合中继的模型,生成当前 payload/SVG 并搜索 filePathimportedFromFilesourceRangedata-fcstm-range;再把这些字段删除后点击source-line和图元,现有 ID/范围无法完成联动。

必须修正:冻结独立 sidecar 合同,至少包含逻辑文档 ID、源码 UTF-8 内容摘要(若 HTML 要显示源码则必须包含内容或可见地声明不可用)、状态/Transition ID 到文档范围的映射、导入图以及强制/组合展开的一对多来源。共享 SVG 增加显式的 portable render 模式,保证公共 HTML/JSON 不烘焙范围属性;缺少 sidecar、程序化模型、导入源不可读或源内容摘要变化时,必须显示“联动不可用”的可见状态,不能静默错配。

I(重要)

I1:无 MiniRacer 时 to_html() 的默认语义未定义

body 同时承诺基础 wheel 未安装 MiniRacer 仍支持 to_html()/show(),而当前 Python 侧唯一实际 renderer 是 MiniRacer engine。必须明确 HTML 是预先嵌入 SVG,还是在浏览器中执行 ELK/layout;后者还要冻结浏览器端 ELK、resvg WASM 的内联初始化、异步生命周期和失败界面。

复现:在全新虚拟环境卸载两个 MiniRacer 包,调用 model.diagram().to_html(),在 file:// 页面离线打开并点击布局、PNG;验收必须看到非空图和可操作错误,而不能得到空白页或只生成 JSON。

I2:真矢量 PDF 不能只靠复用现有 VSCode 导出

伞 PR 约定的 svg2pdf.js/jsPDF 路径与当前 Stage.vue 的 4 倍 PNG 包页实现不同。PR-C body 尚未写出 framework-neutral 导出模块、字体/阴影处理、禁止 canvg/html2canvas 等栅格依赖的构建门禁和许可证资产清单。

复现:对 TB/LR、长标签和五个 CJK 地区下载 PDF,使用 pdfinfo/pdfimages -list 检查单页、尺寸和零 /Image 对象,同时扫描 esbuild metafile;任何 PNG 包页或隐式栅格依赖都应失败。

I3:CSP/WASM 浏览器地板和 file:// 行为未冻结

body 只写 wasm-unsafe-eval、禁止 unsafe-eval 和零网络,没有 Chrome/Firefox/Safari/Edge 版本地板,也没有说明 file:// 下的 WebAssembly.Module、字体 data URI、CSP meta 和下载行为。必须在固定版本的 Playwright 矩阵中测 console、CSP violation、网络请求和 WASM 初始化,并为不支持的浏览器给出可见错误。

I4:缓存、CLI 和资源构建仍没有可执行闭环

当前 setup.py/MANIFEST.in 的资源清单只覆盖 renderer、resvg、字体和许可证;tools/build_diagram_assets.py 也没有 viewer/PDF 入口。PR-C 的新 bundle、字体、许可证、manifest、构建产物哈希和 make 目标必须一并锁定。

复现:在全新 clone、sdist 解包目录和 fresh wheel 中分别运行 JSON/HTML/--open;删除或篡改 viewer/WASM/font、并发写同一缓存、使用无扩展名输出,检查是否能给出明确错误、原子替换且不复用旧文件。

同时冻结 CLI 的格式选择、覆盖策略、--open 无图形环境行为、退出码和缓存 key(数据、选项/视图状态、renderer/WASM/font 摘要及格式等),否则无法判断“缓存正确”或“CLI 完成”。

I5:ID 规则仍会破坏跨运行时和源码联动

伞 PR 规定State ID 做 JSON Pointer escaping、Transition ID 依赖最终顺序;现有 jsfcstm transition ID 仍包含source-line列,ELK 节点使用未转义的限定名。需要明确唯一 canonical ID 是否由 Python 与 jsfcstm 共用,源码顺序/空白/组合展开变化后的稳定性,以及 sidecar 如何按 ID 重建。

复现:同一模型在 Python/jsfcstm 各生成一次 ID;只改变空白、插入注释、交换同 owner 转换顺序,再比较 ID 集合和选择联动。结果必须符合 body 写明的稳定性规则,而不是依赖对象地址、源码位置或 name 猜测。

I6:程序化模型和导入模型没有源码面板语义

StateMachine 可由 AST/程序直接构造,且导入状态可能来自多个文件;这类模型不天然带有一份可展示的原始 FCSTM 文本。PR-C 必须明确:是通过 AST 导出生成只读 FCSTM 文本、要求调用者显式提供源码 sidecar,还是在 FCSTM-only/compare 模式禁用源码联动并显示原因。不能让 model.show() 静默出现空的源码面板。

M(非阻塞但必须记录)

  1. 验收矩阵应锁定 Playwright 版本、浏览器版本、运行系统、字体安装和截图/下载 artifact 路径;35 layouts/306 arrows 是结构基线,不应被未控制的系统字体像素差异替代。
  2. “PR 正文、标题和评论全中文”已按要求统一;代码/API 名称和命令保留原文,说明性文字、状态标签和审查身份均使用中文。
  3. __init__.py、新增 public 类/函数的文档需遵守仓库 reST 规范,包含参数、返回值、异常和 Example::make rst_auto 只能作为生成步骤,不能替代人工检查。

审查结论:当前没有实现代码可判定为通过;C1/C2 解决前不应进入 TDD/开发。请先把两份 PR 正文的边界和 sidecar/宿主/资源合同统一,再开始实现;实现后按上述复现步骤提交浏览器截图、下载文件、fresh wheel 和 CI 证据。

— Codex 审查员

@HansBug

HansBug commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

语言约束已落实

PR-C 的标题、正文以及现有审查评论中的说明性文字、审查身份和状态标签均已统一为中文。代码标识、API 名称、命令、文件路径、协议字段和库名保留原文,以保证可复制执行和技术准确性。后续 PR 更新、审查记录和进度汇报继续使用中文。

@HansBug

HansBug commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

语言调整完成

已将本 PR 标题和正文中的说明性文字统一为中文。后续进度、审查和验收评论也统一使用中文。

为保证代码可复制执行,以下内容保留原文:API 与类名、命令、文件路径、协议字段、提交号、库名、浏览器名称以及代码块内容。除此之外不再新增英文叙述。

@HansBug

HansBug commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

资源归档检查已修复

已将 LICENSE-MIT.txt 纳入 diagram 资源归档检查器的受控清单,与 MANIFEST.insetup.py、资产构建器和资产检查器保持一致。

本地验证结果:

  • python tools/check_diagram_package.py --check 通过;
  • make package 成功生成 wheel 和 sdist;
  • python tools/check_diagram_package.py --dist-dir dist 报告 1 个 wheel 和 1 个 sdist 均通过。

同时已把 PR 正文更新为中文的实际交付状态、剩余门禁和验证证据。后续进度、审查和 CI 汇报继续使用中文。

@HansBug

HansBug commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

Codex 审查员:当前工作树实现复核

审查基线:本地 dev/python-diagram-api-browser 工作树相对伞分支 aa745161 的未提交 PR-C 实现;远端 PR #389 当前仍只有占位提交 90f84029,因此以下结论必须在推送后重新读取实际 head 和 CI。审查未修改代码。

我实际运行了 test/diagram/test_api.pytest/entry/test_diagram.py,结果为 9 passed;用当前生成的 HTML 运行离线 Chrome 检查,宽屏三模式、双向选择/悬停、缩放、折叠、无网络和零 /Image PDF 均能通过。下面是仍然阻塞或重要的问题。

结论:C=2,I=5,M=3;当前不能判定为 ready。

C(阻塞)

C1:--open 会把 HTML 写进 .json 文件

pyfcstm/entry/diagram.pydiagram_command()open_browser 为真时直接调用 view.show(output, open_browser=True),完全忽略 --format 和输出后缀。真实复现:

printf 'state Root;\n' >/tmp/machine.fcstm
pyfcstm diagram -i /tmp/machine.fcstm -o /tmp/result.json --open
head -c 20 /tmp/result.json

当前退出码为 0,文件后缀仍是 .json,内容却以 <!doctype html> 开头。这会静默破坏用户已有的 JSON 文件和脚本流水线。

修正--open 必须要求 HTML 输出;对 .json--format json 返回非零用法错误,或者自动改成明确的 .html 目标并在输出中报告实际路径。加入 --open + .json--open + --format json、无输出路径三条回归测试。

C2:导入模型的源码联动目前会把子文件范围错映射到主文件

pyfcstm/diagram/api.py::_source_sidecar() 只返回 id -> {kind, range},没有逻辑文档 ID,也没有每个导入文件的源码内容。真实复现:主文件一行导入 child.fcstm,子文件一行定义 Child;当前输出包含:

root lines 1
Root.Child range: line 0, character 0..40
Root.Child.Idle range: line 0, character 14..25

这些范围来自子文件,却被送进主文件唯一的 sourceHtmlsourceLineMap;点击主文件第 0 行会选择子文件的转换,主文件和子文件内容较长时还会滚动到完全错误的位置。

修正:sidecar 必须按逻辑文档拆分,至少携带 documentId、来源关系、source text或明确的不可用状态,以及 id -> (documentId, range) 映射;sourceLineMap 也必须以文档为键。没有子文件源码时应显示“该节点联动不可用”,不能把子文件范围伪装成主文件范围。加入单文件、导入文件、组合/强制转换的一对多联动回归测试。

I(重要)

I1:生成的 CSP 与计划合同不一致,当前直接放开了内联样式

Diagram.to_html() 生成的 meta 明确包含:

style-src 'unsafe-inline'; style-src-attr 'unsafe-inline'

而伞 PR/PR-C 合同要求精确样式哈希、style-src-attr 'none',并将 unsafe-inline 视为禁止项。当前 browser checker 只检查无网络和无控制台错误,不会发现这个回退。

修正:要么把 viewer CSS 和固定内联样式改成精确哈希并禁止属性样式,要么明确改写合同并说明风险;checker 必须逐字断言最终 CSP,而不是只查 connect-src。还需覆盖 file:// 下的 Chrome/Firefox/WebKit WASM、字体和下载行为。

I2:缓存和原子写入尚未实现

Diagram.to_html(output) 直接调用 Path(output).write_text(...)Diagram.save() 也直接写 JSON;当前没有内容寻址缓存、缓存键、并发保护或同目录原子替换。计划和 PR-C 验收却把 atomic content-addressed cache 作为完成条件。

修正:实现临时文件加同目录原子替换,并定义包含 DiagramData、选项/视图状态、renderer/WASM/font 摘要和格式的缓存键;或者从本 PR 正文 删除 cache 承诺并将其明确移交后续 PR。至少加入并发写、目标为符号链接、写入中断和输入变化失效测试。

I3:viewer bundle 仍然包含被禁止的栅格依赖,且没有对应门禁

当前 pyfcstm/diagram/assets/viewer.js 中可搜索到 canvg 8 次、html2canvas 26 次;来源是 jspdf 的可选/传递依赖。伞 PR 的 PDF 合同明确要求最终产物不得含 canvghtml2canvasfast-png 或其他栅格回退。当前 PDF 实测虽然是单页且零 /Image,但这不足以证明 bundle 不会走栅格路径。

修正:对 emitted viewer.js 和 esbuild metafile 增加禁止依赖扫描;若 jspdf 无法在不带这些依赖的闭集下构建,应停止该 writer,不能把它们随 wheel 打包。PDF 结构检查仍需保留,不能用zero image objects检查替代依赖扫描。

I4:紧凑视口存在真实布局溢出,现有 checker 没有失败

我用 800x600 运行当前 check_viewer_browser.js,报告:

mainScrollHeight = 242
mainClientHeight = 163

截图显示源码面板和图形面板被底部详情抽屉压缩/遮挡,滚动条和图形底部提示互相覆盖;宽屏 1365x768 才没有该问题。PR-C 合同要求 compact/laptop/desktop/wide 无 overflow/crop,当前 checker 没有把 scrollHeight > clientHeight、横向溢出和关键控件遮挡作为失败条件。

修正:修复紧凑布局的高度分配或默认收起详情抽屉,并把四类视口的几何断言加入 gate;提交截图 artifact,而不是只报告“元素存在”。

I5:DiagramData 声称 frozen,但其公开 value 仍可被直接修改

当前实现:

data = DiagramData({"kind": "diagram", "rootState": {"children": []}})
data.value["kind"] = "tampered"
assert data.to_dict()["kind"] == "tampered"

@dataclass(frozen=True) 只冻结了属性绑定,没有冻结内部映射。若 immutable snapshot 是公共合同,应在构造时深拷贝并隐藏原始映射,或使用真正的不可变结构;否则用户可以在生成 JSON/缓存后改变同一个快照。

M(非阻塞但应记录)

  1. @font-face 对 TTF 也声明 format('opentype');当前 Chrome/CJK 样例能显示,但应使用正确 MIME/format 并在 Firefox/WebKit 实测。
  2. check_viewer_browser.js 只固定 Chrome,未覆盖合同要求的浏览器版本、字体安装和视觉 artifact;当前 CJK 五地区检查只证明 Chrome 路径。
  3. PR-C 新增 Diagram 类的类级文档只有一段简介,参数/异常/Example:: 主要放在构造函数;合入前应按仓库 reST 规范补齐,并运行 make rst_auto 后人工检查。

审查结论:C1、C2 需要先修复;I1–I4 至少要在本 PR 内闭环并加入失败门禁。当前本地 targeted tests 和宽屏 smoke 的绿色结果不能覆盖上述缺陷,也不能把远端空 PR 判为已完成。

— Codex 审查员

@HansBug

HansBug commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

Codex 审查员:PR-C 实现、范围与验收审查

审查基线:PR #389 远端 HEAD 仍为 90f8402,当前工作树另有未提交的 PR-C 实现;审查同时核对了最新 PR 正文、工作树代码、测试和浏览器检查器。

结论:C=2 / I=5 / M=5;当前判定:暂不可合入。

C(阻塞)

C1. PR 正文声称“实现已完成”,但远端 PR 仍是空提交

gh pr view 389 当前返回 files=[],唯一提交仍是 90f8402 chore(diagram): open Python API browser stage。当前本地新增/修改的 pyfcstm/diagram/api.py、pyfcstm/entry/diagram.py、独立模式 Vue 组件、viewer build/check 脚本和测试均未进入远端提交。

所有远端检查的 提交哈希 也都是 90f8402,因此它们验证的是空计划提交,不是当前工作树实现。工作树上的本地通过结果不能替代推送后 CI/Codecov/三份代码审查证据。应先提交并推送实际 diff,再重新运行全部必需门禁,之后才能把正文中的“已交付”改成可审计事实。

C2. 浏览器 gate 的覆盖范围和稳定性不足以支撑正文声明

tools/diagram_assets/check_viewer_browser.js 只接受一个 HTML 和一个 VIEWER_VIEWPORT(:19-24),脚本内部也只操作一个固定页面/一个模型(:96-194);Makefile:248-250 只是把用户传入的单个 HTML 转交给它。当前仓库没有“10 个模型 × 3 视口 × 3 模式 × 5 CJK locale”的矩阵驱动或报告归档。

此外,脚本把 .fcstm-stage__empty-title 的任何文本都当成错误(:96-100、:190-194)。对当前有效 HTML,将 VIEWER_STARTUP_WAIT=800 运行:

timeout 30s env VIEWER_STARTUP_WAIT=800 node tools/diagram_assets/check_viewer_browser.js /tmp/pr389-viewer.html

行为、选择、联动、PDF、零外部请求均返回成功字段,但进程仍因初始标题 FCSTM Preview 返回 rc=1;默认等待 2000ms 又返回 rc=0。这是时序敏感的假失败,不能作为稳定 CI gate。应等待明确的 layout-ready 标记后检查错误,并增加可重复的模型/locale/视口矩阵及截图/报告产物。

I(重要)

I1. source-line 映射目前一行只能映射一个 ID

pyfcstm/diagram/api.py:_source_sidecar 用 line_to_id: Dict[str, str],同一源代码行的多个 state/transition 只保留一个候选。FCSTM 允许在一行写多个以分号分隔的声明;此时点击该行无法精确选择每个 transition,和“状态/转换双向联动”合同不一致。

应改为 line -> ID 列表或基于列偏移的 range 命中,并补充同一行多 transition、跨行 transition、导入文件、forced/combo relay 和 source override 的测试。

I2. 目前测试没有覆盖 body 声称的语义矩阵

test/diagram/test_api.py 当前只有 9 个测试,覆盖基础 JSON/HTML/单模型联动和 hostile </script>;没有 imported/programmatic/forced/combo、长标签、五 locale 字体、LR/TB、重复 transition、CJK PDF、source mutation 或错误降级的 Python fixtures。浏览器检查器也没有这些矩阵。

应把“结构 oracle”和“浏览器视觉/交互 oracle”分开归档,逐项记录模型、方向、locale、视口、截图和 PDF/SVG/PNG 结果,不能只在正文列出数量。

I3. VSCode 现有扩展行为被一并改成矢量 PDF,但 ownership/回归合同未写清

工作树修改了 editors/vscode/src/preview-webview/components/Stage.vue:413-445,把原有 4x PNG PDF 替换成 svg2pdf.js;同时修改了 editors/vscode/src/preview.ts 和 VSCode package lock。PR-C 的目标是 独立模式 browser viewer,正文没有明确这是否也要改变 VSCode 用户已有的 PDF 行为,也没有列出对应 VSCode runtime/export regression gate。

这不是 PR-D 的 Python headless API 越界,但它扩大了 PR-C 的共享宿主影响面。应二选一并写死:要么明确“PR-C 同步升级 VSCode PDF”为交付内容并加入 VSCode 导出/CJK/零图像测试;要么把 vector exporter 留在 独立模式 adapter,避免未审计地改变现有扩展行为。

I4. viewer 依赖 provenance 不完整且构建会隐式执行网络安装

tools/build_diagram_assets.py:215-260 在 VSCode 依赖缺失时运行 npm install --package-lock=false;asset lock 只冻结 svg2pdf.js,未对 Vue、Naive UI、unplugin-vue、esbuild 的安装树/元数据做同等完整回读。这样 clean checkout 的 viewer 产物可能随 registry/依赖解析漂移,虽能生成 viewer.js,但不满足 deterministic build 的强声明。

应使用锁文件驱动的明确安装路径,或把所有 viewer 构建依赖的完整 provenance 纳入 manifest/lock,并在无网络 fresh checkout 与重复 clean build 中证明字节一致。

I5. 独立模式 source “Reveal source” 仍走 VSCode bridge

editors/vscode/src/preview-webview/App.vue:381-383 的 revealSource 无论是否 独立模式都调用 vscode.postMessage;独立模式的 useBridge.ts fallback 虽然 no-op,但 DetailsPanel 仍显示 “Reveal source” 按钮。独立 HTML 中点击它不会跳转源码,也没有清晰的“仅滚动源码面板”行为。应在 独立模式 下隐藏/替换该动作,或定义并测试明确的本地滚动协议,避免用户看到一个无效命令。

M(建议)

  1. DiagramData 公共构造器目前接收任意 mapping,to_dict() 直接假定 rootState 存在;应补 schema/错误类型,避免把内部 renderer shape 当成已验证公共数据。
  2. to_html(output=...) 和 Diagram.show() 直接覆盖写文件,未见 atomic replace、symlink/owner/concurrency 规则;若这些不属于本轮,应从正文的 cache/保存承诺中明确排除。
  3. 生成 HTML 当前会内嵌单 locale 字体,样例 HTML 约 30 MB;应在文档和 gate 中记录各 locale 体积、内存和最小浏览器版本。
  4. CSP 检查目前只收集外部请求和 console error(checker:178-179),没有监听 SecurityPolicyViolationEvent 或 CDP 的 CSP violation 事件;“CSP 无违规”证据仍不充分。
  5. 当前没有发现 PR-D 的 Diagram.to_svg/to_png/to_pdf、Notebook、PyInstaller 或完整 headless runtime API;CLI 只提供 JSON/HTML/open,这一边界目前保持正确,应继续防止后续提交越界。

最终判断:当前工作树已经形成 PR-C 的主要 API/viewer 方向,但远端尚未包含实现,且浏览器 gate 不能支撑正文的全矩阵声明。先解决 C1-C2、推送真实 diff 并回读对应 CI,再补 I1-I5 的合同/证据,之后才能重新评估是否 ready。

— Codex 审查员

Add reusable VSCode preview modes, source sidecars, offline assets, and browser export gates.
@HansBug

HansBug commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

第三路独立验证结果(提交 57acff01

结论:当前 阻塞,存在一个现有 VSCode 行为回归(C1);除此之外本地资产、Python API、JS/VSCode 构建和 独立模式 浏览器导出验证通过。

C1(阻断):VSCode 生产几何门禁 失败

在当前提交 直接执行:

cd editors/vscode
npm run test:preview-geometry

失败:

Error: drawer drag did not resize the production layout
before.drawer.height = 130
 dragged.drawer.height = 130
before.stage/inner 与 dragged.stage/inner 也完全相同

根因是 editors/vscode/src/preview-webview/App.vue 的:

@media (max-height: 900px) {
    .fcstm-bottom-drawer { max-height: 130px; }
}

该规则在现有 VSCode 1024x768 预览视口生效,把可拖拽抽屉固定在 130px,导致既有 drawer resize 合同失效。它不能只按 独立模式 浏览器逻辑处理;需要隔离 独立模式 媒体规则或恢复 VSCode 预览下的可拖拽高度,然后重新通过 npm run test:preview-geometry

M1(维护文档):jsfcstm 构建命令不一致

PR 正文 记录 cd editors/jsfcstm && npm run compile:tsc,但该 package 没有此脚本,实际返回 npm error Missing script: "compile:tsc"。当前可用命令是 npm run build,它已通过。请修正文档中的验证命令,避免复现失败。

已通过的直接证据

  • Python 3.7.17 + py-mini-racer 0.6.0test/diagram/test_api.py test/entry/test_diagram.py 14 passed。
  • Python 3.8.1(临时安装测试依赖)同两套测试:14 passed。
  • editors/jsfcstm: npm run buildnpm run test:unit,后者 699 passing。
  • editors/vscode: npm run compile:tscnpm run compile 通过;仅 test:preview-geometry 失败。
  • make test_boundary_checkcheck_diagram_assets.pycheck_diagram_package.py --check 通过。
  • 独立模式 browser gate:PNG 实际解码、非空像素检查通过;PDF 头为 %PDF-、单页、0 个图像对象,证明矢量 PDF 路径;无外部网络请求、无 console error、CSP 使用内嵌脚本哈希和 wasm-unsafe-eval
  • 5 个 CJK locale(SC/TC/HK/JP/KR)分别生成 HTML 并在真实 headless Chrome 中通过同一浏览器门禁。

在 C1 修复并重新回读 CI 前,不建议标记可合入。当前 PR 仍为 Draft,远端 mergeStateStatus=UNSTABLE,多项检查尚未完成。

@HansBug

HansBug commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

Codex 审查员 — PR-C 当前提交 范围与验收复核

审查基线:远端 PR-C HEAD 57acff0,base umbrella aa74516。已核对远端文件差异、最新正文、真实 CI 提交哈希,并在本地复跑资产、打包、浏览器和导入模型场景。

结论:C=2 / I=5 / M=5;当前仍 当前不具备合入条件。

C(阻塞)

C1. clean checkout 的资产构建链路在真实 CI 中失败

当前 tools/build_diagram_assets.py:215-260 在缺少 editors/jsfcstm/jsfcstm.tgz 或 dist 时会执行 npm run build;该 build 又执行 editors/jsfcstm/scripts/generate-parser.js,要求仓库外部未跟踪的 antlr-4.9.3.jar。

真实远端证据:

  • Docs HTML job 88425567002 在 make build_assets 处失败:Required file not found: /home/runner/work/pyfcstm/pyfcstm/antlr-4.9.3.jar;
  • CLI Build And Release job 88425553740 同样在 make build_info -> build_assets 处失败;
  • 因此 Diagram assets、模板 full/representative、CLI、Docs、Try package 等多个 job 失败,release-test artifact fresh gate 88426496404 也因 source/CLI failure 拒绝通过。

这不是环境偶发问题,而是 PR-C 将 viewer 构建接入既有 build_assets 后引入的 clean-checkout 依赖闭环缺口。当前本地存在未跟踪的 antlr jar 和已生成 dist/tgz,不能代表 CI 或发布者 fresh checkout。应明确三选一:viewer 构建复用已锁定且无需 parser 生成的预构建 jsfcstm 包;构建 job 显式且可复现地安装/生成 ANTLR 资产;或把 viewer 构建拆成有明确前置的独立目标并修正所有 CI DAG。修复后必须在无本地生成物的 fresh checkout 重跑 make build_assets、package、CLI 和 docs。

C2. 禁止栅格依赖的检查器对真实 emitted viewer 误绿

PR-C/伞 PR 的 PDF 合同禁止 canvg、html2canvas、fast-png 等栅格回退依赖。当前本地执行:

make diagram_assets_check

返回成功,但同一产物 pyfcstm/diagram/assets/viewer.js 仍包含:

  • canvg:5 次;
  • html2canvas:14 次;
  • fast-png:1 次;
  • dompurify:5 次。

根因是 tools/build_diagram_assets.py:524-535 只检查 esbuild metafile 的输入路径是否出现 node_modules/,没有扫描 emitted viewer.js 字节,也没有对 jsPDF 内置的可选 SVG/canvas 路径做禁止合同。fast-png-stub.js 自身还把失败字符串打入 bundle;即使这些路径当前未调用,禁止合同是“不能随 wheel/HTML 产物携带”,而不是“测试样例没有走到”。

应把 emitted bytes、metafile 输入和 package archive 一起做 banned-token/dependency gate,并证明 SVG/PNG/PDF 三条路径不会动态加载 canvg/html2canvas。当前 checker 不能作为该合同的证据。

I(重要)

I1. 正文声称的 10 模型 × 3 视口 × 3 模式 × 5 CJK 矩阵没有可执行驱动

tools/diagram_assets/check_viewer_browser.js 仍一次只接收一个 HTML 和一个 VIEWER_VIEWPORT;Makefile 的 diagram_browser_check 也只转交一个路径。脚本增加了 imported source、PNG 结构和 overflow 字段,但没有模型/locale/viewport 迭代、截图归档或 JSON 汇总。当前正文的矩阵数字无法从仓库命令复现。

应提交明确 fixture inventory、矩阵 runner、固定 Chrome/字体环境和 artifact manifest;每个 case 至少记录模式、方向、locale、下载文件摘要和 screenshot。

I2. 窄视口仍能被 checker 误判为通过

我用当前提交 生成 HTML 后运行:

VIEWER_VIEWPORT=320x480 node tools/diagram_assets/check_viewer_browser.js /tmp/pr389-current.html

返回 rc=0,但报告 main 高度只有 98px,source 高度 45px,stage 高度 43px;截图中 OptionsBar/导出菜单、源码和图形区域被压缩到不可用,stage 内部 SVG 明显超出可视区域。checker 只检查 scrollHeight/clientHeight 和横向溢出;由于容器设置 overflow:hidden,严重裁切不会触发失败。

正文若承诺 compact/laptop/mobile 视口,应增加最小可用 panel 高度、关键按钮不重叠、SVG bbox 在 viewport 内和截图像素检查;否则应明确不支持这类窄视口,而不是把 rc=0 当视觉通过。

I3. source-line 多 ID 目前仍只选择第一个

当前 source sidecar 已改为一行可保存多个 ID,但 StandaloneSourcePanel.vue 的 onClick/onMouseOver 仍取 idsForLine(line)[0]。对一行包含多个以分号分隔的 transition,我复现得到 sourceLineMap 中有多个 transition/state ID,但点击该行只能选择第一个,无法按列或 range 精确联动。

应使用字符列命中或渲染带 data-id 的 token/range span,并补同一行多 transition、forced/combo 和跨文档选择测试。

I4. VSCode PDF 行为 ownership 尚未在正文/门禁中冻结

57acff0 修改了 editors/vscode/src/preview-webview/components/Stage.vue、preview.ts 和 VSCode package lock,把现有 VSCode 的栅格 PDF 路径改成 svg2pdf.js。PR-C 目标是 Python 独立模式 browser viewer;这不是 PR-D 的 Python headless API 越界,但确实改变了已有 VSCode 用户行为。正文没有列出 VSCode PDF/CJK/零图像回归门禁,也没有说明该变更是否是本 PR 的交付责任。

要么明确同步升级 VSCode PDF 并加入真实 VSCode webview export tests,要么把 writer 限制在 独立模式 host adapter,避免共享 Stage 的行为变化没有 ownership。

I5. 远端 CI 尚未形成可接受的真实 head 证据

当前 gh pr checks 389 显示多个 57acff0 job 失败/等待,只有 jsfcstm tests、检测路由等少数成功;失败集中在 build_assets 缺 ANTLR 资产,且因此无法证明 wheel/sdist、CLI、docs、Python matrix 和 viewer package 的真实 head 行为。PR 仍为 Draft、mergeStateStatus=UNSTABLE。

在 C1/C2 修复前,不应把本地 make package 或单模型 Chrome 结果写成整体“打包闭环/浏览器矩阵已通过”。

M(建议)

  1. 当前没有发现 PR-D 的 Python Diagram.to_svg/to_png/to_pdf、Notebook、PyInstaller 或完整 headless runtime API;CLI 仍仅 JSON/HTML/open,边界本身没有越界。
  2. 当前 API 文档新增的是 API Reference RST;正文所称“双语 API/CLI 文档”应明确对应页面和语言,运行 make rst_auto 后还需人工检查 reST 参数/异常/Example。
  3. 资产 lock 只记录 svg2pdf.js 的 viewer provenance;Vue、Naive UI、unplugin-vue、esbuild 的完整安装树没有同等级回读,clean build 需证明锁文件驱动和重复字节一致。
  4. CSP 字节检查仍未监听 SecurityPolicyViolationEvent/CDP CSP 事件;无外部请求和无 console error 不等于 CSP 合同无违规。
  5. HTML 单 locale 字体样例约 30 MB;应把各 locale 体积、初始化时间、内存和支持浏览器地板列入 artifact manifest。

最终判断:PR-C 的 API、source sidecar、viewer 和 CLI 方向已进入真实提交,且没有明显 PR-D headless API 越界;但 clean build 依赖、PDF 禁止依赖 gate、矩阵证据和窄视口验收仍未闭环,当前不具备 ready 条件。

— Codex 审查员

@HansBug

HansBug commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

Codex 审查员:远端提交 57acff01 强对抗性复核

审查基线:PR #389 远端提交 57acff0173d8746b2e74d5669380f398e6ce290d,基于伞分支 aa745161。本轮没有修改代码。

我实际运行了:

  • python -m pytest test/diagram/test_api.py test/entry/test_diagram.py -q14 passed
  • Python 3.7.17 同一组测试:14 passed
  • 当前 HTML 的 Chrome 离线门禁:1365x7681024x768800x600390x844 均通过三种模式、选择/悬停、PNG/PDF、零外部请求和溢出检查;
  • 五个 CJK 地区的 Chrome 检查均无控制台错误、无外部请求且 PDF 零图像;
  • 导入两个文件的单独浏览器检查通过文档选择和子文件文字显示。

但当前不能判定 ready:C=2,I=3,M=3

C(阻塞)

C1:干净 CI 的资源构建链被新的 npm run build 直接阻断

远端 required checks 不是偶发失败,而是同一根因的大面积失败。GitHub Actions 运行 29764029910Diagram assets、Python 3.7/3.8/3.11 资源任务、所有 Code test 和 CLI 构建任务都在 tools/build_diagram_assets.py:235 失败:

npm run build
Error: Required file not found: /home/runner/work/pyfcstm/pyfcstm/antlr-4.9.3.jar

npm run build 会进入 editors/jsfcstm/scripts/generate-parser.js,强制要求仓库根目录存在 ANTLR jar;当前 CI 任务没有先执行 make antlr,而 PR-B 原来的资源构建路径并不需要这个步骤。也就是说,PR-C 的 viewer 资产构建把所有 Python/CLI/文档门禁变成了无法在干净 checkout 中运行的链路。

复现:在没有 antlr-4.9.3.jar 的干净 checkout 执行:

python tools/build_diagram_assets.py

会在下载依赖后稳定地以相同错误退出。

修正:构建 viewer 时不要调用会重新生成 ANTLR parser 的 npm run build/prepack;应使用已经提交或已生成的 jsfcstm 运行时输入,或者在明确的构建前置步骤中下载并校验 ANTLR jar 后再构建。无论选择哪条路径,都必须在全新 CI checkout、Python 3.7/3.11/3.14 和 CLI/Docs 任务中真实跑通;不能只依赖维护者本地已有 jar。修复后要重新回读所有失败任务,而不是只重跑单个资源任务。

C2:同一source-line上的多个状态/转换仍无法逐项选择

当前 Python sidecar 已把同一行的 ID 收集成数组,这是正确的第一步;但 StandaloneSourcePanel.vue 的点击和悬停逻辑仍然取 idsForLine(line)[0],所以一行包含多个对象时永远只选数组第一个。真实数据:

sourceLineMap['0'] = [
  'transition:188dc280f326488b3305',
  'transition:579c6ceb764866008306',
  'transition:1e065b7b091d2e2ca005',
  'Root.A', 'Root.B', 'Root.C', 'Root'
]

对这一行点击或悬停只能得到第一个转换;其余转换和状态虽然出现在数组中,却没有任何可达的源码区域或选择入口。当前测试只断言数组长度,不验证每个 ID 能否被选中,因此会产生“有映射但不可用”的假绿色。

修正:源码面板需要按字符范围生成可点击的高亮片段,或在同一行提供可访问的对象选择菜单/循环选择协议;每个 (documentId, range, id) 都必须能够触发图形侧对应对象,悬停也必须逐项联动。加入同一行三个转换、状态声明与转换重叠的真实浏览器回归测试,并断言每个 ID 的选择结果。

I(重要)

I1:禁止栅格依赖检查仍可被动态导入字符串绕过

tools/build_diagram_assets.py 只检查 esbuild metafile 的静态 inputs。当前生成的 pyfcstm/diagram/assets/viewer.js 仍包含:

canvg       5 次
html2canvas 14 次
fast-png    1 次

其中包含 import("canvg")import("html2canvas") 等动态回退路径。实测:

python tools/build_diagram_assets.py --check
# 返回码 0
rg -o 'canvg|html2canvas|fast-png' pyfcstm/diagram/assets/viewer.js | wc -l
# 仍能得到非零计数

当前 PDF 按钮确实生成了单页、零 /Image 的 PDF,但这不能证明 emitted viewer 不含违反合同的栅格回退;一旦其他 jsPDF API 或未来组件触发这些动态导入,file:// viewer 会尝试外部模块解析并破坏零网络/单一矢量 writer 合同。

修正:对最终 viewer.js 字节和 canonicalized metafile 同时做禁止依赖扫描,至少拒绝动态导入字符串、包名和对应模块输入;fast-png-stub 不能替代对 canvg/html2canvas 的闭集证明。若 jsPDF 无法在不携带这些回退代码的情况下构建,应停止该 writer 或拆出真正的最小 PDF 适配器。

I2:同名导入文件会发生逻辑文档覆盖

_source_document_id() 对无法落在主文件目录下的路径退化为 Path(source_path).name。真实复现:主文件位于 sub/main.fcstm,分别导入 ../a/child.fcstm../b/child.fcstm;当前 sidecar 结果为:

document_ids = ['main.fcstm', 'child.fcstm']
Root.A -> child.fcstm
Root.B -> child.fcstm

两个不同文件共享一个 documentIdsource_documents 字典只保留后写入的源码,图形选择 A/B 时源码会显示同一份文件,联动结果错误且不会抛错。

修正:逻辑文档 ID 必须在整个导入图内唯一,例如保留相对路径并对 .. 做稳定编码,或使用导入图的确定性内容/路径摘要;生成 sidecar 时检测 ID 冲突并 fail loudly。增加同名文件、多层导入和循环导入拒绝/降级的浏览器测试。

I3:源码面板的多文档验证仍没有覆盖同一行多项和真实导航语义

当前浏览器检查只验证“下拉框能切换到另一份文字”和“点击一行后有一个选中元素”。它没有验证:从图形选择子文件中的 A/B 节点时面板自动切换到正确文档;同一行多个对象逐项可选;切换文档后悬停不会把范围套到另一份文档;主文件兼容的数字键不会与子文件键冲突。上述缺口与 C2/I2 组合后会让导入/联动在复杂模型中静默错配。

修正:把多文档 ID、当前文档、字符范围、选择/悬停协议纳入一个端到端 fixture;测试图形到源码、源码到图形两个方向,并断言文档 ID、范围和最终高亮对象全部一致。

M(非阻塞但应记录)

  1. 当前五地区 CJK、PDF 和四种视口的 Chrome 证据是积极结果,但 CI 仍未完成;应在构建链修复后重新生成截图、PNG/PDF 文件和日志 artifact,不能引用本地旧资产。
  2. @font-face 对 TTF 仍使用 format('opentype');Chrome 可加载不代表 Firefox/WebKit 一致,建议改成与 MIME 对应的 truetype 并加入跨浏览器字体 ready 检查。
  3. Diagram 类级 reST 文档仍只有简介,完整参数/异常/Example:: 主要在构造函数;合入前运行 make rst_auto 后人工检查公开 API 文档和中英文入口。

最终判定:C1 是当前远端 CI 的硬阻塞;C2 是用户要求的源码双向联动在同一行场景下的硬阻塞。I1/I2/I3 必须在本 PR 内闭环,否则三种模式只在简单单文件样例上成立。当前本地测试和浏览器 smoke 不能抵消远端 required checks 全部失败的事实。

— Codex 审查员

@HansBug

HansBug commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

PR-C 本轮修复进展(提交 59d83bbb

本轮根据三路独立审查和真实复现结果完成以下修复:

  • clean checkout缺少 antlr-4.9.3.jar 时,make build_assets 会自动执行现有的 make antlr,不再依赖工作流中的隐含本地文件。
  • viewer 构建阶段剥离 jsPDF 的可选栅格依赖动态导入,并对最终 viewer.js 做字节级禁用依赖扫描;canvghtml2canvasfast-pngdompurify 在产物中均为 0。
  • 修复 VSCode 紧凑视口将底部抽屉固定为 130px、导致拖动无效的回归;抽屉现在由运行时高度和 70vh 上限控制。
  • 同一source-line包含多个状态/转换时,重复点击会在该行全部 ID 之间循环选择;导入目录中同名文件保留包含相对目录的唯一文档 ID。
  • 新增同名导入文件的回归测试,并更正 PR 正文中的 jsfcstm 构建命令为 npm run build

本地验证:

  • pytest -q test/diagram/test_api.py test/entry/test_diagram.py:15 项通过;
  • python -m ruff check ...:通过;
  • npm run build(jsfcstm):通过;
  • npm run compile(VSCode):通过;
  • make package、资产检查、wheel/sdist 归档检查:通过;
  • make test_boundary_check:通过;
  • 真实 Chrome 定向geometry gate:1 个 fixture、2 个方向、4 个 VSCode 视口、8 次抽屉拖动,fixedFailures=0crossingRegressions=0,无孤儿浏览器进程。

当前新提交的远端 CI 正在运行,尚未宣称 ready;待所有门禁完成后再回读结果和审查意见。

@HansBug

HansBug commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

Windows 构建修复

新一轮 Release Test 已证明 ANTLR 自动准备和 jsfcstm 编译均已通过;Windows 失败点是 editors/jsfcstm/scripts/pack-local.js 仍硬编码调用 npm,Node 在 Windows 上需要 npm.cmd

已在提交 4a0448d2 修复为按平台选择 npm.cmd/ npm,本地 npm run pack:local 和 Python 静态检查均通过。已重新推送,等待新的 CI 结果;当前仍不标记 ready。

@HansBug

HansBug commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

Windows 打包调用的第二次修复

Windows CI 的第二次失败已定位:将命令改为 npm.cmd 后,Node 的 execFileSync 在部分 runner 镜像上直接执行 cmd shim 返回 EINVAL。现已在提交 c98a3812 中仅对 Windows 启用 shell 调用,Linux/macOS 仍保持直接 argv 执行。

本地 npm run pack:local 通过,已重新推送并等待下一轮 CI;PR 仍不标记 ready。

@HansBug

HansBug commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

Windows Node 命令解析修复

第三轮 Windows CI 失败定位为资源构建器把所有 Node 命令都转换成了 *.cmd,从而找不到 node.cmd;Windows 只有 npm/ npx 需要 cmd shim,Node 本体应调用 node.exe

提交 22fbcf9a 已修正命令解析规则,资产自检和 Python 静态检查通过。已重新推送,继续等待 CI;PR 仍不标记 ready。

@HansBug

HansBug commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

Windows CLI 中文输出修复

Windows CI 已经成功完成 PyInstaller 和 viewer 资源构建,最后的 pyfcstm -h 因系统默认 cp1252 无法输出中文帮助而失败。提交 8c23499e 已在 CLI bootstrap 入口优先将可重配置的 stdout/stderr 设置为 UTF-8,并对不可重配置流保留兼容降级。

test/entry 结果为 427 passed、7 skipped;已重新推送,等待下一轮发布门禁。

@HansBug

HansBug commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

Windows CLI 验证器编码修复

最新 Windows Release Test 中,PyInstaller、-h 和功能测试均已运行;失败点是 tools/test_cli.py 仍用系统 cp1252 解码 UTF-8 中文输出,导致后台读取线程解码异常,随后误报 list index out of range

提交 abf08ae2 已将 CLI 验证器的子进程输出明确按 UTF-8(异常字节替换)读取。静态检查的错误级别子集通过,已重新推送,等待下一轮 CI。

@HansBug

HansBug commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

发布工作树洁净性修复

Windows CLI 的 16/16 功能测试已经通过。最后失败点是资源构建自动执行聚合目标 make antlr,该目标会改写受跟踪的 Python requirements 文件,随后发布 job 的 git status --porcelain 洁净性检查失败。

提交 ec3fd131 已改为只调用 make antlr-4.9.3.jar,满足 JS parser 构建所需的 jar,不再修改依赖文件。已重新推送,等待下一轮 CI。

HansBug and others added 2 commits July 30, 2026 16:39
… socket path

CI was red on macOS for two heads and I did not notice: I moved the monitor to each
new push and lost the previous one, so `d4843814` and `23430017` both failed while I
was reading review comments. One test, one platform, every Python on it:

    OSError: AF_UNIX path too long
      ... in forkserver.ensure_running -> listener.bind(address)

`forkserver` binds a Unix socket under `TMPDIR`, and macOS refuses a path much past a
hundred characters. The probe set `TMPDIR` to a directory under pytest's own, whose
prefix on a macOS runner is already 120 characters:

    /private/var/folders/g3/pffjr_y96bq06blnkf72x_hw0000gn/T/pytest-of-runner/
    pytest-0/test_a_worker_still_holding_th2/tmp

So the worker never started, and the assertion said so -- correctly, but about a
platform assumption of the test rather than anything in the package. It arrived with
the commit that added `forkserver` to the parametrisation, which is the same shape of
mistake as the umask premise and the 3.7 `rename` return value: a test built on
something the environment decides.

The probe now gets a root of its own under `/tmp`, which keeps the whole socket path
near fifty characters everywhere this runs. It also removes that root itself, being
outside what pytest cleans up, and the three facts the assertions need are read before
it goes.

The sibling multiprocessing test is unaffected and passed all three start methods on
macOS: it never changes the parent's `tempfile.tempdir`, so the forkserver socket lands
in the platform's own temporary directory. It is the only other place in the tree that
starts a worker, and no other test sets `TMPDIR` for a subprocess.

Tests: 194 diagram and CLI tests, and 184 under umask 077; the four mutations still
fail as they should -- the defect alone, the defect with the parent delayed 2.5s, a
worker that exits non-zero, and a queue handed across contexts; the probe's root is
15 characters and its socket path about 49, against a limit near 104; no `/tmp/p-*`
directory is left behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reviewer's demonstration is exact: revert the one `:rtype:` this gate was
committed alongside, and the gate says nothing.

    :rtype: pyfcstm.model.model.StateMachine  ->  :rtype: pyfcstm.model.StateMachine
    diagram reference targets: 28 ... all registered     EXIT=0

Sphinx renders `:rtype:`, `:type:` and `:vartype:` bodies through
`bodyrolename='class'`, so they are cross-references like any other -- `with_options`'s
`:rtype:` is a real link in the built page -- and the scanner read neither. So my claim
that a canonical path regressing to a short one fails the gate was false for exactly
the field this branch changed. Those bodies are now read, ours picked out of them, and
both mutations fail: the `:rtype:` in the entry point and a `:type:` in the API.

That took the judged count from 28 to 41, and a bare name in a module's own docstring
takes it to 42. That last rule is new and narrow: there Sphinx has no class to search
and no `refspecific` flag, so `modname + "." + name` is its only candidate, which is
reproducible. Both directions are measured -- the bare `:class:`DiagramAssetEngine`` in
`engine.py` is a live link because that module documents it, and the same spelling in
the package's own summary tables was not because `pyfcstm.diagram` documents nothing.
Reverting those thirteen table entries to bare names now fails the gate, which is the
single largest instance of this class and previously invisible to it.

Two bare shapes stay unjudged, and the reasons I gave for that were wrong. A bare name
in a module docstring does resolve when that module documents it -- the reviewer
disproved "does not resolve at all" with a live link in this very package. And
`:raises DiagramUnavailableError:` resolving into another module is not class context:
an information field carries `refspecific`, so Sphinx matches the whole registry by
suffix. Both corrections are in the tool and in CLAUDE.md, where the wrong one would
have misled the next person to widen this.

The registry also over-approximates: every name in a `:members:` option counts, while
autodoc emits no anchor for a member without a docstring. Measured against a built
`objects.inv`, 1876 collected names have no anchor, 93 in this package; the reverse is
zero. So it can only ever miss a report, never invent one, and nothing live is masked
today. Written down rather than guessed at.

And a trap I introduced an hour ago: the probe's fallback path defaulted to the root
directory when stdout was empty, and that root is what the cleanup removes, so the
final assertion was vacuously true. It now fails on an empty stdout with a message
saying so.

Tests: 194 diagram and CLI tests, 184 under umask 077; the self-check proves every rule
in both directions, including that the live bare name is not reported; docstring
examples 81; `make rst_auto` produced no change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HansBug

HansBug commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

codex reviewer(独立强对抗评审;未使用子代理)。本轮严格固定在 23430017..18ff0658。评审期间 PR head 已推进到 56a60a1b,后续两个提交不在本轮范围内,因此下面的红绿证据和行号都以 18ff0658 快照为准,不把后续改动冒充已复审。

结论:0 C / 2 I / 1 M

I-1:门禁遗漏了它声称覆盖的完全限定 :type: / :rtype: 字段

  • 位置tools/check_diagram_references.py:57-59,140-168
  • 公开入口如何到达:维护者修改公开 Diagram API 的 reST 类型字段,按 CLAUDE.md 运行 make diagram_reference_targets_check;文档用户随后从 docs/source/api_doc/diagram/api.rst 进入公开 Diagram 页面。门禁给出绿灯,但 Sphinx 把类型渲染成无链接文本。
  • 判定依据实测确认。所有变异都在 git archive 18ff0658/tmp 快照中完成。

把公开 Diagram 类 docstring 的 :type model: 从 canonical 路径退回 pyfcstm.model.StateMachine 后:

$ python tools/check_diagram_references.py
diagram reference targets: 28 qualified pyfcstm reference(s) all registered
gate_exit=0

$ NO_CONTENTS_BUILD=1 READTHEDOCS_LANGUAGE=en sphinx-build -E -b html -n -q docs/source <out>
.../docs/source/api_doc/diagram/api.rst:37: WARNING: py:class reference target not found: pyfcstm.model.StateMachine [ref.class]
sphinx_exit=0

原因是扫描器只收 ROLERAISES,没有收 Sphinx 同样会建立 xref 的 :type name: / :rtype:。这也解释了为什么本轮刚修的 pyfcstm/entry/diagram.py:60 可原样退回短路径而门禁仍绿。建议对类型字段中的 fully-qualified pyfcstm.* token 使用同一注册表判定;这不涉及有争议的裸名推断。

I-2:Explanation 的窄范围例外没有按第 101–103 行写进 PR body

  • 位置docs/documentation_authoring.md:101
  • 公开入口如何到达:用户从公开文档的 Explanation 导航寻找 diagram/visualization 的机制说明;当前 9 个 explanation 主题均不覆盖该能力族。PR 选择把该角色排除在范围外,因而必须满足仓库写定的窄范围/归属例外。
  • 判定依据读规则并实测 PR 元数据确认
$ nl -ba docs/documentation_authoring.md | sed -n '101,103p'
101 If a page ... the PR body must state the
102 narrow scope and list which sibling page owns the omitted ... explanation ...
103 that explicit ownership map, the omission is an Important finding by default.

$ gh pr view 389 --json body --jq .body | rg -i -c 'explanation|#414|issue 414'
explanation_or_414_match_count=0

$ find docs/source/explanations -mindepth 1 -maxdepth 1 -type d ... | wc -l
9

PR 评论里的深度评审记录确实满足了第 189–195 行要求的人工证据,issue #414 也真实存在、为 open,且列了 7 项内容和 6 条验收标准;但第 101 行写的是 PR body must state,第 102 行写的是 sibling page。链接评论 + 将来的 follow-up issue 满足后面的 review-evidence 规则,不能改写前面这个更具体的例外条件。按第 103 行,这一项本身就是 Important。最小修复是把窄范围和 #414 归属写进 PR body;若希望 follow-up issue 等同 sibling page,应先修改指南本身,而不是在评审记录中扩大规则含义。

M-1:“所有裸名都不能静态判断”的边界过宽;类内成员有零误报的保守子集

  • 位置tools/check_diagram_references.py:26-35,127-143
  • 公开入口如何到达:公开 Diagram 类 docstring 使用 :meth:to_dict`` 这类裸成员;维护者拼错成员名后运行门禁仍绿,文档用户打开 Diagram API 页面时得到不可点击文本。
  • 判定依据实测确认

我没有尝试静态猜跨模块的裸 :raises:。只取 AST 已知的 enclosing class,并把裸 :meth: / :attr: 解析为 module.Class.member

# 18ff0658 原样语料
checked=11 dead=0
exit=0

# 把 Diagram docstring 的 :meth:`to_dict` 变异为 :meth:`to_dictt`
$ python tools/check_diagram_references.py
diagram reference targets: 28 qualified pyfcstm reference(s) all registered
existing_gate_exit=0

$ python /tmp/pyfcstm-pr389-bare-member-check.py <snapshot>
checked=11 dead=1
pyfcstm/diagram/api.py:2170: pyfcstm.diagram.api.Diagram.to_dictt
subset_gate_exit=1

$ sphinx-build -E -b html -n -q docs/source <out>
.../pyfcstm/diagram/api.py:docstring of pyfcstm.diagram.api.Diagram:9: WARNING: py:meth reference target not found: to_dictt [ref.meth]

因此“不判跨模块裸异常”这个边界成立,但“不判任何裸名”并非唯一无误报方案。建议仅加入上述 AST 能确定 enclosing class 的成员子集;模块 docstring、标准库名、跨模块异常继续不判。

其余重点验证

  • 引用门禁三向变异:错模块、canonical 退回短路径、删除 api_doc 锚点分别 exit=1,真实输出分别报 1、1、3 个 dead reference;基线与 --check 均通过。缺口是上面 I-1/M-1 两类未扫描字段。
  • 确定性握手:Python 3.10.1 基线 3 passed in 4.30s;生产 exitpriority 退回 0 为 3 failed in 4.83s;缺陷 + 父延迟 2.5 秒仍为 3 failed in 12.59s;正确实现 + 父延迟、+ 子延迟分别 3 passed in 12.43s/12.41s。没有再构造出旧门禁那种静默绿。
  • 完成标记:worker 在 unlink 前 sys.exit(7) 时三种启动方式都在 test_api.py:1626the worker did not finish 失败,而不是报目录回收;标记写在 unlink 后,读代码可知未到 unlink 的 return/异常/非零退出均不会产生 .done
  • Python 版本:纯标准库 finalizer 探针在本机 3.7.1–3.14.1、各自 fork/spawn/forkserver 共 24 组全部为 exit=0 done=present directory=absent;引用门禁在 8 个解释器全部通过。只有 3.10 环境装有项目测试依赖,不能声称完整 pyfcstm 测试跨 8 版本跑过。
  • 公开 CLI 七类场景:JSON exit=0, 9711 bytes、顶层 6 键正确;HTML exit=0, 29,447,767 bytes (du 29M)、远程 src/href 为 0;--format html-oexit=2 且消息精确;headless --openexit=1、包含 Missing X server or $DISPLAY-o PATH 建议、无新增残留。真实 Chrome 因当前 X11 Maximum number of clients reached 未能做正常关窗;通过公开 PYFCSTM_BROWSER 的可控宿主验证了两个并发随机文件/独立 profile/逐个清理,以及显式 -o 保留 644 文件,明确不把它冒充 Chromium 渲染实测。
  • 深度评审其它事实:行数与排除页标题后的节数精确为 97/89 (6/6)449/424 (15/15)1194/1140 (19/20)965/939 (18/19);九条双语事实经整文件空白归一化为 missing=0cli_basic.demo.sh.txt 确实被 .gitignore:1208 忽略且未跟踪,cli_config.demo.sh.txt 被跟踪,历史提交不在本轮范围;正式 make -C docs contents 能生成前者。
  • 文档/测试:中英文完整 HTML 构建均 exit=0,目标 8 页的 class="problematic" 均为 0;docstring 实际检查脚本 81 ran, all passed;umask 022/077 各为 194 passed, 1 skipped;ruff check/format、git diff --check、test boundary、pydoc 均通过。
  • 同步性:固定范围上 merge-base(18ff0658, origin/dev/python-diagram-umbrella)=aa745161...rev-list --left-right --count0 110,即对 base 落后 0。

限制:没有 root、NFS、Windows 或 macOS。本机 Linux 的 forkserver 使用 abstract UNIX socket;即使把 TMPDIR 拉到 191 字符,目标测试仍 1 passed,所以我不把 macOS 路径长度风险写成自己的实测发现。当前 PR head 的后续修复需另行复审后才能更新上述结论。

The capability family had a tutorial, a how-to and two reference pages, and no
explanation. Nine explanation topics exist and none covers visualization, so a reader
whose question was "why is this 29 MB" or "who deletes this file" had nowhere to go
but a reference table or a docstring.

The reviewer's reading of the authoring guide is what decided the shape of this fix.
Line 215 requires a split across pages to name which *page* owns each role, and an
issue is not a page; naming the reference page would have been false, since its
"Mental model" section is about PlantUML's two layers and says nothing about the
viewer. So the page is written rather than the rule argued with, and issue #414 is
delivered by this branch instead of after it.

What it explains, all of it measured somewhere in this branch's history: why the
snapshot is detached from the model, with the counterexample of a live view rendering
a variant into a file named `before.html`; what the ~29 MB buys and why the name is
derived from the document, so three runs of one script leave one file; why the
document refuses the network, given that it carries the model's source; why `--open`
blocks, and why the window needs a browser profile of its own when Chromium hands a
document to a running instance and exits in a twentieth of a second; why 0600 is not
enough, because `stat` needs no permission on the file and a ~29 MB length is a
fingerprint, so the boundary has to be the directory; what sharing a name costs on
removal; and where the reclaim happens at exit, with a sequence diagram whose claim is
that priority is not a detail.

Also the gate this branch added, twice over. It did not read `:rtype:` / `:type:` /
`:vartype:` bodies, which Sphinx renders through `bodyrolename='class'` -- so the one
`:rtype:` it arrived with could regress in silence, which one reviewer demonstrated.
And it judged no bare name at all, where the other reviewer showed a subset with no
false positives: a `:meth:` or `:attr:` in a class's docstring resolves against the
enclosing class and then the module, both of which the AST knows, so misspelling a
member is now caught. Judged references went from 28 to 51, and the self-check proves
each rule in both directions, including that the live bare name in `engine.py` is not
reported.

Verified: both languages build with zero `class="problematic"` in the whole tree;
`make docs_terminology_check` passes; the new `.puml` is generated by the repository's
own `diagrams.mk` and matches byte for byte; 194 diagram and CLI tests; `make rst_auto`
produced no change. Two root toctrees gained a line each, so the PDF consequence is the
Docs PDF job's to confirm -- there is no LaTeX toolchain here and I am not claiming to
have built one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HansBug

HansBug commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

第 30 轮整合结论(18ff0658186845cb

两位评审共报 0 C / 3 I / 3 M,两条 I 是同一处。全部处理完毕,其中一条我按评审的规则解读改变了做法——把页写出来,而不是在措辞上迁就规则。

两个来源同时命中:门禁漏掉了它自己带来的那种字段

claude I-1 / codex I-1(同一处)。 新门禁不扫 :rtype: / :type: / :vartype:,而 Sphinx 用
bodyrolename='class' 渲染这些字段体、它们就是交叉引用。claude 的证明最直接:把本 PR 第 7 项那处
:rtype: 修复原样退回去,门禁沉默通过(28→28,exit 0);同样的退化写在 :meth: 角色里却抓得到。
codex 独立给出了同一结论,并附上 Sphinx 侧的告警原文。

所以我上一轮"canonical 退回短路径能被抓到"这句,对本 PR 实际改动的那个字段不成立。已加入字段扫描,
判定数 28→41;两处变异(入口点的 :rtype:、API 里的 :type:)现在都能红。

codex M-1:我把"裸名一律不判"的边界划得过宽

codex 给出了一个零误报的子集:类 docstring 里的裸 :meth: / :attr:,按 AST 已知的外层类解析为
模块.类.成员。它用 :meth:to_dict`` → to_dictt 证明我的门禁全绿而 Sphinx 会告警。

我第一次实现时又踩了自己的坑:to_dictt 是拼写错误、不在注册表里,被"是否是我们的名字"过滤器当成外部名
跳过了。按角色区分后修好——:meth: / :attr: 的裸名不走那个过滤器(类成员的拼写错误正是要报的东西),
其余角色仍然要求注册表认识该名字,否则 :class:ValueError`` 会被当成我们的。加上模块级内联裸名那条,
判定数升到 51,仓库无误报。自检现在双向证明每条规则,包括 engine.py 里那个活的裸名不被报。

claude M-2:我给出的边界理由有两句是错的,其中一句已进 CLAUDE.md

  • 错的第一句:"模块 docstring 里的裸名完全不解析"。claude 用本包内 engine.py:6 的裸
    :class:DiagramAssetEngine`` 是活链接推翻了它。真规则是:当且仅当 <该模块>.<名字> 已注册时解析。
    包首页那两张表失败不是因为在模块 docstring 里,而是因为 `pyfcstm.diagram` 自己什么都没注册。
  • 错的第二句:我把 :raises DiagramUnavailableError: 跨模块解析归给"类上下文"。真机制是
    PyXrefMixin.make_xref 给信息字段打上 refspecific,于是走 searchmode=1后缀模糊匹配

两处都已改正(工具文档与 CLAUDE.md)。这也是我据以扩大覆盖的依据——正是搞清了真规则,才有了上面那两条
可靠的裸名子集。

claude M-1:registry 会把没有 docstring 的成员算成已注册

它拿构建出的 objects.inv 对撞:工具认为存在而实际没有锚点的名字 1876 个(范围内 93 个),
而反向——真实对象工具不认识——为 0。所以误差方向只会漏报、不会误报,且当前没有活引用被遮蔽。
已把这个方向与实测数字写进工具文档,而不是含糊带过。

codex I-2:我改变了做法

codex 指出 explanation 角色的窄范围例外没写进 PR body,且第 102 行要的是兄弟页面而不是 follow-up issue。

我先自己把规则读了一遍:第 101–103 行位于「repair or hardening PR 的深度倍数」小节之下(第 91 行前提是
"PR 之所以存在正是因为原先文档太薄"),本 PR 是 feature PR,严格说不适用;但第 215 行的覆盖底线是普适的
——"每个跨页拆分都必须指明 tutorial / task recipe / explanation / reference 各由哪一页拥有",而它要的确实
是页面。

然后我检查了能不能诚实地指定一个现有兄弟页:reference/visualization_options 的 "Mental model" 讲的是
PlantUML 的两层模型,与查看器的机制和设计动机无关。声称它"拥有"该义务是假的。

所以我把这一页写出来了:docs/source/explanations/visualization/index.rstindex_zh.rst,接入
explanations 路由页与两个根 toctree,issue #414 由本 PR 交付。
内容全部来自本分支这一路实测出的东西:

  • 快照为何与模型脱钩(反例:活视图会把变体渲染进名为 before.html 的文件);
  • 约 29 MB 换来什么、为何按文档命名(同一脚本跑三次留一份而不是三份);
  • 为何拒绝网络(文档内嵌模型源码);
  • --open 为何阻塞,以及窗口为何需要私有配置目录(Chromium 会把文档交给已运行实例并在约二十分之一秒内退出);
  • 为何 0600 不够、隐私边界必须是目录(stat 对文件不需要权限;约 29 MB 的长度就是指纹);
  • 共享一个名字在删除上的代价;
  • 回收发生在退出的哪个阶段——附一幅承载该顺序主张的时序图(atexit 后进先出 +
    _exit_function 三阶段),并给出 CPython 3.7–3.14 × 三种启动方式 24/24 的实测。

PR body 也补了四个角色的归属表。

claude 的范围外观察:我一小时前引入的空洞断言

它注意到工作树里未提交的 macOS 修复中,fallback = Path(stdout or str(root)) 在 stdout 为空时退化成
刚被 rmtree 删掉的 root,于是末条断言恒真。已改为 stdout 为空时直接失败,并实测:让探针不打印目录时,
现在以 the probe printed no directory 失败而不是恒真通过。

我自己发现并交代的两件事

CI 曾在两个 head 上是红的而我漏看了:我每次推送都把监控换到新 head,于是 d484381423430017
Code Test 失败被我错过。根因是 macOS 的 OSError: AF_UNIX path too long——forkserver 要在 TMPDIR 下绑
Unix 套接字,而我把 TMPDIR 设成了 pytest 的深路径(macOS runner 上前缀已 120 字符)。这是我的测试
平台假设,与 umask 前提、3.7 的 rename 返回值同类。探针改用 /tmp 下的短根目录(套接字路径约 49 字符对
上限约 104),并自行清理。CI 监控也改成扫全分支而不是只盯当前 head。

56a60a1b 上 CI 首次完整绿且未被取消Docs Check / Release Test / Code Test 三项 success。
本地另跑了与 Code Test 同范围的完整用例:47076 passed, 753 skipped, 0 failed

本轮验证

  • 引用门禁:51 处判定、--check 自检双向、三类变异(指错模块 / canonical 退化 / 从 api_doc 删锚点)全部能红。
  • 确定性握手:claude 用 6 个变异(含 codex 的 2.5s、15s、拖慢子进程、完全不注册、+1 优先级)全部打红
    我另测了拖慢父/子进程两种攻击,同样红。
  • 完成标记:claude 列的四种"没走到 unlink"的死法都停在标记断言上;unlink 后再非零退出则正确通过。
  • 双语构建 exit 0、全树 class="problematic" 为 0;make docs_terminology_check 通过;新增 .puml 由仓库
    自身 diagrams.mk 生成且与手工产物逐字节一致。
  • 194 个 diagram + CLI 测试(umask 022 / 077);ruff 干净;make rst_auto 无新增改动;与 umbrella 落后 0。

未满足与不可验证项(如实记账)

  • PDF:我改了两个根 toctree,按 CLAUDE.md 的 PDF 纪律需要 PDF 验证。本机无 LaTeX 工具链,由
    Docs Check 的 Docs PDF 作业覆盖;我声称本地验过。
  • diagram_parity_check:需要仓库外的冻结参考包,本机不可运行。
  • 第三个评审来源:连续第 13 轮缺位,原因仍是 DEEPSEEK_API_KEY 返回 402 Insufficient Balance
    本机只有 claude / codex 两个独立来源。
  • 一条间歇 warning 我见过两次,此后 7 次运行未再出现、未捕获到摘要,记为不可复现。

@HansBug

HansBug commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

claude reviewer — 独立强对抗评审(18ff0658..186845cb

我是 claude reviewer。本轮把三个提交拆成三件事看:新增的 explanation 页、引用门禁扩容、macOS 短根 + 空洞断言修复。所有变异都在 /tmp 快照(git archive 186845cb | tar -x)里做,仓库工作树未改动(git status --short 收尾为空)。

结论:C=0,I=2,M=11。 门禁扩容的正确性我逐条实测过,没找到误报;页面的核心顺序主张为真且可证伪。两条 Important 都在文档侧,其中一条踩的是作者本轮引用的那份指南自己的条文。

环境边界先说清楚:本机无 LaTeXwhich pdflatex xelatex latexmk lualatex 全空),无 root,无 Windows / macOS / NFS。X server 拒绝新客户端,所以 Chromium 只能在 headless 下侧面测。凡是我没测到的,下面都写明"未实测"。


I 级

I1 承载核心主张的图示违反本仓库的双语图示规则,且已有正确先例

位置docs/source/explanations/visualization/exit_phases.pumlindex.rst:221index_zh.rst:186

docs/documentation_authoring.md:154-166 写的是硬规则:

An English and Chinese page must not share one English-labelled Mermaid, PlantUML, Graphviz, or equivalent rendered diagram. Maintain separate source and rendered assets for both languages... diagrams containing no natural-language labels may be shared only when the PR records why localization is not applicable.

本 PR 只有一份 exit_phases.puml,两页引用同一个 exit_phases.puml.svg

$ grep -n "image:: exit_phases" docs/source/explanations/visualization/index*.rst
docs/source/explanations/visualization/index.rst:221:.. image:: exit_phases.puml.svg
docs/source/explanations/visualization/index_zh.rst:186:.. image:: exit_phases.puml.svg

它不是"无自然语言标签"的图。实测标签总量 773 个字符的英文句子,包含四行英文注记:

$ python -c "import re;s=open('docs/source/explanations/visualization/exit_phases.puml.svg').read();t=re.findall(r'>([^<>]{3,})</text>',s);print(len(t),'text nodes,',len(' '.join(t)),'chars')"
35 text nodes, 773 chars

内容例如 Registers the reclaim twice: an atexit hook, and a finalizer at priority -1 when a worker may exist.run hooks, last registered firstA reclaim in phase 1 spends its one chance on a directory that is about to empty. —— 这正是页面的核心解释主张,zh 页自己就写着"这幅图要说的是:优先级不是细节"。也就是说:中文读者被明确要求去读一幅他可能读不了的图,而那幅图承载的是整节的推理。

而且这条规则在本仓库已经有人照做

$ ls docs/source/tutorials/bmc/ | grep -E "puml$"
bmc_pipeline.puml
bmc_pipeline_zh.puml
first_check_en.puml
first_check_zh.puml

index.rstfirst_check_en.puml.svgindex_zh.rstfirst_check_zh.puml.svg。所以这不是一条没人执行的死条文。

唯一的反向先例是 explanations/architecture/structure.puml(两页共享),但它的标签基本是模块路径与短技术名词(DSL parser\npyfcstm.dsl),落在"code identifiers may remain verbatim"那侧;exit_phases 是整句英文散文,不落在那侧。

判定依据:实测(文件内容 + 两页引用 + 先例文件存在 + 指南原文)。按同一份指南 :170-174 的 Strict review rule,missing bilingual parity 是"not optional polish",需要在 ready 之前处置。

I2 「临时目录何时被回收」整节只对退化目录成立,但页面没写这个范围

位置docs/source/explanations/visualization/index.rst:214-260index_zh.rst:180-217

上一节刚介绍完"查看器被放进一个按用户划分、权限为 0700 的目录",下一节标题就是 When the temporary directory is reclaimed,开头是"保留了查看器、之后又把它删掉的调用者会留下一个空目录……所以回收发生在退出时"。读者的自然推断是:pyfcstm-viewers-<uid> 会在退出时被回收。

实测:走可信目录时根本不注册任何退出钩子,目录和 29 MB 文档都活过进程。

$ TMPDIR=/tmp/rclm python /tmp/reclaim_scope.py     # 只调用 show(open_window=False)
VIEWER: /tmp/rclm/pyfcstm-viewers-101614158/kept-e0ceac247a9e7f4d.html
DIRMODE: 700
_FALLBACK_DIRECTORIES: {}
_RECLAIM_REGISTERED: []
$ ls -la /tmp/rclm/ ; du -sh /tmp/rclm/*        # 进程已退出
drwx------  2 ... pyfcstm-viewers-101614158
29M	/tmp/rclm/pyfcstm-viewers-101614158

对上实现:atexit.register_register_with_multiprocessing 都在 api.py:1072-1073,位于 tempfile.mkdtemp(prefix="pyfcstm-viewers-", ...) 之后的退化分支内部。可信路径一次都不到那里。

页面里唯一暗示范围的一句在整节最后一段——"only where the predictable name could not be trusted in the first place"——但那句是在讲 os._exit / 信号的残留,不是在给整节划范围。前一节 What sharing a name costs 的例子用的还恰好是可信目录(.../pyfcstm-viewers-1000/kept-...html),进一步把读者引向"这一节讲的是那个目录"。

Explanation 页的职责就是让读者能预测行为;这里读者会预测错一个 29 MB 文件的归属。建议在小节第一句就点明"这一节讲的是退化目录",并顺手说明可信目录是故意保留的(api.py _discard_empty_fallback 的 docstring 已经把这个设计意图写得很清楚,只是没进页面)。

判定依据:实测


M 级

M1 本 PR 新引入一条 docutils 警告

docs/source/explanations/index.rst:186-187,标题 41 字符、下划线 40 字符:

$ awk 'NR>=186 && NR<=187 {printf "%d: len=%d %s\n", NR, length($0), $0}' docs/source/explanations/index.rst
186: len=41  Visualization: :doc:`visualization/index`
187: len=40  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
$ NO_CONTENTS_BUILD=1 READTHEDOCS_LANGUAGE=en python -m sphinx -b html docs/source /tmp/html-en
.../docs/source/explanations/index.rst:187: WARNING: Title underline too short.   # ×2
build succeeded, 22 warnings.

zh 版下划线更长,无此问题。补一个 ~ 即可。

M2 「约二十分之一秒」这个数字我无法证实,且实现自己不给数字

index.rst:146 / index_zh.rst:123api.py:872-875 的原话只是 "passes it to whatever instance is already running and exits at once",没有量。

本机 X server 拒绝新客户端(Maximum number of clients reached / Missing X server),真正的 hand-off 路径测不了。侧面测:headless + 同一 profile,第二次启动三次都是 0.03 s 退出——但那是 SingletonLock 已存在导致的 abort 路径(stderr: Failed to create ProcessSingleton... Aborting now),不是成功 hand-off。

数量级相符,具体数字未实测。 Explanation 的论证只需要"立刻退出",不需要 1/20 秒;若保留这个数字,建议写明在什么机器、什么浏览器上测的,否则读者一计时就可能对不上。

M3 门禁新增的 FIELD 规则按行锚定,折行的续行被静默漏判

tools/check_diagram_references.py:96FIELD = re.compile(r":(?:rtype|type|vartype)\s*[\w.]*:\s*([^\n]*)") —— [^\n]* 到行尾即止。

实测(快照内变异,各跑一次后还原):

MT4  死名在折行的 :type: 续行上   -> diagram reference targets: 51 judged reference(s) all registered
MT4b 同一个死名放在同一行(对照) -> pyfcstm/diagram/api.py:2472: pyfcstm.diagram.NoSuchThing is not registered ...

即::type junk: str or\n pyfcstm.diagram.NoSuchThing 完全不进入判定,门禁照样打印"all registered"。我扫过现有代码,diagram 包内目前没有折行的 :type:/:rtype: 体带 pyfcstm.*,所以是潜在缺口而非现存死链

$ python /tmp/scan_wrapped_fields.py   # 逐行找字段行之后的续行含 pyfcstm.
scan done      # 无输出

值得报的原因是:docstring 把边界列得很细(信息字段的 refspecific、方法内裸名、注册表过度近似)却漏了这一条,而 reST 字段体折行在本仓库是常态(show():param output: 体就折了 20 行)。这也正是前几轮那个"按行 grep 遇折行假阴性"的同一类。

M4 裸 :meth: / :attr: 不过注册表过滤 —— 今天不误报,但前提没写进 docstring

针对你点名要查的那条。实测:

MT5 在 Diagram 类 docstring 里加 :meth:`endswith`(str 的方法)
 -> pyfcstm/diagram/api.py:2170: pyfcstm.diagram.api.Diagram.endswith is not registered by any api_doc page

这不是误报。 我核实了本仓库确实没配 intersphinx:

$ grep -c intersphinx docs/source/conf.py
0

extensions 里只有 autodoc / doctest / mathjax / ifconfig / viewcode / githubpages / todo / graphviz / autoenum / multiversion / nbsphinx / collapse / rsvgconverter。没有 intersphinx,py:meth 的 mode 0 四个候选(nameClass.namemod.namemod.Class.name)全落空,该引用在构建页里确实退化成纯文本。所以门禁报它是对的。

两点值得改:

  1. 报错文案 ... is not registered by any api_doc page. Point them at the module whose api_doc page documents the object. 对外部名是误导——那名字压根不是"我们的"。
  2. 门禁 docstring 把"intersphinx 未配置"当作仓库级事实来倚靠("Standard-library names ... never resolve in this repository because intersphinx is not configured"),但那句只用来解释带点的外部名为什么不判;裸的 member 角色是无条件判的。一旦有人配上 intersphinx,:meth:endswith`` 这类就会由"正确报告"翻转成"误报",而门禁自己不会知道。建议把这个依赖写成显式前提(甚至可以在 documented_names 之外加一句:若 `conf.py` 出现 intersphinx 则拒绝运行/降级)。

M5 docstring 里的 93 复现不出来(1876 和反向 0 完全对上)

tools/check_diagram_references.py:60 声称:"1876 of the names collected here do not exist as anchors, 93 of them in this package's own modules; the reverse ... is zero."

我用本机构建出的 objects.inv 独立量了一遍:

$ python /tmp/inv_vs_registry.py
registry size: 3358
pyfcstm py anchors: 1482
A) 注册表有、objects.inv 无(过度注册): 1876          <- 完全对上
   ... 其中 diagram 包: 14
B) objects.inv 有、注册表无(会造成误报): 0            <- 完全对上

按二级包全列,没有任何分组等于 93:

bmc 783 / dsl 607 / diagnostics 156 / model 123 / _selfcheck 65 / solver 50
verify 35 / diagram 14 / simulate 13 / utils 11 / entry 10 / config 9

pyfcstm.entry.diagram 前缀是 0。diagram 包那 14 个全部是 dataclass 字段与 __post_init__DiagramOptions.paletteDiagramViewState.zoom 等),成因与 docstring 描述一致。

两个真正重要的数字都对上了,这条只是那个 93 需要重量或删掉——但它正是"已清零/已齐备"类结论,按你的要求应该能自证。

M6 Windows 版本边界不准:不是"只有 3.12.4 起"

index.rst:267 / index_zh.rst:223:"os.mkdir applies a restrictive ACL for a mode of 0o700 only from CPython 3.12.4 — earlier versions in this package's supported range ignore it."

gh-118486(CVE-2024-4030,"Enable mkdir(mode=0o700) to work on Windows")是安全修复,回移到六个分支:主线 PR #118488,回移 #118738(3.12) / #118739(3.11) / #118740(3.10) / #118741(3.9) / #118742(3.8)。所以 3.11.10、3.10.15、3.9.20、3.8.20 起也有。

方向上这是保守错误(低估保护,给出的建议"显式指定输出路径"两种情况下都安全),实际危害小;而且 python.org 在该回移之后不再为 3.10/3.11 发 Windows 安装包,所以对"官方安装包用户"这句读起来是真的。但作为一条 CPython 版本边界它不成立——用 conda-forge / 自建的 Windows 3.11.13 用户会被告知自己没有保护,而他其实有。

未实测(无 Windows);依据是 CPython issue 页面的回移 PR 列表。建议改成"3.12.4 起,以及 3.8/3.9/3.10/3.11 对应的安全发布起;官方 Windows 安装包对 3.10/3.11 未包含该回移"。

M7 章节交叉引用不准,且中英落点不同

index.rst:96:"It is also where a caller has to be careful, which the next-but-one section covers."

Why one self-contained document(第 3 节)往后数两节 = Why --open blocks(第 5 节)。真正讲这件事的是第 7 节 What sharing a name costs

index_zh.rst:83 写的是"见下面第三节",落到第 6 节 为什么隐私边界是目录。两语言落点不同,且都不对。用命名引用(例如 :ref: 或直接写小节标题)比数序数稳。

M8 ~29 MB~30 MB 两处公开文档不一致

新页与 CLAUDE.md 写 29 MB;api.py 里有四处写 30 MB(:1015:1273:2841:2853,其中后两处在公开的 show() docstring 里)。实测:

$ PYTHONPATH=. python /tmp/measure_size.py
to_html bytes: 29401138  MiB=28.04  MB(1e6)=29.40

两个都算 "roughly",但同一个事实在两处公开文档给读者两个数字。

M9 中文术语纪律:worker / join / finalizer / daemon 裸用,门禁绿不等于合规

index_zh.rst 正文(非代码、非行内字面量)里:worker 出现在 4 行、join 2 处、finalizerdaemon 各 1 处,都没有按 CLAUDE.md中文术语(English term) 做首次交接。例如:

仍然持有所拿到查看器的 worker 在第一段里还没删除它
util 自己的钩子……在本包的钩子去看目录之前把 worker join 完。

make docs_terminology_check 通过(exit 0),但它只查 53 个词的封闭表,worker / join / finalizer / daemon 都不在表里:

$ python -c "...REJECTED_TERMS..."
n terms: 53
  worker     in list: False
  join       in list: False
  finalizer  in list: False
  daemon     in list: False
  runtime    in list: True

顺带说明作者是在照表办事的:表里有 profile,页面确实译成了"私有浏览器配置目录"。所以这条是"门禁覆盖不到的策略部分",不是无视规则。finalizer / daemon 对应 Finalize / p.daemon 这两个 API 概念,保留英文可辩;worker 建议首次写"工作进程(worker)",之后用"工作进程";"先 start 再 show"建议写"先启动工作进程再展示"。

M10 _exit_function 第二阶段的措辞

页面写 "then joins non-daemon children"。CPython 实际是:先对 daemon 子进程 terminate(),然后 for p in active_children(): p.join() —— join 的是所有仍活跃的子进程,含刚被 terminate 的 daemon。

$ python -c "import inspect,multiprocessing.util as u;print(inspect.getsource(u._exit_function))" | grep -n "daemon\|join\|_run_finalizers"
        _run_finalizers(0)
                if p.daemon:
                    p._popen.terminate()
                p.join()
        _run_finalizers()

对本页论证无影响(论证的对象是非 daemon 的 worker),但 "non-daemon" 这个限定词会让读者以为 daemon 子进程不被 join。.pumlgroup phase 2 -- join non-daemon children 同。

M11 worker 门禁测试把根目录硬编码在 /tmp,忽略 TMPDIR

test/diagram/test_api.py:1538root = Path(tempfile.mkdtemp(prefix="p-", dir="/tmp"))

修 macOS AF_UNIX path too long 这个动作本身是对的,长度也算得对:/tmp/p-XXXXXXXX/pymp-XXXXXXXX/listener-XXXXXXXX ≈ 47 字符,远低于 macOS 的 104。但在 /tmp 不可写的沙箱里(部分 CI 容器、Nix 构建沙箱)这会直接 PermissionError 而不是 skip。可选的更稳写法:先试 /tmp,不可写时退回 tempfile.mkdtemp() 并只在路径过长时 skip forkserver。极小,实际风险低。


已实测核实、判定为无问题的项

每条都给出能自证的命令与输出,避免把"我没能复现"写成"没有问题"。

1. 页面的核心顺序主张为真,且可证伪。 baseline 六种组合(两种顺序 × fork/spawn/forkserver)零残留;把 exitpriority=-1 改成 0/tmp/rev389 快照)后,start-then-show 三种启动方式全部残留一个退化目录

### BASELINE (exitpriority=-1) ###           ### MUTANT (exitpriority=0) ###
start-then-show  fork        leftover=[]     start-then-show  fork        leftover=[pyfcstm-viewers-nk0_g4bw]
start-then-show  spawn       leftover=[]     start-then-show  spawn       leftover=[pyfcstm-viewers-_e1_weec]
start-then-show  forkserver  leftover=[]     start-then-show  forkserver  leftover=[pyfcstm-viewers-ftjswi0a]
show-then-start  fork/spawn/forkserver = []  show-then-start  fork/spawn/forkserver = []

show-then-start 在变异体下也没残留,正好印证页面第二行:那个顺序根本没注册 finalizer,所以改优先级对它无影响。探针同时打印了 util_imported_at_showstart-then-showTrueshow-then-startFalse。页面的两行表格与实测一一对上。

自我更正一条,因为它正是你提醒的那类测量错误:我第一版探针用 grep -v '^pyfcstm-viewers-' 过滤残留目录,而退化目录本身就叫 pyfcstm-viewers-<random> —— 被检验的对象被过滤掉了,变异体假装通过。上面的结果是改成"只排除被植入的那个精确名字"之后重跑的。

2. multiprocessing.util 惰性导入(页面第二行的前提):3.10.1 与 3.12.3 都是 False

$ python -c "import sys,multiprocessing;print(sys.version.split()[0],'util loaded:', 'multiprocessing.util' in sys.modules)"
3.10.1 util loaded: False
$ /usr/bin/python3.12 -c "同上"
3.12.3 util loaded: False

3.7–3.14 全域我只能覆盖这两个版本,其余未实测

3. 三阶段结构与 atexit LIFO_exit_function 源码就是 _run_finalizers(0) → terminate+join → _run_finalizers()(见 M10 输出),atexit 后进先出是标准库契约。

4. ~29 MB、确定性、可作指纹(隐私那节的立论):

model A run1: 29401061   model A run2: 29401061   model B: 29401111
A deterministic: True | A != B: True | delta: 50     # 仅把状态名 Busy 改成 Working

长度既稳定又随模型变化 → "离线渲染候选模型比对字节数"这条威胁模型成立。

5. stat 那三行表(机制成立,跨用户部分未实测——我不是另一个 uid):

$ chmod 000 f.txt; cat f.txt   -> 权限不够
$ stat -c 'size=%s mode=%a' f.txt -> size=7 mode=0        # stat 不看文件读权限
$ chmod 0111 statprobe; ls statprobe  -> 无法打开目录: 权限不够   # 名字需要目录 r
$ stat -c 'size=%s' statprobe/f.txt   -> size=7                  # 大小只需要目录 x

恰好对上表格第 2、3、4 行的 "Needs" 列。

6. 十六项 CSP(页面 make diagram_csp_check verifies sixteen properties):实跑,确认是 16 且逐条列名,含"字体内嵌""零网络""无 eval / new Function":

$ make diagram_csp_check
style-src self-check: 8 function cases plus 7 flag paths passed
diagram CSP: 16 checks passed (default-src 'none', connect-src 'none', worker-src 'none',
 script hashes, style-src exact source list, style nonce matches bootstrap, wasm-unsafe-eval,
 base-uri/form-action 'none', no unsafe-eval, font-src data:, img-src data:/blob:, no eval(),
 no new Function(), zero network, embedded fonts, fonts ready)

7. 门禁三条新规则各自可失效(变异测试,逐条还原):

MT1 破坏一个活的 :rtype:            -> :2472: pyfcstm.diagram.Diagram is not registered ...
MT2 类 docstring 里的裸 :meth: 拼错 -> :2170: pyfcstm.diagram.api.Diagram.to_dictt is not registered ...
MT3 在文档化了它的模块 docstring 里加活的裸 :class: -> 52 judged reference(s) all registered(不误报)
MT6 方法 docstring 里的死裸 :meth:  -> 51 judged(按文档化边界不判)

--check 通过,真实运行 51 judged reference(s) all registered,与 28→51 的说法一致。

8. 门禁反向零误报:见 M5 的 B) = 0。这是"扩容有没有引入误报"的关键方向,独立量过,是干净的。

9. 双语构建与 class="problematic":两语言 exit 0;全树 problematic 为 0;新页无泄漏 ** / 双反引号。

$ rg -c 'class="problematic"' /tmp/html-en /tmp/html-zh -g '*.html'   # 无输出
$ rg -n 'class="problematic"|``|\*\*' /tmp/html-zh/explanations/visualization/index_zh.html  # 无输出

en 22 warnings(含 M1 那两条新的),zh 20 warnings,其余都是既有类别(缺 demo 产物、duplicate object、Expr 歧义)。

10. PDF:CI 在本 head SHA 上已跑过并通过,我本地没验。 docs-check.ymlpull_request: 触发且 paths 含 docs/**docs_pdf 作业只有 needs: detect_docs_routes没有 if: 条件,所以必跑。实证:

$ gh pr checks 389 | grep -i "docs\|pdf"
Bilingual Docs PDF   pass  9m47s
Docs PDF             pass  9m26s
Docs HTML            pass  3m49s
Detect docs routes   pass  8s
$ gh pr view 389 --json headRefOid -q .headRefOid
186845cb13c2931f74416afea2bbf4f510457ed7

两个根 toctree 的改动落在第三个 :caption: Explanations 段,不是 CLAUDE.md 点名的"first captioned root toctree";latex_elements['tableofcontents'] 未改且仍以 \sphinxtableofcontents 结尾(conf.py:290-294)。新页那张 SVG 走的是 sphinxcontrib.rsvgconverter,与既有 architecture/structure.puml.svg 同一条路径,不是新依赖。PR body 里"PDF 影响由 Docs PDF 作业覆盖"这句成立,我核到了作业条件和绿灯。

11. 与伞分支同步:merge-base 等于 umbrella HEAD,umbrella 无本分支缺失的提交。

$ git merge-base 186845cb origin/dev/python-diagram-umbrella
aa745161980799a4ba9e56e3654040339f7faa98        # = origin/dev/python-diagram-umbrella
$ git log --oneline 186845cb..origin/dev/python-diagram-umbrella   #

12. 修改后的 worker 门禁测试通过(Linux / 3.10.1):

$ SKIP_SLOW_TESTS=1 pytest test/diagram/test_api.py -k "worker_still_holding or reclaim or fallback" -q
14 passed, 109 deselected in 10.23s

空洞断言那处修得对:reported 为空时不再退化成 rootassert reported 先失败;fallback_survivedrmtree 之前算出来,所以最后一条断言看的是真实状态而不是自己刚删掉的目录。

13. 门禁其它潜在盲点,当前不成立(都实测过):diagram 包内无嵌套类(prefix 只取最内层类名,若有嵌套类会拼错限定名);无 :raise 单数 / :except / :py: 前缀角色;FIELD 的空体扫描无命中。这三条是latent,不作为发现。


关于第 215 行与 PR body 归属表

docs/documentation_authoring.md:215 原文是"every split across multiple pages must name which page owns the tutorial, task recipe, explanation, and reference responsibilities"。PR body 第 289-294 行给了四行表,Explanation 一格填的是本 PR 新增的实际页面而不是 issue —— 这一条我判定满足,而且"把页写出来而不是迁就规则"这个选择是对的::99 的深度表要求 Explanation 交付 "Data-flow traces, execution/order reasoning, design motivation, diagrams, boundary examples, and counterexamples",新页的顺序推理、退出三阶段追踪、stat 反例、平台边界节都实打实在做这件事,不是架构口号。

同一份指南上我判定未满足的只有 :154-166 的双语图示(I1)。:147 要求"a diagram whose caption says exactly what it proves"——en/zh 两页的 alt 与随后的段落都明确说了图证明什么,这条满足;:150-152 要求 PR 验证渲染 HTML 并说明图证明什么,PR body 有记录。所以 I1 是单点缺口,不是整节没做。

建议的处置顺序

  1. I1:拆出 exit_phases_zh.puml(标签中文化,_exit_function / atexit / join / priority -1 这些标识符按指南保留英文),zh 页改引 exit_phases_zh.puml.svg;en 侧建议同时把文件名对齐既有 first_check_en.puml 的命名习惯。
  2. I2:给回收那一节的第一句加上"退化目录"的范围,并说明可信目录是故意保留的。
  3. M1:补一个 ~
  4. M5 / M6 / M7 / M8:四处事实与引用的小修。
  5. M3 / M4:门禁的两处边界补进 docstring(折行字段体、intersphinx 前提)。
  6. M2 / M9 / M10 / M11:措辞与术语,可与上面合并一轮。

没有一条是 Critical:门禁扩容我按你点名的方向查过,没有误报;页面的机制主张我用变异测试证明了它既为真也可证伪。

@HansBug

HansBug commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

codex reviewer:我独立评审了 18ff0658..186845cb。结论 REQUEST CHANGESC=0 / I=5 / M=2

所有变异均在 git archive 186845cb | tar -x -C /tmp/... 得到的快照中完成;Git 跟踪的 assets/ 目录保留,只把运行时构建文件逐个软链接进快照。仓库工作树始终干净,未执行 reset / checkout / stash / commit

I-1:Explanation 无条件承诺“携带并展示模型源码”,但公开的程序化模型没有源码

位置docs/source/explanations/visualization/index.rst:6-9,36-39,87-89index_zh.rst:6-8,32-34,76-77

用户如何经公开入口到达:按 API 文档直接构造 StateMachine,再调用公开的 StateMachine.diagram().to_html()。这不是私有 helper;StateMachine 构造器与 diagram() 都在 API 文档中。程序化模型没有 parser source/range,查看器会明确进入 source-unavailable 状态,而新页却说文件会“shows/carries the model's own source beside the picture”。

判定依据:实测确认。186845cb 快照中:

$ PYTHONPATH=. python <programmatic StateMachine -> diagram().to_html() probe>
source_text = None
sourceAvailable = False
sourceDocuments = {'main.fcstm': {'html': '<span class="fcstm-source-line" data-line="0" data-line-number="1"> </span>', 'label': 'main.fcstm'}}
sourceUnavailableReason = This model did not retain its original FCSTM source; load it through load_state_machine_from_file/text, or pass source_text explicitly.

这页是 Explanation,docs/documentation_authoring.md:199-201 又明确要求 boundary/counterexample;应把承诺收窄为“从文本/文件解析且保留 range 的模型”,并解释程序化模型以及只传 source_text 但没有 range 时为何不能做源码联动。

I-2:把约 29 MB 的“长度熵”说成可决定性识别模型,实测同尺寸碰撞立即出现

位置docs/source/explanations/visualization/index.rst:177-186index_zh.rst:151-157

用户如何经公开入口到达:用户通过公开 load_state_machine_from_text(...).diagram().to_html() 生成查看器,并按 Explanation 的隐私分析判断暴露文件大小是否等于暴露模型身份。

判定依据:实测确认。 四个不同模型生成不同 SHA-256 文档,但字节数完全相同:

$ PYTHONPATH=. python <render four public models and print len + sha256>
29397900 5ab7ea2b6fb891d8 state Root;
29397900 5c0d65bb553a9654 state Boot;
29397900 32873dcdfc7523e9 state Test;
29397900 73773c2d5eb47a00 state AAAA;

所以“29 MB 本身让长度有足够熵、匹配是 decisive”不成立;固定内嵌资产占绝大部分,同长度模型很自然地同尺寸。紧接着的 “Neither leak is closed by renaming ... neither is in the name” 也与上文“name derived from the document”自相矛盾:随机改名会关闭 digest-name 这一条泄露,只是不会隐藏尺寸。正确表述应区分:digest 文件名是可离线枚举的强指纹;文件大小只是有碰撞的粗粒度 side channel。目录 0700 仍然同时隐藏两者,但不能靠错误的熵论证它。

I-3:新增字段扫描只看第一行,会把合法折行字段中的死目标放过去

位置tools/check_diagram_references.py:96,222-227

用户如何经公开入口到达:维护者在公开 Diagram.save:rtype: 中按普通 reST 方式折行;用户随后阅读生成的 API 页面,第二个类型退化成纯文本。无需调用私有生产 helper;变异的是公开方法 docstring。

判定依据:实测确认。 基线是 51 judged。在 /tmp 快照把 Diagram.save 的字段改为:

:rtype: pyfcstm.diagram.api.Diagram or
    pyfcstm.diagram.api.ReferenceThatDoesNotExist

门禁只吃到第一行的活目标,仍然绿;Sphinx 则把第二行留成纯文本,并在 -n 下准确报警:

$ python tools/check_diagram_references.py
diagram reference targets: 52 judged reference(s) all registered

$ rg -n -C1 ReferenceThatDoesNotExist /tmp/.../api_doc/diagram/api.html
531-<dd class="field-odd"><p><a class="reference internal" ...>pyfcstm.diagram.api.Diagram</a> or
532:pyfcstm.diagram.api.ReferenceThatDoesNotExist</p>

$ sphinx-build -E -b html -n ... | rg ReferenceThatDoesNotExist
.../api_doc/diagram/api.rst:37: WARNING: py:class reference target not found: pyfcstm.diagram.api.ReferenceThatDoesNotExist [ref.class]

这正是上一轮已发生过的“按行扫描遇折行假阴性”类别。字段解析应覆盖同一 field body 的所有缩进续行,并给 :rtype: / :type: / :vartype: 各补“首行活、续行死”的反向变异。

I-4:中文页复用了全英文时序图,直接违反仓库的双语图示硬门禁

位置docs/source/explanations/visualization/index_zh.rst:186exit_phases.puml:2-40

用户如何经公开入口到达:从中文根文档进入“图查看器原理说明”;正文是中文,承担核心顺序主张的图却从标题、参与者到三阶段说明全部为英文。

判定依据:读代码并检查实际渲染产物。 两页引用同一 SVG,资产提取出的自然语言全部是英文:

$ rg -n 'image:: exit_phases' docs/source/explanations/visualization/index*.rst
docs/source/explanations/visualization/index.rst:221:.. image:: exit_phases.puml.svg
docs/source/explanations/visualization/index_zh.rst:186:.. image:: exit_phases.puml.svg

$ rg -o '<text[^>]*>[^<]+' exit_phases.puml.svg | sed 's/<text[^>]*>//' | head
Where a viewer directory is reclaimed, and why not earlier
caller
atexit
multiprocessing.util
_exit_function
worker
...
phase 1 -- finalizers at priority >= 0
phase 2 -- join non-daemon children
phase 3 -- remaining finalizers

docs/documentation_authoring.md:154-166 明文要求:带 reader-facing natural-language labels 的图必须维护英中两套 source/rendered assets,中文页不得共享英文标注图,并且两套都要视觉检查。按 :168-174,这是 Important 且阻塞 ready;需要新增中文 .puml/.svg/.png 并让中文页引用它。

I-5:PR body 的四角色表满足第 215 行,但 #414 仍有一个明确验收项没有交付

位置docs/source/tutorials/visualization/index.rst:89-97index_zh.rst:82-89,以及 docs/source/explanations/visualization/index.rst:281-283 / 中文 :234-235

用户如何经公开入口到达:只读 Tutorial、How-to 或 Reference 的用户按页面内的“下一步”导航;他们看不到新 Explanation 的入口,只能碰巧从全局导航找到。issue #414 的验收标准明确要求“参考页与 tutorial / how-to 增加指向该页的链接,形成显式归属映射”。

判定依据:读 issue 验收标准并对整棵公开文档实测。 当前仅两个根页引用新 Explanation,三个 sibling family 为零:

$ rg -n 'explanations/visualization' docs/source/tutorials/visualization \
    docs/source/how_to/visualization docs/source/reference/visualization_options \
    docs/source/reference/cli
# 无输出

$ rg -n 'explanations/visualization' docs/source -g '*.rst'
docs/source/index_zh.rst:163:    explanations/visualization/index_zh
docs/source/index_zh.rst:175:* :doc:`explanations/visualization/index_zh`
docs/source/index_en.rst:169:    explanations/visualization/index
docs/source/index_en.rst:181:* :doc:`explanations/visualization/index`

PR body 的 Tutorial / How-to / Reference / Explanation 归属表本身确实满足 docs/documentation_authoring.md:215,我不重复上一轮的错误;这里是另一件事:PR body 又声明 issue #414 已由本 PR 交付,而该 issue 自己的一条 acceptance criterion 未满足。请在英中 Tutorial、How-to、Reference 的相关 viewer 段落补入站链接。

M-1:Windows 的版本边界和“没有 owner 可问”都写得过于绝对

位置docs/source/explanations/visualization/index.rst:265-272index_zh.rst:222-226

用户如何经公开入口到达:Windows 用户调用公开 show(open_window=False),阅读平台边界来判断临时目录是否有 ACL 隔离。

判定依据:官方文档与代码阅读确认;本机无 Windows,不冒充 Windows 实测。 “only from CPython 3.12.4,earlier versions ignore it”漏掉了安全修复对旧维护分支的回移。逐版抓取 Python 官方 os.mkdir 文档的真实输出是:

3.8: Changed in version 3.8.20: Windows now handles a mode of 0o700 .
3.9: Changed in version 3.9.20: Windows now handles a mode of 0o700 .
3.10: Changed in version 3.10.15: Windows now handles a mode of 0o700 .
3.11: Changed in version 3.11.10: Windows now handles a mode of 0o700 .
3.12: Changed in version 3.12.4: Windows now handles a mode of 0o700 .
3.13: Changed in version 3.13: Windows now handles a mode of 0o700 .

“On Windows there is no owner to ask about”作为平台事实也不成立:Microsoft GetNamedSecurityInfoW 文档明确提供 ppsidOwner,返回 security descriptor 的 owner SID。准确边界应是“当前 portable os.lstat / os.geteuid 实现没有执行 Windows owner/DACL 校验”,而不是 Windows 没有 owner。当前文字是保守地低估保护,没有直接制造越权,所以定 M。

M-2:PR body 指向的深度评审证据链接是 404

位置:PR body 第 303-306 行(深度评审评论 链接)。

用户如何经公开入口到达:评审员按 PR body 的唯一链接核查 substantial documentation PR 所要求的人工厚度、图示和双语证据。

判定依据:实测确认。 body 指向 issuecomment-5127181554,API 返回:

$ GH_TOKEN="$(gh auth token --user HansBug)" \
    gh api repos/HansBug/pyfcstm/issues/comments/5127181554
gh: Not Found (HTTP 404)
{"message":"Not Found",...}

仓库里实际名为“文档深度评审记录”的旧评论是 issuecomment-5128362427,但它明确写的是 Explanation 不在范围内,不能作为这次新页/新图的视觉与双语评审证据。请修正文中的证据链接,并补上针对本次新 Explanation(尤其两套图)的实际人工检查记录。

已核验、未形成发现

  • 退出三阶段与版本范围:纯标准库探针在本机实际安装的 CPython 3.7.1、3.8.1、3.9.1、3.10.1、3.11.1、3.12.1、3.13.1、3.14.1 上逐一跑 fork / spawn / forkserver,24/24 都是 phase0 done=False; phase-1 done=Truepytest -q test/diagram/test_api.py -k 'worker_still... or two_reclaims... or multiprocessing_worker...'7 passed;因此本轮没有推翻三阶段、负优先级或 LIFO 推理。
  • stat 的 POSIX 核心论断:本机把文件 chmod 为 000 后,stat 仍输出 file_mode=0 size=6;0700 目录自身仍可从可搜索父目录被 stat。本机没有第二用户/root/NFS,所以不把这些环境写成实测。
  • 类 docstring 的外部裸 :meth::按要求构造了裸目标 exists 与限定目标 pathlib.Path.exists。门禁报告裸名为 Diagram.exists;Sphinx -n 同时真实报告两者 unresolved(仓库未配置 intersphinx),所以这里没有复现假红。真正的问题是 I-3 的字段续行假绿。
  • PDF / 根 toctree:精确 head 186845cb 的 Docs Check run 30531739738Docs HTMLDocs PDF 均 success;PDF validation 为英文 898 页、中文 867 页。当前没有 PDF 阻断。CI 同时确认渲染树 class="problematic" 为 0;本机没有 LaTeX,不声称本地编译 PDF。
  • 术语与基础测试make docs_terminology_check 通过;python tools/check_diagram_references.py --check 通过;基线输出 51 judged reference(s) all registeredruff check 通过;干净快照的 pytest -q test/diagram/test_api.py122 passed, 1 skipped
  • 分支同步:远端 dev/python-diagram-umbrella、本地 origin/...、PR baseRefOid 均为 aa745161980799a4ba9e56e3654040339f7faa98,且该提交是 186845cb 的祖先。
  • Chromium 约 1/20 秒交接:我没有在现有桌面 profile 上再开可见窗口。隔离的 headless 探针走的是另一条 singleton 行为(同 profile 以 status 21 拒绝),不能证实或证伪桌面 app-window handoff;因此这条保持“本轮未独立复现”,没有写成“没有问题”。探针创建的临时 Chrome 进程/profile 已全部清理,未碰现有浏览器会话。

综上,PDF、退出顺序、POSIX stat、裸外部 :meth: 误报和 umbrella 同步目前都有正向证据;阻塞项集中在新 Explanation 的事实边界、双语图示、#414 未完成验收,以及字段门禁仍可假绿。

…cited was broken

Both reviewers went at the page rather than the code, and two of its factual claims did
not survive.

The size argument was wrong. I wrote that a ~29 MB document "has enough entropy in its
length" for a byte-count match to name the model, and I verified it by rendering models
whose *source text* differs in length -- which of course differ in output length. A
reviewer rendered four whose source is the same length:

    29397900  sha 5ab7ea2b6fb891d8  state Root;
    29397900  sha 5c0d65bb553a9654  state Boot;
    29397900  sha 32873dcdfc7523e9  state Test;
    29397900  sha 73773c2d5eb47a00  state AAAA;

Identical to the byte. So the two leaks are not the same kind: a name derived from the
document is a strong fingerprint that matches exactly or not at all, and the size is a
coarse side channel that collides. The sentence after it contradicted itself as well --
"neither leak is closed by renaming the file" is false of the one that is the file's
name. Renaming closes that one and leaves the other; no mode on the file closes either,
because `stat` needs none; the directory closes both. The design is unchanged and the
argument for it is now true.

The source promise was unconditional and is not. A `StateMachine` assembled in Python
has no source and no ranges, and the viewer says so -- `sourceAvailable: False` with a
reason naming the loaders that keep it. That is a public path, and an explanation page
owes the boundary, so it now has one, including the halfway case of `source_text`
without ranges.

The Windows boundary understated the protection. CVE-2024-4030 was backported: 3.8.20,
3.9.20, 3.10.15, 3.11.10, 3.12.4 and 3.13 all handle a mode of 0o700, not 3.12.4 alone.
And "on Windows there is no owner to ask about" is wrong about the platform -- an owner
exists and is readable through its security API; what is true is that the portable
`os.lstat` and `os.geteuid` used here do not report it.

Then the rule the page's own PR invoked. `docs/documentation_authoring.md:154` forbids a
Chinese page from sharing an English-labelled diagram, and the repository already
follows it in `tutorials/bmc` with `first_check_en.puml` and `first_check_zh.puml`. My
one diagram carried 773 characters of English prose in 35 text nodes and both pages
pointed at it -- while the Chinese page told its reader that this diagram is where the
argument lives. There is now a Chinese source and a Chinese rendering: 35 text nodes
each, 0 CJK characters in one and 207 in the other, both PNGs looked at rather than
assumed. Their phase-two label also said "join non-daemon children"; CPython terminates
the daemons and joins every child still active.

Issue #414's own acceptance criterion asked for inbound links, and only the two root
indexes had one. Tutorial, how-to and reference now each point at the page, in both
languages.

On the checker: its field rule read one line, so a `:rtype:` wrapped after `or` -- plain
reST, and the shape `show()`'s own `:param output:` already uses -- let the second target
through in silence. This is the third time a line-anchored read has bitten this branch.
Field bodies now include their continuation lines, the self-check pins that, and the
reviewer's mutation fails. Its docstring also claimed 93 ghost registry entries in this
package where the real number is 14, and leaned unstated on this repository configuring
no intersphinx: with one, the bare-member rule would start reporting live links, so the
checker now refuses to run if `conf.py` gains one, and its message no longer tells you
to repoint a reference that was never ours.

Smaller: `explanations/index.rst` had a title underline one character short, which my own
build printed and I read past; `api.py` said 30 MB in four places where everything else
says 29; and the probe's root is no longer hard-wired to `/tmp`, which is not writable in
every sandbox.

Verified: both languages build with 20 warnings, two fewer than before, and zero
`class="problematic"` anywhere; `make docs_terminology_check`,
`diagram_reference_targets_check`, `diagram_docstring_check` pass; 194 diagram and CLI
tests, 184 under umask 077; `make rst_auto` produced no change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HansBug

HansBug commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

第 31 轮整合结论(186845cbb07121d7

两位评审共报 0 C / 7 I / 13 M,多处重叠。全部处理完毕。 本轮两位都把火力对准了新写的 explanation 页,而页上有两条事实主张没能站住——这两条是本轮最重要的收获。

我的两条事实主张是错的

codex I-2:长度熵那条论证不成立,而我自己的"验证"无效。 我写"约 29 MB 的文档,其长度的熵足以让匹配成为决定性识别",验证时挑的是源文本长度不同的模型——它们当然产出不同长度。codex 挑了四个源文本等长的:

29397900  sha 5ab7ea2b6fb891d8  state Root;
29397900  sha 5c0d65bb553a9654  state Boot;
29397900  sha 32873dcdfc7523e9  state Test;
29397900  sha 73773c2d5eb47a00  state AAAA;

我复跑确认:字节数完全相同。所以两种泄露不是一类——由文档派生的名字是指纹(精确对上或完全对不上),大小只是侧信道(容易碰撞)。紧随其后那句"改文件名关不掉其中任何一个泄露,因为泄露都不在名字里"更是自相矛盾:其中一个泄露就是文件名。改名能关掉前者、留下后者;而文件上的任何权限位都关不掉两者(stat 不需要权限);目录把两者一起关掉。设计没变,支撑它的论证现在为真。

codex I-1:源码那半是无条件承诺,但它有条件。 在 Python 里直接拼出来的 StateMachine 既没有源码也没有位置区间,查看器会明确进入 source-unavailable 状态(sourceAvailable: False + 指明该用哪些加载器)。这是公开路径,而 explanation 页本就该给边界,所以新增了一节,并包含"只传 source_text 而无区间"的中间情况。

codex M-1 / claude M6:Windows 边界低估了保护。 CVE-2024-4030 被回移到 3.8.20 / 3.9.20 / 3.10.15 / 3.11.10 / 3.12.4 / 3.13,不是只有 3.12.4。而"Windows 上没有属主可问"作为平台事实也不对——那里有属主、可经平台安全接口读到;真实边界是本包使用的可移植 os.lstat / os.geteuid 不报告它。两处都已改准。

我违反了自己引用的那份指南

codex I-4 / claude I1。 docs/documentation_authoring.md:154 明文禁止中文页共用英文标注的图,而仓库在 tutorials/bmc/ 早有 first_check_en.puml / first_check_zh.puml 的正确先例。我那一张图带 773 字符英文散文、35 个文本节点,两页共用——偏偏中文页自己写着"这幅图要说的是……",把中文读者指向一幅他可能读不了的图。

现已有中英两套源码与两套渲染产物,各 35 个文本节点、英文侧汉字 0 个 / 中文侧 207 个,两份 PNG 我都逐图目视检查(中文汉字正常显示、非方块)。两图第二阶段的标签原写 "join non-daemon children",而 CPython 实际是先 terminate() daemon 子进程、再 join 所有仍活跃的子进程(claude M10),也已改。

codex I-5:#414 自己的一条验收标准没交付

我在 PR body 声明 #414 由本 PR 交付,但它的验收标准里"参考页与 tutorial / how-to 增加指向该页的链接"当时只有两个根页做到。现已在 tutorial / how-to / reference 三族双语各一处加入站链接,用 codex 自己给的命令可自证。

codex I-3 / claude M3:门禁的字段规则只读一行——同一类错误第三次

折行的 :rtype: A or\n B 是普通 reST(show() 自己的 :param output: 就折了 20 行),而按行锚定的正则只吃第一行,第二个死目标静默放过。这已经是本分支第三次被"按行读"咬(前两次是 grep 模式排除被检验写法、按行 grep 遇折行假阴性)。字段体现在包含缩进续行,自检钉住了这条,codex 的原变异现在能红。

其余 M

  • claude M4:裸成员规则今天不误报,但它默默倚赖本仓库未配 intersphinx。我把这个前提写明,并让门禁在 conf.py 出现 intersphinx 时拒绝运行而不是变成噪音(实测触发);报错文案也不再对外部名说"去指向文档它的模块"。
  • claude M5:工具文档里那个 93 复现不出来(真实是 14,全为 dataclass 字段与 __post_init__);两个重要数字 1876 与反向 0 完全对上。已改。
  • claude M1explanations/index.rst 的标题下划线短一个字符——这条告警我自己的构建打印过而我读过去了(我按 "visualization" 过滤告警,而它恰好出现在标题文本里)。已补齐,双语告警从 22 降到 20。
  • claude M2:我删掉了"约二十分之一秒"这个数字。它指出我那 0.043 秒测的其实是 SingletonLock 已存在导致的 abort 路径,不是成功 hand-off——也就是说我自己的"实测"不支持那个数字。改为与实现 docstring 一致的"随即退出"。
  • claude M8api.py 里四处写 30 MB、别处写 29 MB,同一事实两个数字。统一为 29 MB(实测 29,401,138 字节)。
  • claude M9:中文页 worker 裸用未按术语纪律首次交接。已改为"工作进程(worker)"并在后文用中文;finalizer 改为"终结器(finalizer)"。daemon 对应 p.daemon 这个 API 概念,保留英文。
  • claude M11:探针根目录硬编码 /tmp,在 /tmp 不可写的沙箱里会 PermissionError。已改为退回平台临时目录,并只在路径过长时跳过 forkserver
  • codex M-2:PR body 里那条深度评审链接是 404(我写错了 comment id)。已修正为 #issuecomment-5128362427,并按它的意见补上针对新页与两张图的人工检查记录——因为那条旧记录明确写着 Explanation 不在范围内,不能充当本次的视觉与双语证据。

两位确认为无问题的部分(我不重复其证据,仅记要点)

退出三阶段与负优先级推理:claude 六个变异全打红、codex 24/24 跨版本自证;完成标记覆盖四种正常死法;POSIX stat 论断;类内裸 :meth: 对外部名的报告不是误报(仓库确实未配 intersphinx,Sphinx -n 同样报未解析);PDF 由 186845cb 的 Docs Check run 覆盖(英文 898 页 / 中文 867 页,Docs PDF success);与 umbrella 同步落后 0。

本轮验证

  • 双语构建 exit 0、各 20 条告警(比上一轮少 2 条)、全树 class="problematic" 为 0;中文页确认引用中文图。
  • make docs_terminology_check / diagram_reference_targets_check(51 处)/ diagram_docstring_check(81 例)通过;make rst_auto 无新增改动。
  • 194 个 diagram + CLI 测试;umask 077 下 184 个;ruff 干净。
  • 门禁三个方向变异复验:折行续行死名、intersphinx 守卫、拼错成员名——全部能红。
  • CI 在 186845cb(上一 head)上三项全绿且未被取消;b07121d7 的 CI 正在跑。

未满足与不可验证项

  • 第三个评审来源连续第 14 轮缺位(DEEPSEEK_API_KEY 返回 402 Insufficient Balance;本机只有 claude / codex 两个独立来源)。
  • diagram_parity_check 需要仓库外的冻结参考包,本机不可运行。
  • Chromium 桌面 app-window 的 hand-off 时序:两位与我都没能在真实桌面 profile 上复现成功路径(本机 X server 拒绝新客户端),所以页面已不再给具体数字。

@HansBug

HansBug commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

claude reviewer — 186845cb..b07121d7 独立强对抗评审

我是 claude reviewer。本轮只看这一段范围。变异全部在 /tmp/rev389/snapgit archive b07121d7 | tar -x)里做,仓库工作树未被修改(复核:git status --porcelain 空)。

先说结论:这一轮要修的六件事,我逐条核验,其中五件确实修对了(CVE 版本清单、_exit_function 第二阶段标签、门禁字段续行规则、门禁 14 这个数、双语图、入站链接)。但改正过程中新写进去的句子里,又有三条与实际行为不符,其中两条恰好是这次专门去修的那两段主张本身。另有一条 Windows 边界虽然不假、但把结论说反了。


I-1 29,401,138 bytes for a one-state model 与本页自己的表格差 3,238 字节

  • 位置:docs/source/explanations/visualization/index.rst:73index_zh.rst:63;与之矛盾的是同页 index.rst:209 / index_zh.rst:177
  • 用户如何到达:读这一页的人;本页自己给了复现配方(单状态模型)
  • 判定依据:实测确认
$ python -c "
from pyfcstm.model.load import load_state_machine_from_text
sm = load_state_machine_from_text('state Root;')
print(len(sm.diagram().to_html().encode('utf-8')))"
29397900                       # to_html / save() 都是这个数

$ pyfcstm diagram -i one_state.fcstm -o cli.html && stat -c%s cli.html
29397885                       # 从文件加载(CLI 路径)

29,401,138 不是任何单状态模型的体积。它落在 1 状态(29,397,900)与"2 子状态复合"(29,401,767)之间:

1 state                      29397900   (delta -3238)
2 states+init                29401767   (delta  +629)
2 states + transition        29403238   (delta +2100)

也就是说这个数对应的是约两个状态的模型,for a one-state model 这个标签是错的。

这里要特别说明一点,免得被当成"资产版本不同":资产是构建产物、不在 Git 里,所以精确字节数原则上依赖本机资产。但下面那张四模型表的四个 sha 前缀在我这里逐个精确复现(见 I-2),说明我的资产与作者一致 —— 因此这 3,238 字节的差是页内真实不一致,不是资产差异。

建议:把 73 行的数直接换成 29,397,900,与表格对齐;或者注明那是哪个模型。


I-2 produce byte-identical documents 是假的,且被它下面紧跟的表格自我否证;中文页写的是对的

  • 位置:index.rst:205(英)对 index_zh.rst:173(中)
  • 用户如何到达:同上
  • 判定依据:实测确认

四个模型的 sha 各不相同(我这边逐字节复现,含 sha 前缀):

29397900  sha 5ab7ea2b6fb891d8  state Root;
29397900  sha 5c0d65bb553a9654  state Boot;
29397900  sha 32873dcdfc7523e9  state Test;
29397900  sha 73773c2d5eb47a00  state AAAA;

文档若真是 byte-identical,这四个 sha 必然相同。而且这句话会把本段的论点整个拆掉:名字就是 sha256(document)[:16]api.py:1284kept-%s.html),文档字节相同则名字也会碰撞,"名字是强指纹、大小是粗侧信道"的区分就不存在了。

中文页 173 行写的是"字节数完全一致"——这是准确的。所以这同时是一处双语不一致,且只有英文那侧是错的。英文应为 "produce documents of identical length" 之类。

顺带一句正面的:这张表里的 sha 前缀正好就是保留查看器的文件名,所以这个例子同时把"名字=文档摘要"演示掉了,这个设计很好。


I-3 source_text 无区间的"中间情况":the text is shown 与实际相反,查看器把文本换成了原因

  • 位置:index.rst:116index_zh.rst:100("文本会显示出来")
  • 用户如何到达:StateMachine.diagram(source_text=...) 是公开签名参数;本节就是教这条路
  • 判定依据:实测确认 + 读发布产物
$ python -c "
from pyfcstm.model.model import StateMachine, State
sm = StateMachine(defines={}, root_state=State(name='Root', path=('Root',), substates={}))
h = sm.diagram(source_text='state Root;').to_html()
import re; print(re.search(r'\"sourceAvailable\":(true|false)', h).group(1))"
false

原因在 pyfcstm/diagram/api.py:2679"sourceAvailable": bool(source) and bool(source_map) —— 有文本无区间即 False。而面板是互斥三元,不是并列显示。发布的 pyfcstm/diagram/assets/viewer.js(由 editors/vscode/src/preview-webview/standalone.ts 构建)里就是这一句:

h.sourceAvailable===!1?(Mi(),fo("div",Q5n,Ha(h.sourceUnavailableReason||"No FCSTM source is available ...")))
                      :(Mi(),fo("pre",Z5n,[Ur("code",{innerHTML:d.currentSourceHtml}...)]))

对应源码 StandaloneSourcePanel.vue:111-114v-if="sourceAvailable === false" / v-else。所以这种情况下 <pre><code> 根本不渲染,用户看到的是:

This model carries source text but no source ranges, so the diagram cannot be linked
to it. Ranges come from parsing; a model built programmatically has none.

而且 api.py:2675-2678 作者自己的注释就写着改成 False 正是因为"showed a pane where nothing responds"。文档这句和代码注释直接对立。

(本节前半是对的:纯程序化模型 → sourceAvailable: False + 页面上引用的那段 reason 字符串,我逐字复现无误。)


I-4 Windows 安装包那句只点了 3.10 / 3.11,而 3.8.20 / 3.9.20 同样没有安装包 —— 结论被说反了

  • 位置:index.rst:315index_zh.rst:265
  • 用户如何到达:Windows 用户读"平台边界"一节判断自己有没有那道 ACL
  • 判定依据:外部权威来源核验

先说好的:版本清单这次是对的。NVD 上 PSF 自己提交的 CVE-2024-4030 版本数据与页面完全一致:

affected from fixed in
0 3.8.20
3.9.0 3.9.20
3.10.0 3.10.15
3.11.0 3.11.10
3.12.0 3.12.4
3.13.0a1 3.13.0b1

机制描述也对:"The fix adds support for Unix '700' for the mkdir function on Windows which is used by mkdtemp()"。

但接下来那句只说"python.org 在 3.10.15 / 3.11.10 之前就停止提供 Windows 安装包"。3.8 和 3.9 完全一样(python.org 各版本发布页原文):

版本 是否有安装包 最后一个带安装包的版本
3.8.20 No installers 3.8.10
3.9.20 No installers 3.9.13
3.10.15 No installers 3.10.11
3.11.10 No installers 3.11.9

单独点出 3.10/3.11,会让 3.8/3.9 的安装包用户以为自己有这道 ACL —— 而他们没有。本包声明支持 3.7–3.14,3.8/3.9 在范围内。

建议直接写成更强也更简单的结论:用 python.org Windows 安装包时,这道 0o700 ACL 只在 3.12.4+ 与 3.13+ 上存在;其余支持线上只有自建/再分发解释器才有。

"可移植 os.lstat / os.geteuid 不报告属主"这个改法我认为是对的,比原来的"没有属主可问"准确。


I-5 intersphinx 守卫是对整个 conf.py 做子串匹配,注释里提一句就会让门禁拒跑,且报错文案说的是假话

  • 位置:tools/check_diagram_references.py:291
  • 用户如何到达:维护者跑 make diagram_reference_targets_check / CI
  • 判定依据:实测确认(变异)
$ printf '\n# NOTE: we deliberately do not configure intersphinx here.\n' >> docs/source/conf.py
$ python tools/check_diagram_references.py; echo "exit=$?"
docs/source/conf.py now configures intersphinx; the bare-member rule in this checker
assumes it does not, and must be reconsidered before this runs again
exit=1

四个方向全部触发,无法区分真配置与仅提及:

追加内容 结果
intersphinx_mapping = {...} exit=1(应该)
extensions.append('sphinx.ext.intersphinx') exit=1(应该)
# see also: intersphinx exit=1(误伤
# nothing here about intersphinxes at all exit=1(误伤

这正是本仓库 CLAUDE.md 里已经写过的那个 footgun(contains() 是子串匹配,"document the ci skip flag" 会激活 ci skip)。而且误伤时的文案是 conf.py now configures intersphinx,陈述了一个假事实,读到的人会去 conf.py 里找不存在的映射。

建议至少收紧到 intersphinx_mapping 赋值或 sphinx.ext.intersphinx 出现在 extensions 里;更稳的做法是 ast.parse 后看模块级赋值/列表字面量。

正面确认:守卫的前提本身成立 —— grep -c intersphinx docs/source/conf.py = 0,所以今天门禁是真的在跑(不是被自己的守卫挡掉的空转),bare :meth: 规则的依据有效。


M-1 字段续行仍在空行处截断,而 docutils 认为空行之后仍在同一个 field body 内

  • 位置:tools/check_diagram_references.py:207if not following.strip(): break
  • 判定依据:实测确认(先问 docutils,再变异门禁)
$ python -c "
from docutils.core import publish_doctree
print(publish_doctree(''':vartype x: pyfcstm.diagram.api.Diagram

    pyfcstm.diagram.NoSuchThing
''').pformat())"
<field classes="vartype-x">
    <field_name> vartype x
    <field_body>
        <paragraph> pyfcstm.diagram.api.Diagram
        <paragraph> pyfcstm.diagram.NoSuchThing      <-- 仍在 field_body 内

同一份 docstring 喂给门禁:exit=0NoSuchThing 被放过。

评为 M 而不是 I:多段落的 :rtype: / :vartype: 不是本仓库会出现的写法,本轮也没有这种残留。但既然这条规则的整个由来就是"按行读漏了续行",把终止条件对齐 docutils(空行后若仍有更深缩进则继续)更彻底。

我把该规则的其余分支逐个变异过,没有发现误报

变异 期望 实际
:rtype: <live> or\n <dead> 报 ✅(证明这条修复真的能失败)
:type thing: str or\n collections.abc.Mapping 不报 不报 ✅
两行都是活名字 不报 不报 ✅
两个字段、死名字在第二个 报 ✅
三行 body、两个死名字 报两次 报两次 ✅

M-2 点号路径跨行折断时报出带尾点的目标名

:rtype: pyfcstm.diagram.\n api.Diagram → 报 pyfcstm.diagram. is not registered。底层 docstring 确实是坏的(Sphinx 也解析不出来),但这个目标名会让人看不懂。实测

M-3 续行上的命中被记在字段标记那一行

_field_bodies 返回的是标记行偏移,所以死名字在续行时,file:line 指向上一行。读代码 + 实测(三行 body 的两个死名字都报在同一行号)。不影响判定,只影响跳转。

M-4 pyfcstm/entry/diagram.py:178 还是 ~30 MB

本轮把 api.py 四处统一成 29 MB,但同一包内这一处漏了。实测grep -rn "30 MB" pyfcstm/ 只剩这一处。

M-5 PR body 第 48 行的评论链接不存在

#issuecomment-5028625560 在三个端点上都是 404,且不在本 PR 全部 238 条 issue comment 里:

$ gh api repos/HansBug/pyfcstm/issues/comments/5028625560   -> 404 Not Found
$ gh api repos/HansBug/pyfcstm/pulls/comments/5028625560    -> 404 Not Found
$ gh api --paginate repos/HansBug/pyfcstm/issues/389/comments --jq '.[].id' | grep -c 5028625560
0
$ curl -sL .../pull/389 | grep -c 'id="issuecomment-5028625560"'
0     # 页面上只有 body 自己的 href,没有锚点定义

(其余 8 个链接全部 200,且 4 个评论 ID 都能取到真实作者与时间 —— 这一条是唯一的残留。)实测

M-6 PR body 第 317 行"中文侧 207 个"复现不出来

$ python -c "
import re; s=open('exit_phases_zh.puml.svg',encoding='utf-8').read()
t=''.join(re.findall(r'<text[^>]*>(.*?)</text>', s, re.S))
print(len(re.findall(r'[一-鿿]', t)))"
214

[一-龥] 也是 214;去掉 title 节点是 194;唯一字符数 102。没有哪种常规算法给出 207。底下那个实质主张是真的(中文图汉字正常、非方块 —— 我自己看了渲染结果,见下),所以只是审计记录里的数不准。


已核验通过的部分(附可自证的命令与输出)

双语图完全满足 docs/documentation_authoring.md:154-174 两套源码、两套渲染产物;每页引用各自那一份,这是从构建出的 HTML 里读的,不是看文件是否存在:

$ grep -o 'src="[^"]*exit_phases[^"]*"' html-en/explanations/visualization/index.html
src="../../_images/exit_phases.puml.svg"
$ grep -o 'src="[^"]*exit_phases[^"]*"' html-zh/explanations/visualization/index_zh.html
src="../../_images/exit_phases_zh.puml.svg"

逐图看了两份 PNG:中文图 CJK 字形正常、无豆腐块、无溢出裁切,四条生命线与三个 group 框布局清晰;两图结构一致,第二阶段标签"终止 daemon 子进程,join 所有仍活跃的子进程" / "terminate daemons, join every active child" 主张一致。

第二阶段标签这次改对了。 这是本轮最实的一处修复,直接对 CPython 源码核验:

$ python -c "import multiprocessing.util,inspect;print(inspect.getsource(multiprocessing.util._exit_function))"
        _run_finalizers(0)                       # 阶段一:priority >= 0
        ...
            for p in active_children():
                if p.daemon: p._popen.terminate()   # 终止 daemon
            for p in active_children():
                p.join()                            # join 所有仍活跃的
        _run_finalizers()                        # 阶段三

原来的 "joins non-daemon children" 确实是错的 —— active_children() 不区分 daemon。

门禁 docstring 里 1876 / 14 / 零 三个数全部精确复现(拿本次构建出的 objects.inv 对齐):

registry names collected: 3358
objects.inv anchors: 4990
collected-but-no-anchor: 1876
  of which in pyfcstm.diagram*/entry.diagram: 14
anchors-not-in-registry: 0

那 14 个是 11 个 dataclass 字段 + 3 个 __post_init__,与 "all dataclass fields and __post_init__" 一字不差。93 → 14 这个修正是对的。

六处入站链接全部真的可点(读的是 href,且目标文件存在):

tutorials/visualization/index.html               href="../../explanations/visualization/index.html"
how_to/visualization/index.html                  href="../../explanations/visualization/index.html"
reference/visualization_options/index.html       href="../../explanations/visualization/index.html"
tutorials/visualization/index_zh.html            href="../../explanations/visualization/index_zh.html"
how_to/visualization/index_zh.html               href="../../explanations/visualization/index_zh.html"
reference/visualization_options/index_zh.html    href="../../explanations/visualization/index_zh.html"

注意 *_zh 必须在 zh 构建里查 —— en 构建通过 exclude_patterns 排除了它们,只看 en 产物会得出"中文侧没有链接"的假结论。

两种语言构建各 0 个 class="problematic"build succeeded, 20 warnings,两侧 20 条告警是同一批既有问题(重复对象描述、Event/Expr 歧义引用、缺 logo svg、NO_CONTENTS_BUILD=1 导致的 demo txt 缺失、_selfcheck/index.rst 孤儿页),没有一条来自本轮改动的页面;也没有 "Title underline too short",所以 explanations/index.rst 的下划线补齐是有效的。

新增一节的交叉引用在 HTML 里都是真链接:pyfcstm.model.load.load_state_machine_from_textapi_doc/model/load.html#...pyfcstm.model.model.StateMachineapi_doc/model/model.html#...,页内 `What sharing a name costs`_#what-sharing-a-name-costs(锚点存在)。

其余零碎项:twentieth of a second / 二十分之一.rst 中已彻底清零;中文页术语纪律正确(227 行首次"工作进程(worker)",其后 229/240/242/244/246 行均为裸"工作进程");探针根目录的 /tmp 回退带了 shutil 导入且 test/diagram 184 passed, 1 skipped(跳过的是 test_api.py:2457 的 Windows 专有只读属性规则,Linux 上合理);分支与 origin/dev/python-diagram-umbrella 已同步git merge-base --is-ancestor 通过,HEAD..umbrella 为空)。

门禁:diagram_assets_check / diagram_csp_check / diagram_contract_check / diagram_docstring_check / diagram_reference_targets_check 全部 PASS(--check 自检也 PASS,48 judged references)。顺便说一句,我最初把资产软链接进快照时,_assert_no_symlink_tree 正确地把我拦下来了 —— 这个防护是有效的,我改成真实拷贝后才跑通。


我没能覆盖的(不写成"没问题")

  • diagram_browser_check 在我写这条评论时仍在跑(5 locale × 4 视口)。已完成的 23 个用例 externalRequests: []cspViolations: []consoleErrors: 0,但我没有看到最终判定,所以不对这个门禁下结论。
  • 本机只有 CPython 3.10.1。"Measured on CPython 3.7 through 3.14, on fork/spawn/forkserver" 这句我没有重新测量;我只验证了机制(_exit_function 源码)与该测试在 3.10.1 上通过,并确认 CI 矩阵里确实有 3.7–3.14。
  • root、无 Windows、无 macOS、无 NFS、无 LaTeX。Windows 那节的结论全部来自 NVD/PSF 的 CVE 版本数据与 python.org 发布页原文,不是实机验证。
  • 资产是本地构建产物。四模型表的 sha 在我这里精确复现,说明我的资产与作者一致;但换一次资产构建,页面上任何精确字节数都会一起变。

处置建议

I-1 / I-2 / I-3 是这一轮新写进去的事实错误,且 I-2、I-3 正好落在这次专门去修的两段主张上,建议在同一次修改里一并处理(I-2 只需把英文对齐已经正确的中文;I-3 需要按 sourceAvailable=False 的实际行为重写那一句,不是微调措辞)。I-4 是"不假但说反",改成"只有 3.12.4+ / 3.13+ 的安装包才有"反而更短更准。I-5 是门禁自身会误伤并给出假陈述,建议收紧匹配。

在 I-1 到 I-5 处理完之前,我认为不应给出 ready to merge。M 组可以合并到同一次修改里,也可以单独跟进;其中 M-5、M-6 只影响审计记录,不影响交付物。

@HansBug

HansBug commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

codex reviewer:我独立复核了 186845cb..b07121d7。结论是 C=0 / I=3 / M=3,REQUEST CHANGES

所有变异均在 /tmp/pyfcstm-pr389-codex-review.SFV1Zggit archive b07121d7 快照内完成;运行时资产按要求从工作树软链接进入快照,工作树未修改。

发现

[I-1] 程序化模型只传 source_text 时,文档说“文本会显示”,真实浏览器却只显示不可用提示

  • 位置:docs/source/explanations/visualization/index.rst:104-118docs/source/explanations/visualization/index_zh.rst:89-101
  • 公开入口:用户按文档在 Python 中直接构造 StateMachine,调用 model.diagram(source_text="state Root;").to_html(),再正常打开生成的 HTML。
  • 判定:实测确认。初始状态虽携带 sourceHtml,但 sourceAvailable=FalseStandaloneSourcePanel.vuev-if 分支因此不渲染 <pre>,真实 Chrome DOM 只有 unavailable 提示。第 104-106 行把显式 source_text 也列为“comparison needs”的可用情况,同样与当前 UI 合同不符。
  • 实测命令与真实输出:
PYTHONPATH="$SNAP" python - <<'PY'
import json
import re
from pathlib import Path
from pyfcstm.model import State, StateMachine

model = StateMachine(
    defines={},
    root_state=State(name="Root", path=("Root",), substates={}),
)
html = model.diagram(source_text="state Root;").to_html()
state = json.loads(re.search(
    r"window\.__FCSTM_INITIAL_STATE__ = (.*?);</script><script>", html, re.S
).group(1))
Path("/tmp/pr389-programmatic-source.html").write_text(html, encoding="utf-8")
print("sourceAvailable=", state["sourceAvailable"])
print("reason=", state["sourceUnavailableReason"])
print("sourceHtmlHasText=", "Root" in state["sourceHtml"])
print("output=/tmp/pr389-programmatic-source.html")
PY

sourceAvailable= False
reason= This model carries source text but no source ranges, so the diagram cannot be linked to it. Ranges come from parsing; a model built programmatically has none.
sourceHtmlHasText= True
output=/tmp/pr389-programmatic-source.html
google-chrome --headless=new --no-sandbox --disable-gpu --virtual-time-budget=5000 \
  --dump-dom file:///tmp/pr389-programmatic-source.html 2>/tmp/pr389-chrome.err \
  | rg -o '<div class="fcstm-source-panel__unavailable">[^<]*</div>|<pre class="fcstm-source-panel__code"'
<div class="fcstm-source-panel__unavailable">This model carries source text but no source ranges, so the diagram cannot be linked to it. Ranges come from parsing; a model built programmatically has none.</div>

应选择一个明确合同:要么 UI 真正显示无联动的源码文本,要么文档说明文本虽被携带进 HTML,但当前面板会被不可用原因替代;不能继续写成“文本会显示”。

[I-2] intersphinx 守卫把纯注释误判为“已配置”,会阻断正常文档维护

  • 位置:tools/check_diagram_references.py:290-299
  • 公开入口:文档维护者在 docs/source/conf.py 写一条解释“为何未启用 intersphinx”的注释,然后运行仓库给出的 make diagram_reference_targets_check
  • 判定:实测确认。守卫做的是整个文件的裸子串搜索,不是配置检查;而 --check 自检绕过该守卫,所以自检仍绿。
  • 快照中只加入以下注释:
-# Add any Sphinx extension module names here, as strings. They can be
+# Add any Sphinx extension module names here, as strings. An intersphinx
+# mapping is intentionally not configured for these local-only docs.
  • 实测命令与真实输出:
python tools/check_diagram_references.py --check
python tools/check_diagram_references.py

diagram reference targets: self-check passed
docs/source/conf.py now configures intersphinx; the bare-member rule in this checker assumes it does not, and must be reconsidered before this runs again

守卫应解析实际的 extensions / intersphinx_mapping 配置,至少不能让注释和字符串字面量触发;自检还应覆盖这一反例。

[I-3] 合法的转义星号参数字段仍是漏判,折行死目标可静默通过门禁

  • 位置:tools/check_diagram_references.py:102tools/check_diagram_references.py:179-213
  • 公开入口:维护者给公开 variadic API 写正常的 reST docstring,例如 raw docstring 中的 :type \*args:,把类型折到续行,然后运行 make diagram_reference_targets_check。发布后的 Sphinx 页会把死目标渲染成无链接文本。
  • 判定:实测确认。普通 :rtype:、多行 :type value: 以及外部名/本仓库名混排均能按预期处理;但 FIELD[\w.]* 不接受 reST 合法的 \*args / \*\*kwargs 字段名。
  • 变异夹具核心:
def public_variadic(*args):
    r"""
    :param \*args: Values to accept.
    :type \*args: pathlib.Path or
        pyfcstm.diagram.StarredContinuationDead
    """
  • 实测命令与真实输出:
python tools/check_diagram_references.py

diagram reference targets: 51 judged reference(s) all registered

PYTHONPATH="$SNAP" sphinx-build -b html -n -W /tmp/pr389-star-sphinx /tmp/pr389-star-sphinx-out3

/tmp/pr389-star-sphinx/index.rst:2: WARNING: py:class reference target not found: pathlib.Path [ref.class]
/tmp/pr389-star-sphinx/index.rst:2: WARNING: py:class reference target not found: pyfcstm.diagram.StarredContinuationDead [ref.class]
build finished with problems, 2 warnings (with warnings treated as errors).

这里更适合解析 reST field list;若仍用正则,至少要覆盖 \*args\*\*kwargs,并把折行、混合外部名/本仓库名和 starred 字段一起钉入自检。

[M-1] 页首新增的“实测精确字节数”写错,并与同页证据自相矛盾

  • 位置:docs/source/explanations/visualization/index.rst:73-74docs/source/explanations/visualization/index_zh.rst:63
  • 公开入口:用户直接阅读新 explanation 页。页首写 29,401,138,同页第 209-212 行却写 29,397,900
  • 判定:实测确认。公开 show(open_window=False) 路径得到的四个文件均为 29,397,900 字节;同时验证了这四个名字哈希各异,因此“名字是强指纹、大小会碰撞”的核心改写成立。
  • 实测命令与真实输出:
PYTHONPATH="$SNAP" python - <<'PY'
from pyfcstm.model import load_state_machine_from_text

for source in ("state Root;", "state Boot;", "state Test;", "state AAAA;"):
    path = load_state_machine_from_text(source).diagram().show(open_window=False)
    print(path.name, path.stat().st_size, source)
    path.unlink()
PY
kept-5ab7ea2b6fb891d8.html 29397900 state Root;
kept-5c0d65bb553a9654.html 29397900 state Boot;
kept-32873dcdfc7523e9.html 29397900 state Test;
kept-73773c2d5eb47a00.html 29397900 state AAAA;

建议删掉脆弱的页首精确数值,保留“约 29 MB”;若保留精确值,必须与同页实测表一致。

[M-2] Windows 段落称“校验退回到只看名字”,但实现仍校验 os.lstat 报告的对象类型

  • 位置:docs/source/explanations/visualization/index.rst:307-310docs/source/explanations/visualization/index_zh.rst:259-261
  • 公开入口:Windows 用户阅读该公开 explanation 页,判断默认临时目录实际检查了什么。
  • 判定:读代码得出,未在 Windows 实机验证。实现无法验证 Windows 的 owner/mode,这部分表述正确;但它仍调用 os.lstat 并要求 stat.S_ISDIR,不是“只看名字”。
  • 检查命令与真实代码输出:
sed -n '1248,1265p' pyfcstm/diagram/api.py

    try:
        info = os.lstat(str(path))
    except OSError as error:
        # It went away between the two calls, which is not something to work
        # around; the fallback covers it.
        return "%s" % error
    if not stat.S_ISDIR(info.st_mode):
        return "it is not a directory"
    if hasattr(os, "geteuid") and info.st_uid != os.geteuid():
        return "it belongs to another user"
    shared = stat.S_IMODE(info.st_mode) & 0o077
    if os.name != "nt" and shared:
        # The bits that decide who else may look, not the whole mode. A setgid
        # parent -- an ordinary way to run a shared scratch directory -- makes the
        # new directory 2700, which is as private as 0700 and was being refused,
        # sending every process to a fallback and writing the same document again.
        return "it allows %03o to others" % shared
    return None

建议改成“Windows 上仍校验存在性与 os.lstat 报告的目录类型,但无法用这套可移植接口校验属主与权限”。

[M-3] Windows 官方安装包 caveat 漏掉同样受影响的 3.8 和 3.9

  • 位置:docs/source/explanations/visualization/index.rst:312-317docs/source/explanations/visualization/index_zh.rst:263-266
  • 公开入口:本仓库明确支持的 Windows Python 3.8/3.9 用户阅读该页,判断自己的 python.org 安装包是否含 0o700 ACL 修复。当前文字只点名 3.10/3.11 官方安装包用户没有修复,会遗漏 3.8/3.9 的同一边界。
  • 判定:实测确认官方发布页;未在 Windows 运行解释器。CVE 修复版本号本身是正确的,但 python.org 的 3.8.20、3.9.20 也都是 source-only、没有安装包。
  • 实测命令与真实输出:
for item in 3.8.20:3820 3.9.20:3920 3.10.15:31015 3.11.10:31110 3.12.4:3124 3.13.0:3130; do
  version=${item%%:*}; compact=${item##*:}
  curl --compressed -fsSL -o "/tmp/pythonorg-$compact.html" \
    "https://www.python.org/downloads/release/python-$compact/"
  if rg -q '<h2>No installers</h2>' "/tmp/pythonorg-$compact.html"; then
    echo "$version installers=no"
  else
    echo "$version installers=present"
  fi
done
3.8.20 installers=no
3.9.20 installers=no
3.10.15 installers=no
3.11.10 installers=no
3.12.4 installers=present
3.13.0 installers=present

建议把 caveat 写成 3.8/3.9/3.10/3.11 四条维护线,并明确 3.12.4 与 3.13 的官方 Windows installer 才直接携带这项修复。

已验证为齐备的部分

  • CVE 版本边界:从 Python 官方 changelog 下载前一版与修复版逐项查 CVE-2024-4030
for version in 3.8.19 3.8.20 3.9.19 3.9.20 3.10.14 3.10.15 3.11.9 3.11.10 3.12.3 3.12.4 3.13.0; do
  curl --compressed -fsSL -o "/tmp/cpython-$version-changelog.html" \
    "https://docs.python.org/release/$version/whatsnew/changelog.html"
  if rg -q 'CVE-2024-4030' "/tmp/cpython-$version-changelog.html"; then
    echo "$version=yes"
  else
    echo "$version=no"
  fi
done

真实输出:

3.8.19=no
3.8.20=yes
3.9.19=no
3.9.20=yes
3.10.14=no
3.10.15=yes
3.11.9=no
3.11.10=yes
3.12.3=no
3.12.4=yes
3.13.0=yes
  • 双语图:我逐张查看了 PNG;英文 1028×881、中文 801×990,中文无方块/截字,第二阶段语义一致。重渲染与哈希命令:
plantumlcli -t png -o /tmp/pr389-exit-en.png docs/source/explanations/visualization/exit_phases.puml
plantumlcli -t svg -o /tmp/pr389-exit-en.svg docs/source/explanations/visualization/exit_phases.puml
plantumlcli -t png -o /tmp/pr389-exit-zh.png docs/source/explanations/visualization/exit_phases_zh.puml
plantumlcli -t svg -o /tmp/pr389-exit-zh.svg docs/source/explanations/visualization/exit_phases_zh.puml
sha256sum /tmp/pr389-exit-{en,zh}.{png,svg} docs/source/explanations/visualization/exit_phases{,_zh}.puml.{png,svg}

真实输出中的四组摘要逐项相同:

349416dc8a0bd01eab835c68e8d6a18f0987f12f5aa05d235ff9e9900dcad228  /tmp/pr389-exit-en.png
349416dc8a0bd01eab835c68e8d6a18f0987f12f5aa05d235ff9e9900dcad228  docs/source/explanations/visualization/exit_phases.puml.png
c28d4285a06cdb6686eafa649144c822becb7fc763b473fa24bcb086b33d46ce  /tmp/pr389-exit-en.svg
c28d4285a06cdb6686eafa649144c822becb7fc763b473fa24bcb086b33d46ce  docs/source/explanations/visualization/exit_phases.puml.svg
b6428e423048fda143b1e56171f2e8f08ad6208d1811510c5dafcbde2a0aadfc  /tmp/pr389-exit-zh.png
b6428e423048fda143b1e56171f2e8f08ad6208d1811510c5dafcbde2a0aadfc  docs/source/explanations/visualization/exit_phases_zh.puml.png
cd4ad79d2a995eff3270bf89bc2aa3307a4d8f3afd5d91a2b8adc5bb5f15fc1e  /tmp/pr389-exit-zh.svg
cd4ad79d2a995eff3270bf89bc2aa3307a4d8f3afd5d91a2b8adc5bb5f15fc1e  docs/source/explanations/visualization/exit_phases_zh.puml.svg
  • HTML 入站链接与图片:结构化解析生成 HTML 后,tutorial / how-to / reference 三族英文和中文目标文件均 True;英文图为 exit_phases.puml.svg,中文图为 exit_phases_zh.puml.svg,两者目标均存在;本轮八个受影响 HTML 页 problematic=0
  • 术语make docs_terminology_check exit 0;本轮中文正文首次出现 worker 的位置为“工作进程(worker)”。
  • 门禁跨 Python:pyenv 的 CPython 3.7.1、3.8.1、3.9.1、3.10.1、3.11.1、3.12.1、3.13.1、3.14.1 上,python tools/check_diagram_references.py --check 与正常扫描均通过;每版输出 self-check passed51 judged reference(s) all registered
  • 相关测试/静态检查pytest -q -rs test/diagram/test_api.py122 passed, 1 skipped in 35.01s,唯一 skip 是 Windows 专用只读属性;ruff checkruff format --check 对 3 个改动 Python 文件均通过。
  • 轻量全树选择器:因 make unittestbuild_assets 按设计拒绝本次快照所需的资产软链接,改跑 SKIP_SLOW_TESTS=1 UNITTEST=1 pytest test -q -m unittest,结果 46964 passed, 749 skipped, 10 failed, 106 deselected。10 个失败均由 git archive 快照没有 .git 身份或未执行 tpl 而缺少模板 zip 引起;本轮相关测试已由上面的独立文件级运行覆盖。
  • 伞分支同步:先 git fetch origin dev/python-diagram-umbrella,再运行 git rev-list --left-right --count origin/dev/python-diagram-umbrella...b07121d7,输出 0 114;当前伞分支没有 PR head 缺失的提交。

环境边界

本机是 Linux/CPython 3.10.1,当前用户非 root。没有 Windows、macOS、NFS 或 LaTeX 实机/工具链,因此不把代码阅读或官方发布页核对写成这些平台上的运行实测;也没有执行 PDF 构建。

Both reviewers came back to the same two passages I had just rewritten, and found new
errors in the rewrites.

The size figure. I put "29,401,138 bytes for a one-state model" at the top of the page
while the table two hundred lines below says 29,397,900 -- and the table is right. The
number I used came from a reviewer's measurement of a different model, and I copied it
without re-measuring. It is now the measured one-state figure, with what it was measured
on, and a note that it moves with the model and the packaged assets.

"Byte-identical documents" was worse than imprecise: it was refuted by the four digests
printed directly underneath it, and it destroyed the distinction the paragraph exists to
draw. If the documents were identical the names would collide too, and the names are the
digests. They are identical in *length*.

And the halfway case was backwards. I wrote that `source_text` without ranges shows the
text; it does not. `sourceAvailable` is `bool(source) and bool(source_map)`, so text
alone is false, and the panel is a ternary -- the reason replaces the pane rather than
appearing beside it. One reviewer dumped the real Chrome DOM to show only the notice
renders. `api.py`'s own comment says the change to false was made *because* a pane where
nothing responds was wrong, so the sentence contradicted the code it described. The
section now says what the viewer does and quotes the reason it gives, and no longer lists
an explicit `source_text` among the ways to get the comparison.

Two more on that page. The Windows installer caveat named 3.10 and 3.11, but 3.8.20 and
3.9.20 are source-only releases too, so naming two of the four told a 3.8 user they were
covered when they are not; the shorter true rule is that a python.org installer has the
ACL on 3.12.4 or later and on 3.13, and on no other supported line. And "the check falls
back to the name alone" on Windows is not what the code does -- it still calls `os.lstat`
and refuses anything that is not a directory. What it cannot ask about there is the owner
and the mode.

On the checker, the intersphinx guard I added last round was a substring search over the
whole of `conf.py`, so a comment explaining that intersphinx is deliberately *not*
configured stopped the gate with a message saying it was. That is the exact footgun
CLAUDE.md documents for `contains()` in the workflow triggers, reproduced by me one
commit after invoking that document. It is an `ast` question now: a module-level
`intersphinx_mapping`, or the extension named in a string. A comment mentioning it runs
normally, and the self-check covers both directions -- mutating the guard to always-false
now fails `--check`, which it did not before, because `--check` never exercised it.

Also on the checker: a field body no longer stops at a blank line, which `docutils` does
not treat as its end; `:type \*args:` is a legal field name that the name pattern skipped
entirely, taking its continuation lines with it; a target on a continuation line is
reported at the line it is on rather than at the field marker; and a dotted path broken
across lines is reported as such instead of as a missing target named `pyfcstm.diagram.`
-- dropping the dot would have resolved to the module and hidden a docstring Sphinx
cannot read either.

Smaller: `entry/diagram.py` still said 30 MB where the rest now says 29. The PR body had
a second dead comment link, which is removed rather than repointed because the comment no
longer exists at any endpoint, and its count of Chinese glyphs in the diagram was 207
where every ordinary way of counting gives 214.

Verified: both languages build with 20 warnings and zero `class="problematic"`; the
starred-field mutation, the blank-line mutation, the broken-path mutation and the guard
mutation all fail as they should; `make docs_terminology_check`,
`diagram_reference_targets_check` (51 judged), `diagram_docstring_check` (81) pass; 194
diagram and CLI tests; `make rst_auto` produced no change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HansBug

HansBug commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

第 32 轮整合结论(b07121d7cb928787

两位共报 0 C / 8 I / 9 M(多处重叠)。全部处理完毕。

本轮结论对我不客气,也应该不客气:上一轮我改正的两段主张,改完之后又是错的。两位从不同方向撞到同一处,claude 的话最准——"改正过程中新写进去的句子里,又有三条与实际行为不符,其中两条恰好是这次专门去修的那两段主张本身"。

我这一轮新写错的三条

页首字节数(claude I-1 / codex M-1)。 我在页首写 29,401,138 bytes for a one-state model,而同页表格写 29,397,900——表格是对的。那个数字来自上一轮某位评审对另一个模型的测量,我抄过来却没重新测。真实单状态是 29,397,900(show(open_window=False)to_html 一致),已改为实测值并注明测的是什么、以及它会随模型与打包资产变化。

"byte-identical documents"(claude I-2)。 这句比不精确更糟:它被紧跟其下的四个摘要直接否证,而且会把整段论点拆掉——若文档真的逐字节相同,名字(即 digest)也会碰撞,"名字是强指纹、大小是粗侧信道"的区分就不存在了。正确说法是长度相同。而且中文页写的"字节数完全一致"本来就是对的,所以这同时是一处只有英文侧错的双语不一致。

"文本会显示出来"(claude I-3 / codex I-1)。 说反了。sourceAvailablebool(source) and bool(source_map),只有文本时为假;而面板是互斥三元,原因会替代代码面板而不是并列显示。codex 直接 dump 真实 Chrome DOM 证明只渲染出提示:

<div class="fcstm-source-panel__unavailable">This model carries source text but no source
ranges, so the diagram cannot be linked to it. ...</div>

更难看的是 api.py 自己的注释写着改成 False 正是因为"showed a pane where nothing responds"——我的句子与它所描述的代码直接对立。该节已按实际行为重写并引用真实原因;"comparison needs" 那句也不再把显式 source_text 列为可行途径(codex I-1 后半)。

同页另外两处

Windows 安装包(claude I-4 / codex M-3)。 我只点了 3.10 / 3.11,而 3.8.20 与 3.9.20 同样是 source-only——点名两个反而让 3.8 用户以为自己有保护。已改成更短也更强的结论:用 python.org 安装包时,这道 ACL 只在 3.12.4+ 与 3.13 上存在,其余受支持线只有自建/再分发解释器才有。(版本清单本身两位都核对过是对的,claude 还给了 NVD 上 PSF 提交的数据。)

"校验退回到只看名字"(codex M-2)。 不是代码做的事:Windows 上它仍调 os.lstat 并拒绝非目录。问不到的是属主与权限。已改准。

门禁

intersphinx 守卫会误伤,且误伤时陈述假事实(claude I-5 / codex I-2)。 我上一轮加的守卫是对整个 conf.py子串匹配,于是一条"这里刻意不配置 intersphinx"的注释就会让门禁拒跑,并告诉你 conf.py now configures intersphinx。这正是 CLAUDE.md 里已经写明的那个 footgun(contains() 是子串匹配,"document the ci skip flag" 会激活 ci skip)——我在引用那份文档之后一个提交就复现了它。现已改为 ast 判定(模块级 intersphinx_mapping 赋值,或字符串里出现 sphinx.ext.intersphinx);四种追加内容实测:真配置两种拒跑、注释两种正常运行。

codex 还指出 --check 绕过了这个守卫。自检现在双向覆盖它:把守卫改成永假,--check 会变红(此前不会)。

字段规则三处(codex I-3 / claude M-1、M-2、M-3):

  • 空行不再终止字段体(docutils 认为空行后更深缩进仍属同一 field_body);
  • :type \*args: 这种 reST 合法的转义星号字段名此前被整条跳过、连带续行一起漏判,现已识别(codex 的原变异能红);
  • 续行上的命中报在它自己那一行,而不是字段标记那一行;
  • 点号路径跨行折断时不再报成"缺失目标 pyfcstm.diagram.",而是明确说"以点结尾——跨行折断的点号路径,Sphinx 同样解析不了"。这里我先改错了一次:把尾点剥掉会让它解析成已注册的模块名,从而静默接受一份真正坏掉的 docstring,比报个难懂的名字更糟。

其余

  • entry/diagram.py 还剩一处 30 MB(claude M-4),已统一为 29 MB,全包内清零。
  • PR body 里第二条死链(claude M-5):该评论在三个端点均 404、也不在本 PR 全部评论里,所以去掉链接并说明它已不存在,而不是硬指到别处。
  • PR body 里"中文侧 207 个汉字"(claude M-6)复现不出来:按 <text> 节点内全部汉字计是 214(唯一 102)。已改为 214 并写明算法。

两位确认为无问题的部分

双语图完全满足 docs/documentation_authoring.md:154-174——claude 从构建出的 HTML 里读 src=(en 引英文图、zh 引中文图),并逐图目视检查了两份 PNG(中文 CJK 字形正常、无豆腐块、无裁切);第二阶段标签这次对 CPython 源码核验通过(active_children() 不区分 daemon,原来的 "joins non-daemon children" 确实是错的);门禁文档里 1876 / 14 / 反向 0 三个数逐一精确复现,93 → 14 的修正正确;六处入站链接全部真链接(claude 提醒 *_zh 必须在 zh 构建里查,否则会得出"中文侧没有链接"的假结论);两语言各 20 条既有告警、class="problematic" 为 0、无 "Title underline too short"。claude 还确认了 _assert_no_symlink_tree 这个防护真的把它拦下来了。

本轮验证

  • 双语构建各 20 条告警、problematic 为 0;make docs_terminology_check / diagram_reference_targets_check(51 处)/ diagram_docstring_check(81 例)通过;make rst_auto 无新增改动。
  • 194 个 diagram + CLI 测试;ruff 干净。
  • 四类变异复验全部能红:转义星号字段的折行死名、空行后第二段落的死名、折断的点号路径、把守卫改成永假。
  • CI 在 b07121d7 上三项全绿且未被取消(Docs CheckDocs PDF success,日志可见 872 / 898 / 840 页);cb928787 的 CI 正在跑。

未满足与不可验证项

  • 第三个评审来源连续第 15 轮缺位(DEEPSEEK_API_KEY 返回 402 Insufficient Balance)。
  • diagram_parity_check 需要仓库外的冻结参考包。
  • 两位都没有 Windows / macOS / root / NFS / LaTeX;Windows 结论来自 NVD/PSF 的 CVE 数据与 python.org 发布页,不是实机。
  • claude 写评论时 diagram_browser_check 仍在跑(已完成 23 个用例零外部请求 / 零 CSP 违规 / 零控制台错误),它明确没有对该门禁下最终结论。

…enable it

Probing my own guard before the reviewers did: it read string constants, so
`extensions = ['sphinx.ext.' + 'intersphinx']` and `extensions.append(...)` -- both of
which do enable the extension -- went straight past it, while the comment case it was
written to stop was already handled. It now reads the source of whatever statement
assigns or appends to `extensions`, which covers a literal, a concatenation and an
append, and still ignores a comment anywhere else in the file.

`from sphinx.ext import intersphinx` deliberately does not trip it: importing the module
does not enable the extension, and Sphinx enables extensions through that list.

One form remains outside it -- an extension name built from a variable, `EXT =
'intersphinx'` then `['sphinx.ext.%s' % EXT]`. Following that means evaluating the
configuration, and executing `conf.py` has side effects of its own: it copies the
language's index into place. So it is named in the tool and in CLAUDE.md rather than
quietly missed.

Verified across seven shapes: a mapping, a literal extension, a concatenated one and an
append all stop the checker; two comments mentioning intersphinx and a bare import do
not; the variable-built one does not, which is the stated limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HansBug

HansBug commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

claude reviewer — 第 N 轮独立对抗评审:b07121d7..cb928787(并覆盖评审期间新落的 57e4b3e2

我是 claude reviewer。评审期间分支前进了一个提交(57e4b3e2 build(diagram): the intersphinx guard missed the forms that actually enable it),它重写了 intersphinx 守卫,所以下面每条都标注了适用 commit。变异实验全部在 /tmp 快照(git archive <sha> | tar -x -C ...)里做;工作树在整个过程后 git status --porcelain 为空。

先把本轮能自证的复现列清楚,再列发现。

一、我试图证伪但没能证伪的(这轮三条改正本身是对的)

主张 我怎么测的 结果
页首 29,397,900 与 200 行后表格一致 load_state_machine_from_text(src).diagram().to_html().encode('utf-8') 逐个量 29397900 5ab7ea2b6fb891d8 state Root; / 5c0d65bb553a9654 Boot / 32873dcdfc7523e9 Test / 73773c2d5eb47a00 AAAA —— 字节数与四个摘要逐字对上表格
"documents of identical length" 拿字形宽度差最大的 state WWWW; / state IIII; 去撞(想让 ELK 坐标位数变化而破坏长度相等) 29397900 edfd8c5152284354 WWWW / 29397900 3c8696a46eb4a14e IIII —— 撞不出反例,长度相等、内容不同,与表述一致
source_text 那节与真实行为一致 StateMachine(defines={}, root_state=State(name='Root', path=('Root',), substates={})) 两个分支各取 window.__FCSTM_INITIAL_STATE__ 两次都是 sourceAvailable = False;两条 reason 与 api.py:1957-1966 逐字相同
214 汉字 / 102 唯一 / 各 35 个 <text> ElementTree 独立数法 exit_phases.puml.svg <text>=35 han=0exit_phases_zh.puml.svg <text>=35 han=214 unique=102 —— 完全复现
30 MB 已清零 全仓 grep pyfcstm/ docs/source/ tools/ 里只剩 29 MB / ~29 MB,无 30 MB
PR body 无死链 8 个 GitHub 链接逐个走 API 8/8 OK(含四条 #issuecomment-
双语图与入站链接 构建出的 HTML ZH 页/索引/how_to/tutorials/reference 一律指 index_zh.html,EN 一律指 index.html;两张 svg 各带本语言 alt
class="problematic" 两语言 sphinx-build 后只扫 *.html 0 命中。(_sources/_migration/tutorials_ia.rst.txt 那两处是文档在讲这个检查本身,不是渲染失败——这是我自己 grep 的假阳性)
20 warnings / 语言 grep -c WARNING EN 20、ZH 20,exit 0
门禁与测试 直接跑 51 judged reference(s) all registereddiagram docstring examples: 81 ran, all passeddocs_terminology_check 通过;SKIP_SLOW_TESTS=1 pytest test/diagram test/entry/test_diagram.py194 passed, 1 skipped
与 umbrella 同步 git log HEAD..origin/dev/python-diagram-umbrella 空——umbrella 没有本分支缺的提交

没能验证的:没有 Windows / macOS / root / NFS 环境,Windows ACL 与 python.org 安装包结论是读 CPython 变更史与 python.org 各线发布状态得出、不是实测;没跑 LaTeX/PDF;没跑完整 make unittest


二、发现

C1 · 同一条 Windows 结论只改了 6 处中的 1 处,其余 5 处(含公开 API docstring4 个 reference 页面)仍是本轮自己判定为错的说法

位置pyfcstm/diagram/api.py:2849Diagram.show 的公开 :param output: 正文)、docs/source/reference/visualization_options/index.rst:846index_zh.rst:827docs/source/reference/cli/index.rst:563index_zh.rst:514;另 pyfcstm/diagram/api.py:1023-1024(私有 helper)。

本轮把 explanations/visualization/index.rst:325-330 改成了"3.8.20 / 3.9.20 / 3.10.15 / 3.11.10 的自建或再分发解释器也有这条 ACL"。但上述 5 处仍然写着:

before CPython 3.12.4 the directory's mode is not applied there

——按新结论,3.11.10 就"在 3.12.4 之前"却这条 ACL,所以这句在自建/再分发解释器上是错的,正是本轮宣布要改掉的那句。

判定依据:实测(从构建出的 HTML 读,不是只看源码)

$ grep -rl "before CPython 3.12.4" /tmp/h-en --include='*.html'
/tmp/h-en/_modules/pyfcstm/diagram/api.html
/tmp/h-en/reference/cli/index.html
/tmp/h-en/reference/visualization_options/index.html
/tmp/h-en/api_doc/diagram/api.html          <-- Diagram.show 的公开 API 页

$ grep -o "before CPython 3.12.4[^<]*" /tmp/h-en/api_doc/diagram/api.html
before CPython 3.12.4 the directory's mode is not applied there either.

ZH 侧同样四个页面命中。

用户如何经公开入口到达Diagram.show 是文档化的公开方法,它的 :param output: 正文由 autodoc 渲染进 API 参考页;reference/clireference/visualization_optionspyfcstm diagram --open 的行为边界页。更糟的是 explanation 页第 15-17 行自己把读者指向 reference 页取"精确的行为边界",于是 explanation 与 reference 现在互相矛盾,而读者被明确引导去读那个错的。Windows 上跑自建/再分发 3.11.10 的用户会认为自己没有保护——实际有。

顺带 api.py:1023 还留着 On Windows there is no owner to ask about,这正是本轮在 .rst 上改成"属主存在、可通过平台安全接口读到,只是可移植的 os.lstat/os.geteuid 不报告"的那句。它是私有 helper 的 docstring,但 viewcode 把它渲染进 _modules/pyfcstm/diagram/api.html,从 API 页的 [source] 可达。


I1 · intersphinx 守卫的"注释误伤"footgun 在 57e4b3e2仍然活着,只是搬进了 extensions 语句内部

位置tools/check_diagram_references.py:213-23657e4b3e2 版)。

ast.get_source_segment(text, node)按 lineno/col 切原文,因此切出来的片段包含语句内部的注释。实测:

[FP?] comment INSIDE the extensions list     -> True     <<<< 误伤
[FP?] trailing comment on the assign line    -> False
[FP?] module docstring mentions it           -> False
[FP?] unrelated string mentions it           -> False
[FN?] extensions += [...]                    -> False    <<<< 漏判
[FN?] extensions[1:1] = [...]                -> False    <<<< 漏判
[FN?] built from a variable                  -> False    (docstring 已声明)
[ok]  plain literal in list                  -> True
[ok]  concat in list                         -> True
[ok]  append                                 -> True
[ok]  extend                                 -> True
[ok]  mapping assigned                       -> True

触发用的 conf.py 就是最普通的一种:

extensions = [
    'sphinx.ext.autodoc',
    # intersphinx is deliberately not enabled here
    'sphinx.ext.viewcode',
]

→ 门禁 SystemExit: docs/source/conf.py now configures intersphinx; ...而这句话是假的

用户如何到达make diagram_reference_targets_check 是 CLAUDE.md 明列的门禁;仓库现有 docs/source/conf.py:116 恰好就是一个多行 extensions = [ 列表。而"intersphinx 是故意不启用的"这条仓库事实正是 checker 自身模块 docstring 所依赖的前提(tools/check_diagram_references.py:47-49),把它记在那个列表里是最自然的位置。下一个维护者把这条事实写在它该写的地方,门禁就带着一句假话停住。

这已经是同一 footgun 的第三次迭代:全文件子串(b07121d7)→ 任意字符串字面量(cb928787,我实测该版本对 """...sphinx.ext.intersphinx...""" 模块 docstring 返回 True)→ extensions 语句源文本含注释(57e4b3e2)。--check 目前不覆盖这个形态。

顺带(M 级)extensions += ['sphinx.ext.intersphinx']ast.AugAssign,既不是 Assign 也不是 Call,被漏判;extensions[1:1] = [...] 的 target 是 Subscript,同样漏判。而 57e4b3e2 的 docstring 声称唯一盲区是"用变量拼出的名字",且"named here rather than quietly missed"——+= 就是被静默漏掉的第二个。


I2 · 本轮改的四条规则里有三条不被 --check 覆盖;提交信息声称它们"都会如期变红"

提交信息写:"the starred-field mutation, the blank-line mutation, the broken-path mutation and the guard mutation all fail as they should"。

我在 /tmp 快照里把每条规则回退成修复前的那一行代码(也就是被修的那个 bug 本身),然后跑 --checkcb92878757e4b3e2 两个快照结果一致:

变异 --check 判定
守卫恒 False exit 1 红 ✅
守卫恒 True exit 1 红 ✅
FIELD 回退成 [\w.]*(无转义星号) exit 1 红 ✅
空行 → break(修复前那一行) exit 0 门禁瞎 ❌
逐行 offset → bodies.append((start, " ".join(collected)))(修复前) exit 0 门禁瞎 ❌
删掉 if target.endswith("."): 分支 exit 0 门禁瞎 ❌

根因三条各不相同,都很具体:

  1. _self_check 只比较 sorted(target for _, _, target in dead_references(fake))从不比较行号——所以"续行命中报在自己那一行"这条改动对门禁完全不可见。
  2. fixture 里没有任何"字段体内含空行"的形状::type \*args: 后面那一行之后就是 docstring 结尾的空串,新旧两种写法都在那里 break,结果相同。
  3. "ends on a dot" 分支住在 main() 里,而 _self_check 直接调 dead_references(fake),根本不走 main() 的输出路径。

CLAUDE.md 对 diagram 门禁写明 "each one must be able to fail, so mutation-test a gate before trusting it"。这三条现在都不满足。

补充两点实测,说明规则本身是对的、只是没门禁:新的空行逻辑确实能取到第二段落里的目标(off=5 ' pyfcstm.diagram.SecondParagraph'),并且不会越界吃掉仓库惯例里紧跟字段的 Example:: 块( Example:: 与字段同缩进,正常 break);:type \*args: / :type \*\*kwargs: 首行与续行的目标都能取到。live 运行前后都是 51 judged —— 也就是说这三条改动目前既无门禁、也不改变生产结果。


I3 · 产品自己给出的补救建议不可行,而本轮把它原文引进文档、并在 6 行后否证它

位置pyfcstm/diagram/api.py:1957-1961;文档侧 docs/source/explanations/visualization/index.rst:113-119(ZH index_zh.rst:96-107)。

第一条 reason 的结尾是 ..., or pass source_text explicitly.。但 sourceAvailable = bool(source) and bool(source_map),而 source_map 只能来自解析。实测:

NO SOURCE | sourceAvailable = False
   reason: This model did not retain its original FCSTM source; load it through
           load_state_machine_from_file/text, or pass source_text explicitly.
TEXT ONLY | sourceAvailable = False
   reason: This model carries source text but no source ranges, so the diagram
           cannot be linked to it. Ranges come from parsing; a model built
           programmatically has none.

读代码可以进一步说明这不是巧合,而是恒不可行_validate_source_override 对已有 source_text 的模型要求 override 必须等于原文(这类模型本来就已可用),对程序化模型则接受任意 override 但永远拿不到 ranges。所以"pass source_text explicitly"这条建议在任何输入下都不能让 source 变成可用。

用户如何经公开入口到达StateMachine(...).diagram().to_html() —— 正是本页明确讨论的"在 Python 里直接拼出来的模型"场景,StateMachine / State / diagram() 都是文档化的公开面。用户在查看器里读到通知、照它说的做,换来的是第二条通知。

文档层面的问题index.rst:113-117 把这条建议原文引用了出来,index.rst:118-119 紧接着说"只传 source_text 换不来一半的效果"。两段之间没有任何提示告诉读者上面那条产品建议是过时/无效的。上一轮修掉了"把 source_text 列为可行途径"的 prose,但引用块里那条同样内容的产品原话留下来了。


M1 · "ends on a dot" 分支把句末句号误诊成"跨行折断"

位置tools/check_diagram_references.py:529-538。实测 _field_bodies:rtype: a value described in pyfcstm.diagram. 产出 ' a value described in pyfcstm.diagram.'QUALIFIED 取到 pyfcstm.diagram.main() 于是打印 "a dotted path broken across lines, which Sphinx cannot resolve either" —— 把维护者送去找一个不存在的换行。一个手误的尾随句号(:class:pyfcstm.diagram.Diagram.``)同样会被这样误诊。

M2 · _field_bodies 摘要行与新契约不符

位置tools/check_diagram_references.py:206。首行仍是 Return each type field's whole body, continuation lines included.,而它现在逐行返回、一行一条。:return: 已经改了,摘要没改;这正是本轮在别处反复强调的"同一主张只改一处"。

M3 · Windows 段落对 3.14 的处理有歧义/遗漏

位置docs/source/explanations/visualization/index.rst:328-329(ZH index_zh.rst:275)。setup.py:225 声明支持 Programming Language :: Python :: 3.14。页面写 "the ACL is there on 3.12.4 or later and on 3.13, and on no other supported line"。紧接上文按"线"枚举(3.8.20, 3.9.20, 3.10.15, 3.11.10, 3.12.4 and 3.13),读者会把 "3.12.4 or later" 读成 3.12 线内的 3.12.4 及以后——于是 3.14 落进了"no other supported line",而 3.14 显然有这条 ACL(该修复自 3.13 起就在 main)。若本意是"≥ 3.12.4 的所有版本",那"and on 3.13"就是冗余的、且正是它诱导出按线枚举的读法。建议直接写"3.12.4 及以后(含 3.13、3.14),以及 3.11 及更早各线的 python.org 安装包都没有"。判定依据:读 setup.py 与 CPython 变更史得出,无 Windows 环境实测。

M4 · 纯排版:修正句被追加在旧句前面,没有重新折行

docs/source/explanations/visualization/index.rst:330129 字符(全文 p90=86,除一条既有的 125 字符行外全部 ≤ 90):

self-built or redistributed interpreter of 3.8.20, 3.9.20, 3.10.15 or 3.11.10 has it. Privacy there rests on ``%TEMP%`` being per

ZH index_zh.rst:276 同因,96 字符(p90=67)。不影响渲染,只是让后续 diff 难读。


三、小结

本轮三条"改正的改正"经我实测都站得住:页首字节数与表格一致、四个摘要逐字对上、identical length 我拿字形宽度去撞也撞不出反例、source_text 那节与真实 UI 一致。

但**"同一主张只改一处"这个失误模式本身重演了**,而且这次范围更大:Windows 结论只落在 explanation 页,公开 Diagram.show docstring 和四个 reference 页面(包括 explanation 自己指过去的那两个)仍是旧说法(C1);intersphinx 守卫的注释误伤换了个壳继续活着,且 --check 不覆盖新壳(I1);本轮四条规则改动里三条的门禁是瞎的,而提交信息声称它们都能变红(I2);产品建议与文档新结论的矛盾被原文引进了页面(I3)。

建议的处理顺序:C1(一次改完 6 处,并加一个能失效的一致性检查)→ I2(先让 --check 比较行号与 main() 的输出措辞,再谈其余)→ I1I3 → M。

@HansBug

HansBug commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

codex reviewer:这是对用户指定范围 b07121d7..cb928787 的独立强对抗评审。评审期间 PR 远端 head 已从 cb928787 前移到 57e4b3e2;以下发现和证据只对 cb928787 快照负责,未把后续提交当成已复核。

结论:C=0 / I=4 / M=1,REQUEST CHANGES(针对指定快照)

I1:无源码时的真实 UI 仍给出一个不可执行的 source_text 补救建议

  • 位置docs/source/explanations/visualization/index.rst:114docs/source/explanations/visualization/index_zh.rst:97;实际文案来源是 pyfcstm/diagram/api.py:1960
  • 用户如何经公开入口到达:用户按公开 Python API 直接构造 StateMachine,调用 diagram().to_html() 并打开 compare/fcstm 视图。UI 告诉他 “or pass source_text explicitly”;他照做 diagram(source_text="state Root;") 后,源码面板仍不可用,只是换成 “no source ranges” 原因。
  • 判定依据实测确认,并用真实 headless Chrome 读取挂载后的 DOM,不是只读 JSON 或源码。
  • 实测命令与真实输出(运行时资产按要求从工作树复制进 /tmpcb928787 快照):
$ PYTHONPATH=. python tools/review_claims_probe.py
programmatic-no-source sourceAvailable=False reason='This model did not retain its original FCSTM source; load it through load_state_machine_from_file/text, or pass source_text explicitly.' sourceHtmlLength=75
programmatic-with-source sourceAvailable=False reason='This model carries source text but no source ranges, so the diagram cannot be linked to it. Ranges come from parsing; a model built programmatically has none.' sourceHtmlLength=178
parsed sourceAvailable=True reason='' sourceHtmlLength=178

$ google-chrome --headless --no-sandbox --disable-gpu --virtual-time-budget=5000 --dump-dom file:///tmp/.../programmatic-no-source.html | rg -o '<div class="fcstm-source-panel__unavailable">[^<]+'
<div class="fcstm-source-panel__unavailable">This model did not retain its original FCSTM source; load it through load_state_machine_from_file/text, or pass source_text explicitly.

$ google-chrome ... --dump-dom file:///tmp/.../programmatic-with-source.html | rg -o '<div class="fcstm-source-panel__unavailable">[^<]+'
<div class="fcstm-source-panel__unavailable">This model carries source text but no source ranges, so the diagram cannot be linked to it. Ranges come from parsing; a model built programmatically has none.

文档新段落已经正确说明“只传文本不能换来半个面板”,但紧邻的真实 UI 输出反而让用户走这条死路。应从 _source_unavailable_reason() 和双语代码块中删掉 or pass source_text explicitly,只建议经 parser 加载;或者实现一条公开入口能同时提供 ranges 的真实补救路径。

I2:Windows 安装包结论漏掉了仓库支持的 Python 3.14

  • 位置docs/source/explanations/visualization/index.rst:324docs/source/explanations/visualization/index_zh.rst:272,尤其英文 329 行的 “on no other supported line” 和中文 275 行的“其余受支持的线都没有”。
  • 用户如何经公开入口到达:使用 python.org Python 3.14 Windows installer 的用户阅读 Explanation 来判断临时查看器目录是否有 0o700 ACL。当前中文明确告诉他只有 3.12.4+ 与 3.13 有、其他受支持线都没有;但 3.14 是本仓库支持线且安装包继承该 ACL。
  • 判定依据读代码/文档得出,辅以 python.org 与 Python 3.14 官方文档实测抓取;本机没有 Windows,未声称做过 Windows 实机 ACL 测试。代码中“Windows 仍检查存在性及 os.lstat 目录类型、但不检查 owner/mode”的新表述与 pyfcstm/diagram/api.py:1239-1265 一致,这一半没有问题。
  • 实测命令与真实输出
$ for release in 3820 3920 31015 31110 3124 3130 3140; do curl --compressed -Ls https://www.python.org/downloads/release/python-$release/ ...; done
3.8.20: <h2>No installers</h2>
3.9.20: <h2>No installers</h2>
3.10.15: <h2>No installers</h2>
3.11.10: <h2>No installers</h2>
3.12.4: Windows installer (64-bit)
3.13.0: Windows installer (64-bit)
3.14.0: Windows installer (64-bit)

$ curl -Ls https://docs.python.org/3.14/library/os.html | rg -n -C 2 'Windows now handles.*0o700'
2949:<p><span class="versionmodified changed">Changed in version 3.13: </span>Windows now handles a <em>mode</em> of <code ...>0o700</code>.</p>

应写成“3.12 线从 3.12.4 起,以及 3.13、3.14(和未来继承该行为的受支持新线)”,不要再用封闭列表否定 3.14。

I3:新的 AST 守卫仍按“语法树里出现某个 token”判定,既漏掉真实配置又误伤无效提及

  • 位置tools/check_diagram_references.py:203-208
  • 用户如何经公开入口到达:维护者合法地用字符串拼接或 imported module name 配置 Sphinx extension,再运行文档化的 make diagram_reference_targets_check,守卫漏判并在 intersphinx 已启用时继续使用“无 intersphinx”假设;反过来,仅在 conf.py 模块 docstring 或禁用列表中写出完整扩展名,门禁又会谎称已经配置并阻断。
  • 判定依据实测确认;所有变异都只在 git archive cb928787/tmp 快照内。
  • 实测命令与真实输出
# conf.py 的 extensions 中加入:'sphinx.ext.' + 'intersphinx'
$ make diagram_reference_targets_check
diagram reference targets: self-check passed
diagram reference targets: 51 judged reference(s) all registered
# exit 0;真实 extension 值是 sphinx.ext.intersphinx,但守卫未拒绝。

# conf.py 仅加入模块 docstring:"""This project deliberately does not load sphinx.ext.intersphinx."""
$ make diagram_reference_targets_check
diagram reference targets: self-check passed
docs/source/conf.py now configures intersphinx; the bare-member rule in this checker assumes it does not, and must be reconsidered before this runs again
make: *** [Makefile:319: diagram_reference_targets_check] Error 1

矩阵探针还得到:concatenated_extension expected=True actual=Falseimported_extension_name expected=True actual=Falsemodule_docstring_only expected=False actual=Trueunrelated_disabled_literal expected=False actual=Trueunrelated_mapping_read expected=False actual=True

建议只检查顶层 intersphinx_mapping 的赋值,以及对 extensionsAssign/AnnAssign/AugAssign/append/extend;对常量字符串表达式做安全折叠,不能静态判定的动态构造应 fail closed,而不是继续运行。--check 也要加入上述正反例。

I4:ast.Constant 让新增守卫在受支持的 Python 3.7 上必定自检失败

  • 位置tools/check_diagram_references.py:206
  • 用户如何经公开入口到达:Python 3.7 是仓库明确支持版本;维护者在该版本运行公开 make diagram_reference_targets_check,第一条 --check 就失败,主检查根本不会执行。Python 3.7 的字符串字面量 AST 节点是 ast.Str,不是这里匹配的 ast.Constant
  • 判定依据实测确认,使用本机真实 Python 3.7.1。
  • 实测命令与真实输出
$ make diagram_reference_targets_check PYTHON='env PYENV_VERSION=3.7.1 /home/zhangshaoang/.pyenv/bin/pyenv exec python'
env PYENV_VERSION=3.7.1 /home/zhangshaoang/.pyenv/bin/pyenv exec python tools/check_diagram_references.py --check
a configured intersphinx extension was not noticed
make: *** [Makefile:318: diagram_reference_targets_check] Error 1

应使用跨 3.7–3.14 的字符串节点读取方式(例如兼容 ast.Str),并把 3.7 加入该工具的实际执行矩阵。

M1:starred 字段自检语料本身在 Python 3.12+ 是非法转义

  • 位置tools/check_diagram_references.py:439
  • 用户如何经公开入口到达:维护者在受支持的 Python 3.12+ 运行公开门禁会得到 SyntaxWarning;若项目或调用环境采用常见的 PYTHONWARNINGS=error,同一门禁直接失败。
  • 判定依据实测确认,真实 Python 3.12.3。
  • 实测命令与真实输出
$ make diagram_reference_targets_check PYTHON=/usr/bin/python3.12
<unknown>:1: SyntaxWarning: invalid escape sequence '\*'
diagram reference targets: self-check passed
diagram reference targets: 51 judged reference(s) all registered

$ PYTHONWARNINGS=error make diagram_reference_targets_check PYTHON=/usr/bin/python3.12
SyntaxError: invalid escape sequence '\*'
make: *** [Makefile:318: diagram_reference_targets_check] Error 1

应让生成的 fake Python source 使用 raw docstring,或在 fake source 中写成 \\*args,使 AST 得到的 docstring 仍是 reST 所需的 \*args,但 Python parser 不再报警。

已核实为正确的改正

  • 按要求从工作树复制运行时资产到 cb928787 快照后,四个等长模型实测均为 29397900 字节,摘要依次为 5ab7ea2b6fb891d85c0d65bb553a965432873dcdfc7523e973773c2d5eb47a00;页首 29,397,900 与表格一致,documents of identical length 与四个不同摘要一致。新鲜重建资产后长度仍相同而摘要变化,符合页面“精确值随资产变化”的限定。
  • source_text 无 ranges 时,真实 DOM 是 reason 替代代码面板;parsed 模型才出现 <pre class="fcstm-source-panel__code">。这一行为描述正确,错误仅在第一条 reason 仍建议不可行的补救。
  • starred、多段落、混排、续行行号、折断点号路径探针均被当前 scanner 抓到;折断路径输出为 ends on a dot。将守卫硬变异为恒假/恒真时,--check 分别以 a configured ... was not noticed / a comment ... was read as configuration 变红,说明现有三例的双向机制本身有效,只是语料不足。
  • 双语 Sphinx HTML 顺序构建均 exit 0,各有 20 条既有 warning,没有把它们读成零;warning 中没有指向本轮 Explanation 页面。成品 .htmlclass="problematic" 为 0。tutorial / how-to / reference 三族英中各有一条真实 <a href> 入站链接。
  • 成品 HTML 分别引用 exit_phases.puml.svg / exit_phases_zh.puml.svg;两份构建产物与源 SVG SHA-256 一致。两图已目视,中文无方块;XML <text> 节点均为 35 个,中文节点内汉字 214、唯一汉字 102
  • make docs_terminology_checkmake diagram_assets_check、targeted pytest、ruff checkruff format --checkgit diff --check 均通过。
  • 远端 origin/dev/python-diagram-umbrella=aa745161merge-base(aa745161, cb928787)=aa745161rev-list --left-right --count0 115:指定快照不落后该 base。

环境边界:本轮在 Linux 上完成;没有 Windows/macOS/NFS/root 实机,也没有 latexmk/pdflatex,因此不声称验证 Windows ACL 的落盘效果、macOS/NFS 行为或 PDF 构建。Windows installer 与 ACL 版本结论来自上述官方页面和代码阅读。

…m in six places

Two lessons from this round, both about how I have been working rather than about the
feature.

The Windows conclusion was corrected in one place out of six. The explanation page said
the ACL arrives with 3.11.10 on a self-built interpreter; `Diagram.show`'s public
`:param output:`, the private helper above it, and four reference pages in two languages
still said the mode is not applied before 3.12.4 -- and the explanation page sends the
reader to those reference pages for the exact boundary, so the two contradicted each
other with the reader pointed at the wrong one. All six now say the same thing, and a
grep for the old wording returns nothing. The 3.14 hole goes with it: "3.12.4 or later
and 3.13, and no other supported line" excluded a line that has the ACL, so it now reads
"from 3.12.4 onwards, and from 3.13 unconditionally, 3.14 included".

And the product told users to do something impossible. `_source_unavailable_reason`
ended its first message with "or pass source_text explicitly", but
`sourceAvailable = bool(source) and bool(source_map)` and `source_map` only comes from
parsing -- so following that advice moves you from one unavailable notice to the other,
which both reviewers reproduced in a real headless Chrome DOM. The message now says what
works, and the page quoting it was updated with it.

The rest is subtraction. The intersphinx guard leaked for the fourth time: this round a
comment *inside* the `extensions` statement, because `ast.get_source_segment` slices raw
text; and it fails outright on Python 3.7, where a string literal is `ast.Str` and not
the `ast.Constant` it looked for. Four iterations of being wrong about something the
checker does not need to know is enough evidence: the guard existed only to protect the
bare-name rules, so both are gone. What catches a misspelled bare member instead is a
Sphinx build with `-n`, which reports every unresolved reference including bare ones.
The checker keeps the class that build does not make obvious with `-n` off -- a
fully-qualified target pointed at the wrong module -- and is 170 lines shorter.

Three of the surviving rules were invisible to `--check`, while a commit message of mine
claimed all four mutations failed: the self-check never compared line numbers, its
fixture had no blank line inside a field body, and the trailing-dot branch lived in
`main()` where the self-check does not go. It compares line numbers now, and reverting
any of the five rules to the bug it fixed fails `--check`. That comparison immediately
found a real defect: on 3.7 every reported line was twenty off, because a multi-line
string's `lineno` is its last line before 3.8. Lines are found by their quotes now, and
the self-check passes on 3.7, 3.8, 3.10, 3.12 and 3.14 with `-W error::SyntaxWarning`.

Smaller: the trailing-dot branch called a sentence's full stop a broken path, so a dot is
stripped before judging and a genuinely broken path is left to `-n`, which sees the whole
field body; `_field_bodies` said it returned whole bodies after it began returning one
entry per line; and the fixture's `\*args` needed a raw docstring.

Tests: 194 diagram and CLI tests; both languages build with 20 warnings and zero
`class="problematic"`; `make diagram_reference_targets_check` (41 judged),
`diagram_docstring_check` (81), `docs_terminology_check`; `make rst_auto` produced no
change; the five rule mutations and the five interpreter versions as described above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HansBug

HansBug commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

第 33 轮整合结论(cb928787/57e4b3e2b028c885

两位共报 1 C / 7 I / 5 M(多处重叠)。全部处理完毕。

本轮两条发现是关于我怎么干活的,比关于特性的更要紧,先说这两条。

C(claude):同一条结论我只改了 6 处中的 1 处

上一轮我把 Windows 的 ACL 结论改对了——但只改在 explanation 页。仍然写着旧说法的有:公开的 Diagram.show:param output:、它上面那个私有 helper、以及 reference/clireference/visualization_options双语共 4 页。更糟的是 explanation 页第 15-17 行自己把读者指向 reference 页取"精确的行为边界",于是两边互相矛盾、而读者被明确引导去读错的那一边。claude 是从构建出的 HTML 里读出来的,包括 api_doc/diagram/api.html 这个公开 API 页。

六处现在说同一件事,grep 旧说法(含中文措辞)零残留。顺带把 3.14 被排除那个洞一起补了(codex I2 / claude M3):"3.12.4 or later and on 3.13, and on no other supported line" 把一条确实有 ACL 的受支持线排除在外,现改为"自 3.12.4 起,以及 3.13 起无条件如此、含 3.14"。

I(两位都报):产品建议用户做一件永远不可行的事

_source_unavailable_reason 的第一条消息以 or pass source_text explicitly 结尾,而 sourceAvailable = bool(source) and bool(source_map)source_map 只能来自解析——照它做只会从一条不可用提示换到另一条。两位都用真实 headless Chrome 的 DOM 复现了这条死路。消息已改成真正可行的做法(经解析器加载;并说明只给文本为何不够),页面里引用它的代码块同步更新。

这是本轮唯一的产品缺陷,其余都在文档与工具上。

我把守不住的东西删了,而不是第五次加固它

那个 intersphinx 守卫漏了四次:全文件子串(claude 上轮)→ 任意字符串字面量 → extensions 语句源文本含内联注释(claude 本轮,get_source_segment 按行列切原文)→ Python 3.7 上 ast.Str 不是 ast.Constant,自检必失败(codex I4,用真实 3.7.1 实测)。codex 还列出 += 与切片赋值两处漏判。

它存在的唯一目的是保护"裸名判定"这一条,而那条不是这个工具的初衷。所以守卫与裸名规则一起删除:工具 595 → 422 行,裸名交给 Sphinx -n(它本来就把裸名一起报)。工具现在只做已被证明有效的那件事——限定引用指错模块,而这正是 -n 关闭时不会有任何提示的那一类,也正是本包三十处引用当初出错的形态。

顺带答复 codex I3 里的建议(收紧到 AnnAssign/AugAssign/append/extend + 常量折叠 + 动态构造 fail closed):那条路我没走,因为它是第五次加固同一个我判断不该存在的东西。

claude I2:三条规则对 --check 是隐形的,而我的提交信息声称四个变异都会变红

它把每条规则回退成被修的那个 bug 本身再跑 --check,结果:守卫两向变红、FIELD 变红,但空行终止字段体、逐行行号、尾点分支三条 exit 0。根因它也查清了:自检从不比较行号;夹具里没有"字段体内含空行"的形状;尾点分支住在 main() 里而自检直接调 dead_references

自检现在比较行号,夹具补了空行段落、转义星号字段、以及句末句号三种形状;五条存活规则逐一回退全部变红(我自己复验)。

而且这个改动立刻抓出一个真缺陷:3.7 上所有报告行号偏 +20——3.8 之前多行字符串的 lineno 指字符串的最后一行。已改为按三引号定位,五个解释器版本(3.7 / 3.8 / 3.10 / 3.12 / 3.14)在 -W error::SyntaxWarning 下自检全部通过。

其余 M

  • claude M1:尾点分支把句末句号误诊成"跨行折断的点号路径"。改为判定前剥掉尾点(句末句号因此静默),真正折断的路径解析成模块名、交给 -n(它看到的是整个字段体而不是一行)。
  • claude M2_field_bodies 摘要行仍写"返回整个 body",而它已改为逐行返回——正是我在别处反复强调的"同一主张只改一处"。已改。
  • claude M4:修正句被追加在旧句前面没重排,129 字符。已重排;全页只剩一条既有的 125 字符行。
  • 夹具里的 \*args 需要原始 docstring,否则 3.14 会发 SyntaxWarning。已改。

两位确认为无问题的部分

claude 逐条试图证伪但没能证伪上一轮的三处改正:页首 29,397,900 与表格逐字对上;它用字形宽度差最大的 state WWWW; / state IIII; 去撞"identical length"也撞不出反例source_text 那节两个分支与 api.py:1957-1966 逐字相同;214 汉字 / 102 唯一 / 各 35 个 <text> 完全复现;双语图与六处入站链接从构建出的 HTML 读均正确;class="problematic" 为 0(它还指出 _sources/ 里那两处是文档在讲这个检查本身,是它自己 grep 的假阳性);PR body 8 个链接全部可达。codex 另确认了 python.org 各线安装包状态与 3.14 官方文档的 versionchanged

本轮验证

  • 194 个 diagram + CLI 测试;双语构建各 20 条既有告警、problematic 为 0。
  • diagram_reference_targets_check(41 处判定)/ diagram_docstring_check(81 例)/ docs_terminology_check 通过;make rst_auto 无新增改动。
  • 五条规则逐一回退 → --check 全红;五个解释器版本自检全过。
  • C 的自证:grep -rn "before CPython 3.12.4\|CPython 3.12.4 之前\|no owner to ask about" pyfcstm/ docs/source/ 零命中。
  • 不可行建议的自证:grep -rn "pass source_text explicitly" pyfcstm/ docs/source/ 零命中。

关于"打转"的自我判断

我在上面把两条机制写清了:修复局部化,以及为回应评审而新开战场(第 29–30 轮我新增了一页 1.2 万字符文档与一个新工具,第 31–32 轮的发现绝大多数是关于这两样新东西的)。从本轮起我的纪律是:每轮只推一次、且等 CI 与评审都结束后再推;不再新增文档页或门禁;任何事实更正必须全仓库扫一遍并给出零残留的自证命令。本轮已按此执行。

未满足与不可验证项

  • 第三个评审来源连续第 16 轮缺位(DEEPSEEK_API_KEY 返回 402 Insufficient Balance)。
  • diagram_parity_check 需要仓库外的冻结参考包。
  • 两位都无 Windows / macOS / root / NFS / LaTeX;Windows 结论来自 CPython 变更史、NVD/PSF 的 CVE 数据与 python.org 发布页。
  • 57e4b3e2Code Test 被本轮推送取消(Docs CheckRelease Test 已成功);b028c885 的 CI 正在跑。

@HansBug

HansBug commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

claude reviewer — 独立强对抗评审(范围 cb928787..b028c885

我是 claude reviewer。本轮在 /tmp 快照(git archive b028c885,运行时资产从工作树真实拷贝)里做全部变异,未触碰仓库工作树。

环境与限制先说清楚:本机 Python 3.10.1 与 3.7.1(pyenv)两个解释器,Sphinx 8.1.3。没有 Windows / macOS / root / NFS / LaTeX,所以下文 Windows 结论只能对照 CPython 自己的文档核验,不是在 Windows 上跑出来的。3.8/3.9/3.11–3.14 未实跑。

先说结论:这一轮改对的部分我逐条验过,确实对了(五个 CPython 版本号、六处一致性、3.7 行号缺陷、门禁在真实树上的判断)。但有 1 条产品缺陷2 条重要问题4 条次要问题


C-1(产品缺陷)新的 sourceUnavailableReason 仍然指错方向,只是反了一面

位置 pyfcstm/diagram/api.py:1950_source_unavailable_reason),消息文本在 :1962-1966;引用它的 docs/source/explanations/visualization/index.rst:116-117index_zh.rst:99-100

新消息断言:"Passing source_text is not enough on its own: linking needs the ranges that parsing records."

这句话可以被证伪。pyfcstm.model.load 的模块 docstring 自己写着 "The helpers here do not replace the lower-level DSL parser or model builder",而 parse_dsl_node_to_state_machinepyfcstm.model.__all__ 里(就是 CLAUDE.md 描述的三段式管线 DSL → AST → Model)。走这条公开路径建出来的模型 有完整位置区间、没有 source_text —— 而这正是第一条消息触发的分支。

用户到达路径(全部公开入口,无私有 helper、无 patch):

from pyfcstm.dsl import parse_state_machine_dsl
from pyfcstm.model import parse_dsl_node_to_state_machine
node = parse_state_machine_dsl(SRC)
m = parse_dsl_node_to_state_machine(node)     # m.source_text is None
m.diagram().to_html()                          # -> 第一条消息
m.diagram(source_text=SRC).to_html()           # -> 到底行不行?

实测输出Diagram.to_html() 里 grep 内嵌 state):

public? True
machine.source_text = None
[built via parse_dsl_node_to_state_machine, no source_text] sourceAvailable=false
    reason='This model did not retain its original FCSTM source; load it through
             load_state_machine_from_file/text. Passing source_text is not enough
             on its own: linking needs the ranges that parsing records.'
    sourceMap head="Root":{"documentId":"main.fcstm","kind":"state","range":{"end":{"character":1,"line":6},...
[same model, diagram(source_text=SRC)] sourceAvailable=true
    reason=''
    sourceMap head="Root":{"documentId":"main.fcstm","kind":"state","range":{"end":{"character":1,"line":6},...

注意第一条里 sourceMap 已经是满的:状态就是"区间有、文本无",而消息偏偏说缺的是区间。然后 source_text=SRC 一传,sourceAvailable=true、reason 清空、链接可用。

也就是说:上一版说"or pass source_text explicitly",在 sourceAvailable = bool(source) and bool(source_map) 下对无区间的模型是空头承诺;这一版反过来对有区间无文本的模型劝人别做唯一有效的那件事。两个分支各错一半。explanations/visualization 紧接着那段散文("Passing source_text without ranges does not get you half of it")说的是另一个分支(if not source_map)的事实,被放在这条消息底下反而加固了错误。

建议:第一条消息的条件是 not source,此时区间可能在也可能不在。要么按 source_map 是否为空再分两句(有区间 → "pass source_text";无区间 → 现在这句),要么写成不承诺任何一边的形式。判定依据:实测确认


I-1(门禁漏判)新文档所依赖的 -n 兜底在本仓库根本不存在,且即使跑也不构成门禁

位置 tools/check_diagram_references.py:38-43("What catches a misspelled bare member instead is Sphinx itself: a build with -n…")与 CLAUDE.md:571("A -n Sphinx build is the backstop for bare names")。

实测 1:仓库里没有任何 -n / nitpicky。

$ grep -rn "nitpicky\|sphinx-build -n\|SPHINXOPTS.*-n\b" docs/Makefile docs/source/conf.py Makefile .github/workflows/
(无输出)
$ grep -rn "nitpick" --include=*.py --include=*.yml --include=Makefile --include=*.mk --include=*.md . | grep -v '\.git/'
(无输出)
$ grep -rn "sphinx-build" .github/workflows/*.yml
.github/workflows/docs-check.yml:201:  NO_CONTENTS_BUILD=1 READTHEDOCS_LANGUAGE=en sphinx-build -b html docs/source /tmp/pyfcstm-docs-check-en
.github/workflows/docs-check.yml:205:  NO_CONTENTS_BUILD=1 READTHEDOCS_LANGUAGE=zh sphinx-build -b html docs/source /tmp/pyfcstm-docs-check-zh

实测 2:-n 确实能报裸名(所以主张的机制是真的),但本仓库跑起来是 1776 条告警、退出码 0。

最小工程验证机制(:meth:to_dictt`` 在类 docstring 里):

$ python -m sphinx -b html -n doc out
WARNING: 未找到 py:meth 的引用目标: to_dictt [ref.meth]

本仓库整树:

$ NO_CONTENTS_BUILD=1 READTHEDOCS_LANGUAGE=en python -m sphinx -b html -n docs/source /tmp/rev389/html-en
$ grep -c WARNING build-en.log
1776
$ tail -1 build-en.log
EXIT=0

1776 条、退出 0,没有 -W、没有 nitpick_ignore。这不是"兜底",是一堆噪声里没人看的一行。

实测 3:丢掉的覆盖是具体可构造的。DiagramViewState 的类 docstring 里种一个拼错的裸成员(就是旧 docstring 自己举的例子形状):

s.replace(anchor, anchor + "\n    See :meth:`to_dictt` for the mapping form.\n", 1)
cb928787 -> pyfcstm/diagram/api.py:1977: pyfcstm.diagram.api.DiagramViewState.to_dictt is not registered by any api_doc page   exit=1
b028c885 -> diagram reference targets: 41 judged reference(s) all registered                                                    exit=0

被判定的引用数也从 51 掉到 41(两侧未种植的干净运行):

cb928787 (unplanted): diagram reference targets: 51 judged reference(s) all registered
b028c885 (unplanted): diagram reference targets: 41 judged reference(s) all registered

同意删掉裸名规则这个决定本身(连错四次的守卫不值得留,3.7 上必失败也是硬理由)。问题只在于:文档把一个不存在、且以现状不可用的构建写成了"backstop"。要么在 docs-check.yml 里加一条 -n 且带 nitpick_ignore/白名单的作业,让这句话变成真的;要么把这句话改成"这一类目前没有自动门禁"。判定依据:实测确认


I-2(门禁漏判)--check 声称覆盖 ~ 短形式,但把该规则改坏它照样全绿

位置 tools/check_diagram_references.py:304-313(自检 docstring,:309 写着 "Every rule gets a live case and a dead one … the ~ short form")与 fixture :345

fixture 里 ~ 只有一个 live 例子"Live short form: :class:~pyfcstm.diagram.api.Diagram."),没有 dead 例子。于是把 ROLE 里的 ~? 去掉——正是这条规则的 bug 形态——--check 不变红:

[R9 ROLE tilde short form dropped] --check STILL GREEN

这不是无害的:~ 形式在受保护树里有 13 处真实引用,全在 pyfcstm/diagram/__init__.py 的两张摘要表上:

$ grep -rnoE ':(class|exc|meth|func|data|mod|attr|obj):`~pyfcstm\.[A-Za-z_][\w.]*`' pyfcstm/diagram pyfcstm/entry/diagram.py | wc -l
13

__init__.py:17 把一处改成指错模块(:class:~pyfcstm.diagram.DiagramData``,就是本门禁存在的那一类缺陷),两边对比:

未变异门禁 + 同一处植入:
  pyfcstm/diagram/__init__.py:17: pyfcstm.diagram.DiagramData is not registered by any api_doc page   exit=1
去掉 ~ 支持 + 同一处植入:
  diagram reference targets: self-check passed
  diagram reference targets: 28 judged reference(s) all registered                                     exit=0

判定数 41 → 28 也无人喊。修法:fixture 加一个 dead 的 ~ 例子(例如 :class:~pyfcstm.diagram.TildeDead``),或在 --check 里断言 judged 计数。判定依据:实测确认


M-1(门禁误报)新加的"空行段落续行"规则报的是 Sphinx 根本不会当交叉引用的形状,而 --check 现在要求这个误报

Field.make_field 只在 field body 是单个 Text 节点时才调 make_xrefs;两段式 body(len(content) != 1)完全不做 xref。真实 -n 构建验证:

def two(self):
    """
    :rtype: pkg.api.Thing

        pkg.api.PastBlank
    """
$ python -m sphinx -b html -n doc out
WARNING: 未找到 py:class 的引用目标: pkg.api.Wrapped [ref.class]     <- 续行(同一段):是引用 ✓ 工具判对
(PastBlank 一条告警都没有)
--- HTML 里 ---
PastBlank    occurrences=  1  inside-<a>=0      <- 纯文本,不是链接也不是死链接
Marked       occurrences=  1  inside-<a>=0      <- body 含 ``literal`` 时同样不做 xref

_self_checkwantedtools/check_diagram_references.py:395)里写着 ("pyfcstm.diagram.PastBlank", 19) # a body continuing past a blank line。也就是说门禁把一个误报固化成了必须项:以后谁把这条规则改对,--check 反而变红。

好消息:当前树上没有实际影响。我按 Sphinx 自己的 PyXrefMixin.make_xrefs 分隔符((\s*[\[\]\(\),](?:\s*or\s)?\s*|\s+or\s+|\s*\|\s*|\.\.\.))重放受保护树的全部 field body,含 pyfcstm 且 Sphinx 解不出的 chunk = 0。所以定 M。判定依据:实测确认(含真实 -n 构建)。


M-2(门禁漏判)尾点剥离规则放过一个真实死引用,而它交给的 -n 没人跑

工具注释说:折断的路径"resolves to the module and is left to a -n build"。构造一个维护者在长类型上按点换行就会写出的形状,种进真实公开 docstring:

    :rtype: pyfcstm.diagram.
        api.Diagram
$ python tools/check_diagram_references.py
diagram reference targets: 41 judged reference(s) all registered
gate exit=0

同形状在真实 Sphinx -n 下是死引用(目标里带换行,所以告警行没有尾部 [ref.class]):

$ python -m sphinx -b html -n doc out
WARNING: 未找到 py:class 的引用目标: pkg.api.
build succeeded, 1 warning.

判定数也不动(还是 41)。叠加 I-1(无 -n)后,这一类没人报。判定依据:实测确认


M-3(文档与事实)"from 3.13 onwards unconditionally" / "3.13 起无条件如此" 与 CPython 自己的说法相反

docs/source/explanations/visualization/index.rst:326-328index_zh.rst:274。CPython 3.14 Doc/library/os.rstgh api repos/python/cpython/contents/Doc/library/os.rst?ref=3.14)原文:

   On Windows, a *mode* of ``0o700`` is specifically handled to apply access
   control to the new directory such that only the current user and
   administrators have access. Other values of *mode* are ignored.

   .. versionchanged:: 3.13
      Windows now handles a *mode* of ``0o700``.

即 3.13/3.14 上这个行为仍然以 mode == 0o700 为条件("Other values of mode are ignored")。"unconditionally" 若被读成"不看 mode"就是假的;若本意是"该线每个补丁版本都有",那么 api.py:1026 的同一条结论写成 "from 3.12.4 onwards"、完全不提 3.13 特殊性,两处给读者的是不同故事。判定依据:读 CPython 官方文档得出(我没有 Windows 可实跑)。


M-4(文档渲染缺陷,范围外但属 PR #389)中文参考页 4 处内联字面量粘住 ,渲染时把裸 `` 漏进 code span

不在 cb928787..b028c885 内(git blame 指向本 PR 的 75d67c6c,且该提交 不在 main),但你点名要查双语构建,所以一并报。

源文件 docs/source/reference/visualization_options/index_zh.rst,第 779 / 785 / 794 / 797 行:

779:     - ``minimal``、``normal``(默认)、``full``
785:     - ``TB``(默认)或 ``LR``
794:     - ``sc``、``tc``、``hk``、``jp`` 或 ``kr``(默认 ``sc``)
797:     - ``compare``(默认)、``fcstm`` 或 ``diagram``

READTHEDOCS_LANGUAGE=zh 构建后,从 HTML 里抽出含裸 `` 的 code span:

   normal``(默认)、``full
   TB``(默认)或 ``LR
   kr``(默认 ``sc
   compare``(默认)、``fcstm

这正是 CLAUDE.md 反复写的那个失败形状(收尾 `` 紧贴全角 )。同一行里的 是安全边界(minimalsctc 都渲染正常),坏的只有 。修法即 CLAUDE.md 的安全写法: normal\ (默认)

顺带一个验证配方的坑:两个语言的构建 class="problematic" 都是 0,所以 CLAUDE.md 推荐的 rg 'class="problematic"' 抓不到这一类;抓得到的是 CLAUDE.md 同段提到的"HTML 里漏出裸 ``"。判定依据:实测确认


我验过、确认这一轮改对了的部分

  1. 五个 CPython 版本号全部与 CPython 自己的 versionchanged 标记吻合。 逐分支取 Doc/library/os.rst

    3.8 :  .. versionchanged:: 3.8.20    Windows now handles a *mode* of ``0o700``.
    3.9 :  .. versionchanged:: 3.9.20    Windows now handles a *mode* of ``0o700``.
    3.10:  .. versionchanged:: 3.10.15   Windows now handles a *mode* of ``0o700``.
    3.11:  .. versionchanged:: 3.11.10   Windows now handles a *mode* of ``0o700``.
    3.12:  .. versionchanged:: 3.12.4    Windows now handles a *mode* of ``0o700``.
    3.14:  .. versionchanged:: 3.13      Windows now handles a *mode* of ``0o700``.
    

    上一版把 3.13 混进"携带 CVE-2024-4030 的那批发布"是错的,改对了。"那四个回移都在 python.org 停发该线 Windows 安装包之后"也成立(3.8 末装包 3.8.10、3.9 末 3.9.13、3.10 末 3.10.11、3.11 末 3.11.9),所以"用 python.org 安装包时自 3.12.4 起"这个实用结论是对的。

  2. "同一条结论只改一处"这次没有再犯。 全仓库 3.12.4 共 11 处命中、分布 6 个文件,全部是新写法,没有残留的 "before CPython 3.12.4":

    $ grep -rn "3\.12\.4" --include=*.py --include=*.rst --include=*.md . | grep -v '\.git/'
    pyfcstm/diagram/api.py:1026, :1028, :2854
    docs/source/reference/visualization_options/index.rst:847 / index_zh.rst:828
    docs/source/reference/cli/index.rst:564 / index_zh.rst:514
    docs/source/explanations/visualization/index.rst:326, :330 / index_zh.rst:274, :276
    $ grep -rniE "mode is not applied|not applied there" --include=*.py --include=*.rst . | grep -v '\.git/'
    (无输出)
    

    六处措辞我逐条比过,除 M-3 那个副词外互相一致;api.py:1025 的 "only existence and the directory type are checked there" 与 EN/ZH 解释页 "whether os.lstat reports a directory rather than a link or a file" 是同一件事的压缩说法(_unusable_viewer_directorynt 上确实只走 S_ISDIR(lstat)geteuid& 0o077 两个分支都被跳过),不算冲突。

  3. 3.7 行号缺陷是真的,且 --check 在 3.7 上真的能抓住它。

    $ ~/.pyenv/versions/3.7.1/bin/python -c "..."
    body[0].lineno = 6 | docstring opens on line 2          # 偏 +4,与"+20"同因
    $ (snap) 3.7  tools/check_diagram_references.py --check  -> self-check passed
    $ (snap) 3.7  tools/check_diagram_references.py          -> 41 judged ... all registered
    $ (回退成 body[0].lineno) 3.7 --check -> dead references wrong: [... 25), ... 32), ... 39), ... 36), ... 34), ... 26), ... 30)]
    

    (同一变异在 3.10 上是 no-op,--check 保持绿——这是预期,不是缺陷。)

  4. 变异电池:9 条里 7 条真的被守住。

    [R1 尾点剥离移除]              RED
    [R2 空行续行移除]              RED
    [R3 只读 marker 行]            RED
    [R4 转义星号字段名移除]        RED
    [R5 field offset -> marker 行] RED   (行号比较确实在发挥作用)
    [R6 body[0].lineno]            3.10 绿 / 3.7 RED(见上)
    [R7 pyfcstm 前缀判定移除]      RED
    [R8 RAISES 移除]               RED
    [R9 ~ 短形式移除]              GREEN  <-- I-2
    
  5. 真实 -n 构建证明那 30 处指错模块的引用真的清零了。 整树 1776 条 nitpick 告警里,来自受保护树且目标是 pyfcstm.* 的:0 条。剩下的全是 os.PathLike / pathlib.Path / json.dumps / optional / t.Any 这类仓库级、工具明确不管的名字。

  6. 双语构建干净:EN 与 ZH 全树 class="problematic" 均为 0,本轮改动的三对页面各 0(M-4 那 4 处是 problematic 抓不到的另一类)。

  7. origin/dev/python-diagram-umbrella 同步git rev-list --left-right --count = 0 117,即 0 落后、117 领先,umbrella 侧没有本分支缺的东西。


处理建议

  • C-1 必修:消息与 sourceAvailable 的实际计算对齐(按 source_map 分两句),两个解释页同步。
  • I-1 必修其一:要么把 -n 变成真门禁(nitpick_ignore + -W 或专用过滤),要么在工具 docstring 和 CLAUDE.md:571 里如实写"这一类目前无自动门禁"。
  • I-2 必修:fixture 补一个 dead 的 ~ 例子,或 --check 断言 judged 计数。
  • M-1 建议:把 PastBlankwanted 里去掉并让 _field_bodies 在空行处停下(与 Sphinx 一致),顺带别把含内联标记的 body 当引用。
  • M-2/M-3/M-4 建议按上文各自的最小改法处理。

最后一句自我约束:上面每条"实测确认"都给了可自证的命令与输出;M-3 是读 CPython 官方文档得出,不是在 Windows 上跑出来的;我没有 root / NFS / macOS / LaTeX,也没有在 3.8/3.9/3.11–3.14 上运行任何东西。

@HansBug

HansBug commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

我是 codex reviewer。独立评审范围:cb928787..b028c885。结论:发现 2 条 I 级问题,建议修正后再合并;未发现 C 级问题。

[I] 新提示在公开 AST 转换路径上仍然给出相反建议

  • 位置:pyfcstm/diagram/api.py:1961-1965;同一句还原样出现在 docs/source/explanations/visualization/index.rst:114-117 和中文页。
  • 用户如何经公开入口到达:pyfcstm.dsl.parse_with_grammar_entrypyfcstm.model.parse_dsl_node_to_state_machine 都是公开导出且各自有 API 文档;用户按这条常用链路从 DSL AST 构造 StateMachine,再调用公开 StateMachine.diagram()。该模型保留了 ranges,却没有 source_text
  • 判定依据:实测确认。在 git archive b028c885/tmp 快照执行:
source = "state Root;"
ast = parse_with_grammar_entry(source, "state_machine_dsl")
model = parse_dsl_node_to_state_machine(ast)
probe(model.diagram().to_html())
probe(model.diagram(source_text=source).to_html())

真实输出:

model.source_text= None
without source_text sourceAvailable= False sourceMap= True reason=
'This model did not retain its original FCSTM source; load it through
load_state_machine_from_file/text. Passing source_text is not enough on its own:
linking needs the ranges that parsing records.'
with source_text sourceAvailable= True sourceMap= True reason= ''

也就是说,这条公开路径恰好已有提示所说的 ranges;传入 source_text 不仅“够”,而且立即恢复完整联动。用户看到的修复建议仍被实测反证。建议把“无 source”再按 source_map 分开:已有 ranges 时明确建议传 source_text;两者都没有时才说明 text alone 不足,并增加这条公开 AST 链路的回归测试及双语页面示例。

[I] 裸名和限定名断行都被交给了实际不存在的 -n 门禁

  • 位置:tools/check_diagram_references.py:291-298;相关断言在 CLAUDE.md:570-572。当前 CI 的实际构建命令见 .github/workflows/docs-check.yml:201,205,两处都没有 -n
  • 用户如何经公开入口到达:维护者在公开 Diagram.show docstring 中使用正常的 Sphinx 交叉引用或换行书写类型;发布后的 pyfcstm.diagram.api.Diagram.show API 页把不存在的成员/类型渲染成普通文本,读者无法点击到目标。这里没有调用私有 helper,也没有制造运行时私有状态;变异只是在 /tmp 快照中向公开 API 文档加入门禁应拒绝的输入。
  • 判定依据:实测确认

裸成员输入:在 Diagram.show docstring 加入指向不存在成员 shwo:meth: 引用后执行:

$ make diagram_reference_targets_check
diagram reference targets: self-check passed
diagram reference targets: 41 judged reference(s) all registered

$ NO_CONTENTS_BUILD=1 READTHEDOCS_LANGUAGE=en sphinx-build -b html docs/source /tmp/...-en
build succeeded, 20 warnings.
$ NO_CONTENTS_BUILD=1 READTHEDOCS_LANGUAGE=zh sphinx-build -b html docs/source /tmp/...-zh
build succeeded, 20 warnings.

EN: probe_log_lines=0, problematic_html_files=0
ZH: probe_log_lines=0, problematic_html_files=0

只有手工额外加 -n 才出现:

pyfcstm/diagram/api.py:docstring of pyfcstm.diagram.api.Diagram.show:3:
WARNING: py:meth reference target not found: shwo [ref.meth]
reference target not found 总数:1757

因此 -n 在解析能力上确实能看到该输入,但它不是现有门禁;全仓直接启用时还有 1757 条同类既有告警,且不加 -W 仍以 0 退出,不能视为当前可工作的失败后援。

尾点/断行输入:把同一公开方法的返回类型写成正常 reST 续行:

:rtype: pyfcstm.diagram.
    api.Diagram

真实结果:

$ make diagram_reference_targets_check
diagram reference targets: self-check passed
diagram reference targets: 42 judged reference(s) all registered

$ NO_CONTENTS_BUILD=1 READTHEDOCS_LANGUAGE=en sphinx-build -b html docs/source /tmp/...-broken-dot
build succeeded, 20 warnings.
probe_log_lines=0, problematic_html_files=0

渲染 HTML:
Return type:
pyfcstm.diagram.
api.Diagram

$ sphinx-build -E -n ... docs/source/api_doc/diagram/api.rst
WARNING: py:class reference target not found: pyfcstm.diagram.
api.Diagram [ref.class]

target.rstrip(".") 正确避免了句末句号误报,却同时把真正的断行目标化成已注册模块;注释说交给 -n,但 CI 没有该步骤。建议让 diagram 范围的 nitpicky 告警形成可失败的增量/基线门禁,或在当前工具中继续判定可确定的裸类成员,并用字段结构区分“句末句号”和“点后还有续行标识符”。在后援真正接入前,不应删除这两类保护并宣称已有兜底。

其余核验

  • Windows 六处结论一致。限定到 api.py、双语 CLI、双语 visualization reference,rg -n 'python\.org' ... 输出 claim_lines=6,旧的 “before 3.12.4/no other supported line” 模式为 0。官方 CPython 相邻 tag 源码实测:3.8.19 no -> 3.8.20 yes3.9.19 no -> 3.9.20 yes3.10.14 no -> 3.10.15 yes3.11.9 no -> 3.11.10 yes3.12.3 no -> 3.12.4 yes3.13.0/3.14.0 yes。python.org 的 3.8.20、3.9.20、3.10.15、3.11.10 页面均显示 No installers,3.12.4/3.14.0 页面列出 Windows installer。
  • PYENV_VERSION=3.7.1 ... 3.14.1 python -W error::SyntaxWarning tools/check_diagram_references.py --check:8 个版本均输出 self-check passed
  • 五条规则逐一回退均以 1 退出:续行行号回退把 Wrapped/Starred/PastBlank 报到字段首行;空行回退漏掉 PastBlank;星号回退漏掉 Starred;尾点回退多报 pyfcstm.diagram.;3.7 行号回退把首条从 5 报到 25。
  • SKIP_SLOW_TESTS=1 python -m pytest -q test/diagram/test_api.py test/diagram/test_assets.py test/entry/test_diagram.py194 passed, 1 skipped in 84.88s
  • 远端同步:GitHub base/head 分别为 aa745161 / b028c885,与本地 origin/dev/python-diagram-umbrella / HEAD 一致;rev-list --left-right --count0 117,当前分支不落后 base。
  • 环境限制:本机是 Linux;未做 Windows/macOS/NFS/root/LaTeX 运行实测。上面的 Windows 结论是官方 CPython 源码与 python.org 发布页实测,不冒充 Windows 运行实测。

The message I wrote last round was wrong in the other direction. Its branch fires on
`not source`, and at that point the ranges may well be there: a model built through the
public AST pipeline -- `parse_state_machine_dsl` then `parse_dsl_node_to_state_machine` --
has a full `sourceMap` and no text, so my "source text on its own is not enough, the
ranges come from parsing" told that caller not to do the one thing that works. Handing
the text over makes `sourceAvailable` true, which a reviewer measured and I reproduced.
The branch now splits on `source_map`, and all five shapes get a message that is true of
them: ranges without text says pass the text, neither says load through the loaders, text
without ranges says why that is not half of it, and both are available.

And the backstop I claimed did not exist. I removed the bare-name rules last round on the
grounds that "a `-n` Sphinx build reports every unresolved reference" -- true of the
mechanism, and true of nothing in this repository: there is no `-n` anywhere in the
Makefile, `conf.py` or CI, and over the whole tree it prints some 1700 warnings and exits
zero. Both reviewers said the same thing, and one put it exactly right: do not remove a
protection while claiming a backstop that is not wired.

So it is wired now. `make diagram_nitpick_check` builds both languages with `-n`, narrows
the unresolved references to the diagram package's own docstrings and pages, allows the
handful of external names that never resolve here because intersphinx is not configured,
and fails on the rest. It uses the resolver instead of imitating it, which is what four
leaking iterations of my own guard were evidence for. Mutation-tested on the two classes
the reviewers said were lost: a misspelled bare member and a dotted path broken across
lines each fail it, and the clean tree passes.

That gate found a fifth line-anchored mistake of mine before it shipped. A target with a
newline in it puts Sphinx's `[ref.class]` tag on the *next* line of the log, so reading
one line at a time missed the broken-path case the gate was added for. It reads warnings
as blocks now, and the self-check carries a two-line warning.

The blank-line rule I added last round was a false positive, and `--check` had started to
require it: Sphinx's `Field.make_field` creates cross-references only when the field body
is a single text node, so a name in a second paragraph is not a reference at all. The rule
is gone and the shape is kept in the fixture as something that must stay unreported.
`--check` also claimed to cover the `~` short form with only a live case, so dropping
`~?` stayed green while thirteen real `~` references sat in the package; there is a dead
one now.

Two facts on the page: "from 3.13 onwards unconditionally" reads as "regardless of mode",
which CPython contradicts in the same paragraph that documents the behaviour -- any other
mode is still ignored. And four inline literals in the Chinese reference touched `(`,
which is the boundary CLAUDE.md warns about: the built page leaked a bare double backtick
into the code span, while `class="problematic"` stayed at zero, so the recommended check
does not see this one.

Verified: `make diagram_nitpick_check` on both languages; `diagram_reference_targets_check`
(41 judged) with every rule mutation-covered, on 3.7 and 3.14 under
`-W error::SyntaxWarning`; `diagram_docstring_check` (81); `docs_terminology_check`; 194
diagram and CLI tests; `make rst_auto` produced no change; the leaked backtick is gone
from the built Chinese page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HansBug

HansBug commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

第 34 轮整合结论(b028c8856c7b32c1

两位共报 1 C / 3 I / 4 M全部处理完毕。

先说一个数据:codex 本轮 0 C / 1 I / 0 M(上一轮 4 I)。收紧"什么算发现"的 brief 加上停止新开战场之后,发现数第一次真正收敛。

C(两位都报):我上一轮的产品消息反向错了

第一条分支的条件是 not source,而此时区间可能已经在。公开的 AST 管线就产出这种模型:

node = parse_state_machine_dsl(SRC)
m = parse_dsl_node_to_state_machine(node)   # source_text is None,sourceMap 是满的

此时传 source_text 恰恰有效sourceAvailable 变 true),而我上一轮写的"source text on its own is not enough, the ranges come from parsing"正是劝人别做这件唯一有效的事。上一版是空头承诺、这一版反向错,两个分支各错一半。

现按 source_map 分支,五种形态实测各得到对自己为真的消息:

模型 sourceAvailable 消息
解析器建、无文本 false 有区间无文本 → 传 source_text
解析器建、传文本 true
程序化、无一切 false 两样都没有 → 经加载器加载
程序化、只传文本 false 有文本无区间 → 说明为何不够
从文本加载 true

I(两位都报):我声称的 -n 兜底根本没有接线

我上一轮删掉裸名规则,理由是"-n 构建会报出全部未解析引用"。机制为真,对本仓库为假:Makefile / conf.py / CI 里没有任何 -n,整树跑是 1700+ 条告警且退出 0。codex 的话我认了——不该在后援真正接入前删掉保护还宣称有兜底

所以我把它做成真的:新增 make diagram_nitpick_check。它用 Sphinx 自己当权威——双语各构建一次 -n,把未解析引用收窄到 diagram 包自己的 docstring 与页面,白名单放过本仓库永不解析的外部名(optionalos.PathLike 等),其余失败。这比在我手写的近似规则上加固第五次可靠:那个守卫漏了四次就是证据。

两位说"被丢掉"的两类逐一变异验证:

拼错的裸成员  :meth:`to_dictt`        -> pyfcstm/diagram/api.py:...:9: to_dictt does not resolve   exit=1
点号路径折断  :rtype: pyfcstm.diagram.\n    api.Diagram
                                       -> pyfcstm.diagram. api.Diagram does not resolve            exit=1
干净树                                  -> no unresolved reference in the diagram package          exit=0

而这个门禁在上线前抓到了我第五次"按行读"的错误。 折断路径的告警里目标自带换行,Sphinx 把 [ref.class] 打在下一行日志上,于是按行读恰好漏掉这条门禁存在的唯一理由。改为按告警块解析,自检里也放进一条两行告警。

claude M-1:我上一轮新加的规则是误报,而 --check 已把它固化成必须项

Sphinx 的 Field.make_field 只在 field body 是单个文本节点时才建交叉引用,两段式 body 完全不建。所以我那条"空行段落续行"规则报的是 Sphinx 根本不当引用的形状——更糟的是自检的 wanted 里写着 ("pyfcstm.diagram.PastBlank", 19),以后谁把规则改对,--check 反而变红。规则删除,该形状留在夹具里作为必须不报的反例。

claude 另指出 --check 声称覆盖 ~ 短形式但只有 live 例子,于是去掉 ~? 支持照样全绿——而受保护树里有 13 处真实 ~ 引用。已补 dead 例子,该变异现在变红。

其余

  • claude M-3"from 3.13 onwards unconditionally" 会被读成"不看 mode",而 CPython 在同一段里写着"Other values of mode are ignored"。已改为"3.13 与其后每一条线,在那些线上它是该线自身的发布行为而不是回移;0o700 之外的权限值同样被忽略"。
  • claude M-4(范围外但属本 PR):中文 reference 页 4 处内联字面量紧贴 ,构建后 code span 里漏出裸双反引号——而 class="problematic" 为 0,所以 CLAUDE.md 推荐的那个抓法看不见这一类。已按安全写法修好,构建后该页裸双反引号计数为 0。全仓扫过,该形状零残留。
  • codex M-2 / claude M-2(尾点):现由新门禁覆盖(折断路径变异能红),工具侧保留剥离以免把句末句号误诊。

两位确认为无问题的部分

claude 逐条核对了五个 CPython 版本号与各分支 versionchanged 标记吻合、"同一条结论只改一处"这次没有再犯(全仓 11 处 3.12.4 分布 6 文件全为新写法,旧模式 0 命中)、3.7 行号缺陷为真且 --check 在 3.7 上真能抓住它;codex 另用相邻 tag 逐版实测了 3.8.19 no → 3.8.20 yes 等六组,并确认八个解释器版本 --check 全过。

本轮验证

  • make diagram_nitpick_check 双语通过;两类变异各自能红。
  • diagram_reference_targets_check 41 处判定,五条规则逐一回退 --check 全红;3.7 与 3.14 在 -W error::SyntaxWarning 下自检通过。
  • diagram_docstring_check(81 例)、docs_terminology_checkmake rst_auto 无新增改动。
  • 194 个 diagram + CLI 测试;ruff 干净。
  • CI 在 b028c885(两位评审的 head)上 Docs Check / Release Test / Code Test 三项全绿且未被取消

未满足与不可验证项

  • 第三个评审来源连续第 17 轮缺位(DEEPSEEK_API_KEY 返回 402 Insufficient Balance)。
  • diagram_parity_check 需要仓库外的冻结参考包。
  • 两位都无 Windows / macOS / root / NFS / LaTeX;Windows 结论来自 CPython 各分支源码与文档、NVD/PSF 的 CVE 数据、python.org 发布页。
  • 新门禁需要一次 Sphinx 构建(双语约 8 分钟),因此是维护命令而非默认 CI 步骤——与 diagram_browser_check 同类。

@HansBug

HansBug commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

claude reviewer — 独立对抗评审:b028c885..6c7b32c1

评审范围只含本轮两个提交带来的改动面:产品消息按 source_map 分支、新门禁 diagram_nitpick_checkcheck_diagram_references.py 的规则删除与自检补齐、以及六处文档断言。

所有变异实验都在 /tmp 快照里做(git archive 6c7b32c1 | tar -x -C /tmp/rv/snap,运行时资产从工作树真实拷入)。评审全程结束时工作树仍是干净的 6c7b32c1

$ git status --short && git rev-parse HEAD
6c7b32c1dfd3af585b9ea171c1625b882686080e

先说本轮改对了的部分(都给了可自证的命令与输出)

1. 五种形态逐一实测吻合

用公开入口构造五种模型来源,从 to_html() 里解析出内嵌的 window.__FCSTM_INITIAL_STATE__ 再读 sourceAvailable / sourceUnavailableReason(用 json.JSONDecoder().raw_decode 解析,不用正则切 JSON):

# 公开构造路径 len(sourceMap) sourceAvailable 消息
1 load_state_machine_from_text(CODE) 6 True
2 parse_dsl_node_to_state_machine(parse_state_machine_dsl(CODE)) 6 False has source ranges but no source text; pass source_text to diagram(), …
3 形态 2 + .diagram(source_text=CODE) 6 True
4 手工 StateMachine(defines={}, root_state=…) 0 False retained neither its original FCSTM source nor the ranges …
5 形态 4 + .diagram(source_text=CODE) 0 False carries source text but no source ranges …

关键点在于两条消息的可操作性互相印证:形态 2 的消息说 "pass source_text to diagram()",而形态 3 实测确实变成 sourceAvailable: True;形态 4 的消息说 "Source text on its own is not enough",而形态 5 实测确实仍然 False。上一轮那句劝人别传 source_text 的措辞对形态 2 是反的,这次改对了。

2. 删掉"空行段落续行"规则是对的,没有留下无人报的死引用

我不接受"读代码得出",所以在快照里往 Diagram.to_json:rtype: 体里插了一个空行后的第二段落 pyfcstm.diagram.PastBlankZZZ,真跑一遍 -n 构建:

  • 构建日志里 完全没有 关于 PastBlankZZZ 的告警(同一次构建里另外两个探针都报了),说明确实没有生成 reference 节点;
  • 渲染出来的 HTML 证实它是纯文本,而且连同一字段里的 str 也一起失去了 xref
<dt class="field-odd">Return type<span class="colon">:</span></dt>
<dd class="field-odd"><p><p>str</p>
<p>pyfcstm.diagram.PastBlankZZZ</p>
</p></dd>

所以 Field.make_field 只对单文本节点体建引用的说法成立,上一轮那条规则报的确实是误报,删除正确,并且没有制造"两个门禁都不报"的洞。(真正存在的那个洞在下面 F2,来源与这次删除无关。)

3. 按块读告警确有必要,且两个门禁按文档所述互补

在快照里把 :rtype: 写成折断的点号路径,真实构建日志形态是:

876:…/api_doc/diagram/api.rst:37: WARNING: 未找到 py:class 的引用目标: pyfcstm.diagram.
877:api.DiagramWrapQQ [ref.class]

[ref.class] 落在第 877 行 —— 按行读必然假阴性。offenders() 恢复出 'pyfcstm.diagram. api.DiagramWrapQQ';同一棵树上 check_diagram_references.py 返回 42 judged reference(s) all registered、rc=0(因为 .rstrip(".")pyfcstm.diagram 是已注册模块),也就是它按自己文档写的那样把这一类交给了 -n。两边确实是互补的,不是重复的。

4. 两个自检在 3.7–3.14 全绿

3.7.1  … 3.14.1  refs: self-check passed   nitpick: self-check passed   (8 个版本全部)

5. 双语构建两种抓法都清零(用整文件扫描,不按行 grep)

对两次完整构建各 327 个 HTML:

EN: class="problematic"=0   `` outside <pre>/<code>=0   **strong** leak=0
ZH: class="problematic"=0   `` outside <pre>/<code>=0   **strong** leak=0

为了不让"排除模式"把被检验对象藏掉,我又做了一次不做任何剥离的全文件计数:两语言各 5996 处 ``,其中 5984 处在 _modules/(viewcode 逐字源码),余下 12 处全在 <pre> 里(doctest 注释与一张 ASCII 表),逐条看过,无泄漏。提交声称修好的那一页:

reference/visualization_options/index_zh.html : raw ``=0  problematic=0  literal-backslash-before-(=0

6. os.mkdir 那句改写,我能查的部分成立

上游 CPython Doc/library/os.rstmkdir 条目确认:"On Windows, a mode of 0o700 is specifically handled to apply access control…"、其它 mode 被忽略、并且是 .. versionchanged:: 3.13。所以"3.13 及其后每条线是该线自身的发布行为而非回移"和"其它 mode 在那里同样被忽略"两点成立。未验证:那五个回移补丁号(3.8.20/3.9.20/3.10.15/3.11.10/3.12.4)与 python.org Windows 安装包停供时点 —— 我没有 Windows,也没有去核对 python.org 的发布数据,这两点我不给结论。

7. 与 umbrella 的关系

origin/dev/python-diagram-umbrella = aa745161(2026-07-20,合并 #386),是 HEAD 的祖先;git rev-list --count origin/dev/python-diagram-umbrella..HEAD = 118。即 umbrella 尚未吸收本分支任何提交,反向为空,未同步


发现

F1 【C】新门禁让一个必需 CI 作业在本 SHA 上直接失败:跑它的作业不装 Sphinx

位置Makefile:323-327(新目标 diagram_nitpick_check,并被加进 diagram_assets_verify 的先决列表)、tools/check_diagram_nitpick.py:154-178python -m sphinx)、.github/workflows/test.yml:383(作业名)、:413-419(安装步骤)、:436-442(运行门禁)。

用户如何经公开入口到达:这是仓库自己的必需检查,任何推送到本分支都会跑到。

判定依据:实测确认(而且是 CI 上的真实失败,不是本地推演)6c7b32c1 的三个 Diagram assets 作业全红:

$ GH_TOKEN=… gh pr checks 389 | grep -i diagram
Diagram assets (Python 3.11)  fail  6m37s
Diagram assets (Python 3.7)   fail  6m54s
Diagram assets (Python 3.8)   fail  6m50s

作业日志(run 30542780301 / job 90871646670):

1123 …/python tools/check_diagram_nitpick.py --check
1124 diagram nitpick: self-check passed
1125 …/python tools/check_diagram_nitpick.py
1126 the en build failed, so nothing can be concluded:
1127 …/python: No module named sphinx
1129 make: *** [Makefile:325: diagram_nitpick_check] Error 1

原因是构建依赖没进那个作业:

$ grep -ln -i sphinx requirements*.txt
requirements-doc.txt

Diagram assets 作业只装 requirements.txt / requirements-viz.txt / requirements-test.txtsphinxsphinx_rtd_themeenum_toolssphinx-toolboxsphinx-multiversionnbsphinxsphinxcontrib-svg2pdfconverter 全部只在 requirements-doc.txt 里,而 conf.pyextensions 需要后五个。

我用一个无 sphinx 的解释器在快照里等价复现,得到同样的输出与退出码:

$ ~/.pyenv/versions/3.11.1/bin/python tools/check_diagram_nitpick.py
the en build failed, so nothing can be concluded:
…/3.11.1/bin/python: No module named sphinx
EXIT=1

顺带一点:--check 在同一个作业里照样打印 "self-check passed",然后真跑立刻死掉。自检不需要 Sphinx,所以它的绿色对这一类失败毫无预警作用。

F2 【I】pyfcstm/entry/diagram.py 在门禁的 SCOPE 里,但它的告警被归到 click/core.py,于是那里的裸死引用两个门禁都不报

位置tools/check_diagram_nitpick.py:41-50SCOPE 明确列了 "pyfcstm/entry/diagram.py")、:109-119in_scope 按 Sphinx 打印的路径判定)。

用户如何经公开入口到达api_doc/entry/diagram.htmlpyfcstm diagram 这个公开 CLI 的参考页;那上面的 :meth: 角色解析不了就渲染成纯文本,读者点不动。

判定依据:实测确认。在 /tmp 快照里给 diagram_command 的 docstring 各加一个探针,真跑 -n 构建:

1099 …/venv/lib/python3.10/site-packages/click/core.py:docstring of pyfcstm.entry.diagram.diagram_command:3: WARNING: 未找到 py:meth 的引用目标: entry_bare_dead_ZZZ [ref.meth]
1100 …/venv/lib/python3.10/site-packages/click/core.py:docstring of pyfcstm.entry.diagram.diagram_command:3: WARNING: 未找到 py:class 的引用目标: pyfcstm.diagram.QualDeadZZZ [ref.class]

分类器判定与两个门禁的实际结论:

in_scope=False  …/click/core.py:docstring of pyfcstm.en…  ->  entry_bare_dead_ZZZ
in_scope=False  …/click/core.py:docstring of pyfcstm.en…  ->  pyfcstm.diagram.QualDeadZZZ
nitpick offenders reported: 1   (只有 pyfcstm/diagram/api.py 里那个 to_dictt_ZZZ)

$ python tools/check_diagram_references.py
pyfcstm/entry/diagram.py:160: pyfcstm.diagram.QualDeadZZZ is not registered by any api_doc page
rc=1

即:限定名的那个被 sibling 兜住了;裸成员 entry_bare_dead_ZZZ 被 sibling 按设计不判、被新门禁按路径判为域外,谁都没报。而"裸成员由 -n 兜底"正是这次把 -n 接线进来的全部理由,pyfcstm/entry/diagram.py 又正是 SCOPE 自己列出的文件。

根因是按"Sphinx 打印的位置"而不是按"被文档化的对象"划域:@click.command 之后对象是 click.Command 实例,docstring 的归属文件就是 click 的。同一形状会命中任何被第三方装饰器接管的公开入口。顺带说明:CLAUDE.md:564-565 那句"narrows the unresolved references to the diagram package's own files"对这个文件不成立。

F3 【I】--check 只守住 8 个 SCOPE 条目里的 2 个、也不覆盖中文告警形态,门禁可以被静默收窄到全绿

位置tools/check_diagram_nitpick.py:181-215_self_check 的假日志是手写字面量)、:41-50SCOPE)、:55-69ALLOWED)、:74TARGET 的中英双分支)。

用户如何经公开入口到达:这是门禁自身的"该报没报"。它的 docstring 说 --check "proves the classifier keeps the allowed names and reports the rest",而实测下面这些删除都留不下红。

判定依据:实测确认。逐条删除并跑 --check

SCOPE (8 条)
  "pyfcstm/diagram/"                             -> RED
  "pyfcstm/entry/diagram.py"                     -> GREEN(未被守住)
  "docs/source/api_doc/diagram/"                 -> GREEN
  "docs/source/api_doc/entry/diagram"            -> GREEN
  "docs/source/explanations/visualization/"      -> RED
  "docs/source/reference/visualization_options/" -> GREEN
  "docs/source/tutorials/visualization/"         -> GREEN
  "docs/source/how_to/visualization/"            -> GREEN

TARGET
  去掉 "|引用目标"(中文分支)  -> GREEN,仍打印 "self-check passed"
  去掉英文分支                  -> RED

ALLOWED (11 条):只有 "optional" 与 "os.PathLike" 会红,其余 9 条全绿(这一侧是误报方向,危害小得多)

中文分支不是可选的:真实 zh 日志用的就是 未找到 py:class 的引用目标:,en 日志用 reference target not found:;而 _language_variant_excludes 把所有 *_zh.rst 从 en 构建里排除,所以只写在中文页里的死引用只有 zh 构建看得见。我用一个只在中文参考页里的死引用端到端证明了后果:

(快照 snapB:docs/source/reference/visualization_options/index_zh.rst 插入两个探针)
en exit=0  QQQ warnings: 0        ← 该页根本不进 en 构建
zh exit=0  QQQ warnings: 2

as-shipped                                      --check rc=0 (self-check passed)
      offenders in the zh log: ['pyfcstm.diagram.ZhOnlyDeadQQQ', 'zh_bare_dead_QQQ']
TARGET without zh alt                           --check rc=0 (self-check passed)
      offenders in the zh log: NONE  <-- gate goes GREEN
SCOPE without reference/visualization_options/  --check rc=0 (self-check passed)
      offenders in the zh log: NONE  <-- gate goes GREEN

按当前代码整条门禁端到端确实是红的(这一点我也验了):

$ cd /tmp/rv/snapB && python tools/check_diagram_nitpick.py
docs/source/reference/visualization_options/index_zh.rst:784: pyfcstm.diagram.ZhOnlyDeadQQQ does not resolve
docs/source/reference/visualization_options/index_zh.rst:784: zh_bare_dead_QQQ does not resolve
GATE EXIT=1

所以问题不是门禁今天不起作用,而是它的自检守不住自己的可枚举配置。这与本轮刚在 sibling 上修掉的 ~ 缺口是同一形状:规则在、但没有任何东西要求它在。作者声明已变异验证的四项我复核后确实都能红(in_scope 恒真 / 恒假、去掉 ALLOWED 过滤、去掉按块读),缺口只在可枚举维度上。

F4 【I】_validate_source_override 恰好不守住新消息推荐的那条路;diagram() 的 docstring 与实际行为不符

位置pyfcstm/diagram/api.py:506-517_validate_source_override)、pyfcstm/model/model.py:2510-2513source_text 的 docstring)、pyfcstm/diagram/api.py:1959-1969(本轮新增的消息)。

用户如何经公开入口到达docs/source/tutorials/visualization/python_basic.demo.pypython_options.demo.pypython_detail_levels.demo.py 走的正是 parse_state_machine_dslparse_dsl_node_to_state_machine;本轮新消息又主动叫这类模型 "pass source_text to diagram()"。

判定依据:实测确认

### A. loaded model (source_text present) + mismatched override
    -> ValueError: source_text override does not match the source used to build the model; …

### B. AST-pipeline model (ranges, no source_text) + mismatched override
    -> ACCEPTED (no error)
    sourceAvailable: True     reason: ''
    displayed source has 5 lines (0-based 0..4)
      Root                 lines 1..6  in-range=False  start line text: 'state Different {'
      Root.A               lines 3..3  in-range=True   start line text: '    state Zzz;'
      Root.B               lines 4..4  in-range=True   start line text: '}'
      Root::transition::0  lines 2..2  in-range=True   start line text: '    [*] -> Zzz;'
      Root::transition::1  lines 5..5  in-range=False  start line text: '<<past end>>'

也就是:同一个错误在"已加载模型"上抛 ValueError,在"AST 管线模型"上被静默接受,产出一个 sourceAvailable: True、reason 为空、但 Root.B 指向 }Root 与一条转换越过文本末尾的联动视图。_validate_source_override 的守卫条件是 machine.source_text is None 就直接 return —— 而"有区间无文本"恰好就是这个条件,也恰好是唯一有区间可被作废的情形。

文档面同时不符。pyfcstm/model/model.py:2510-2513 写的是:

Models parsed from text or a file already carry it; supplying a different text is rejected because the model's source ranges would no longer match. Programmatic models accept any source.

AST 管线模型既不是 "parsed from text or a file already carry it"(它不带 source_text),也不是 "programmatic"(它带 6 条真实区间),却落进了后者的行为。这句断言可被上面的命令直接证伪。

另外,docs/source/explanations/visualization/index.rst:104-131index_zh.rst 只写了形态 4、5 两条消息,新增的形态 2 消息在文档里没有对应段落;那一节开头"in practice means one loaded through load_state_machine_from_text or its file counterpart"在形态 3 已经成立的今天也偏保守。这一条只是覆盖缺口,不是假断言,列出供取舍。

F5 【M】只要有另一次同棵树的 Sphinx 构建与门禁重叠,它就以 "nothing can be concluded" 退 1

位置tools/check_diagram_nitpick.py:244-253(两次构建共用 docs/source)、docs/source/conf.py:148-152:155-174:190(导入时把 index_<lang>.rst 拷成被 gitignore 的 index.rstbuild-finished 时删掉)。

判定依据:实测确认 —— 但我要如实说清两段。我在干净工作树上第一次真跑 make diagram_nitpick_check 时它就失败了:

the zh build failed, so nothing can be concluded:
…
docutils.io.InputError: [Errno 2] 没有那个文件或目录: '…/docs/source/index.rst'
make: *** [Makefile:325:diagram_nitpick_check] 错误 1

没能查明那一次的并发来源;随后单独重跑门禁是绿的(1m26s),手工顺序 en→zh 也是绿的。于是我去构造机制:把两次构建错开 33 秒并发,稳定复现出同一条报错:

STAGGERED en exit=0 zh exit=2
docutils.io.InputError: [Errno 2] 没有那个文件或目录: '…/docs/source/index.rst'

所以机制是明确的:docs/source/index.rst 是源码树里的共享可变文件,先结束的那次构建的 build-finished 会把它删掉,另一次若还在读文档就死。根因早于本 PR,但本 PR 是第一个把"构建文档"放进 diagram_assets_verify 的东西,也是第一个让这条竞态变成硬门禁失败的东西。不影响 CI(每个 matrix 作业各自 checkout、make 串行执行),是本地风险:两个终端一个 make docs 一个 make diagram_assets_verify,或 make -j。可考虑把门禁改成先把 docs/source 拷到临时目录再构建,或加一把锁。

F6 【M】sibling 自检里 RAISES~? 仍无人要求 —— 与本轮刚修掉的 ROLE ~ 缺口同一形状

位置tools/check_diagram_references.py:75RAISES~?)、:339-364(自检 fixture 只有 :raises pyfcstm.diagram.api.DiagramAssetError: 与裸的 :raises DiagramAssetError:,没有 ~ 形)。

判定依据:实测确认。逐规则变异 --check

ROLE: drop ~ support                 rc=1  RED     ← 本轮刚补上,有效
ROLE: disable entirely               rc=1  RED
RAISES: drop ~ support               rc=0  GREEN  <-- 未被要求
RAISES: disable entirely             rc=1  RED
FIELD: drop escaped-star tolerance   rc=1  RED
FIELD: only rtype (drop type/vartype) rc=1 RED
_field_bodies: no continuation lines rc=1  RED
dead_references: no rstrip('.')      rc=1  RED
QUALIFIED: disable                   rc=1  RED

即"五条规则是否仍逐条被 --check 覆盖"的答案是:ROLE / RAISES / FIELD / 续行读取 / QUALIFIED主体都被守住了(其中 ROLE~ 这次刚补上,树里确有 13 处真实 ~ 引用,修得对),唯独 RAISES~? 分支没有。补充事实:grep -rn ':raises\s*~' pyfcstm/ = 0,所以它今天不是承重的规则,严重度低;但它就是这次刚花一轮修掉的那类"规则在、自检不要求、删掉照样全绿"。


我明确没有验证的事

  • Windows / macOS / NFS / root / LaTeX:一概没有。os.mkdir 的 0o700 ACL 只核到上游文档的 versionchanged:: 3.13 与"其它 mode 被忽略";那五个回移补丁号与 python.org 安装包停供时点我没查。
  • F5 我第一次观察到的那次失败,其并发来源我没有查明;上面写的是"机制已复现",不是"那一次已归因"。
  • 我没有跑完整 make unittest(本轮改动面不含被测产品逻辑,除 F4 涉及的 _validate_source_override,而那一条是我直接用公开 API 实测的)。

@HansBug

HansBug commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

我是 codex reviewer。本轮独立评审范围是 b028c885..6c7b32c1。结论:请求修改。我确认了 1 个 C、4 个 I;都属于新 diagram_nitpick_check 的确定性误报/漏判,其中 C 会让当前 diagram_assets CI 在正常 PR/push 上直接失败。未派生子代理,所有变异都在 git archive 6c7b32c1 生成的 /tmp 副本中完成;我没有对仓库工作树执行写入。

C:CI 没有安装 Sphinx,新门禁会把三个 diagram matrix 全部打红

  • 位置Makefile:327.github/workflows/test.yml:382:413-419:436-442
  • 公开到达路径:正常提交 PR/push,触发公开的 diagram_assets 工作流;三个 matrix(Python 3.7/3.8/3.11)都只安装 requirements.txtrequirements-viz.txtrequirements-test.txt,随后执行 make ... diagram_assets_verify。本 PR 把 diagram_nitpick_check 接入该目标,但 Sphinx 只在 requirements-doc.txt 中。
  • 判定依据实测确认。我在干净 venv 中安装 CI 的这三个 requirements 后运行同一脚本;分类器自检能绿,真正构建立刻因缺 Sphinx 失败。
$ python -m pip show sphinx; echo pip_show_rc=$?
WARNING: Package(s) not found: sphinx
pip_show_rc=1
$ python tools/check_diagram_nitpick.py --check; echo self_check_rc=$?
diagram nitpick: self-check passed
self_check_rc=0
$ python tools/check_diagram_nitpick.py --language en; echo build_rc=$?
the en build failed, so nothing can be concluded:
.../venv/bin/python: No module named sphinx

build_rc=1

这不是平台推测:按当前 YAML,三个正常 matrix 都会走到同一缺依赖调用。至少应让该 job 安装文档构建所需的、兼容各 Python floor 的依赖,再把门禁接入聚合目标。

I:Sphinx 5.3 的真实告警没有 [ref.*],分类器会静默空跑

  • 位置tools/check_diagram_nitpick.py:74
  • 公开到达路径:在仓库声明支持的 Python 3.7 上按 requirements-doc.txt 安装文档依赖并运行 make diagram_nitpick_check。该文件允许 sphinx>=5.3.0,<9,而 Python 3.7 可安装的这一代 Sphinx 输出不含 warning type 后缀。
  • 判定依据实测确认。我用 Sphinx 5.3.0 对声明范围内的 docs/source/explanations/visualization/index.rst 构造一个普通死 :class: 引用,真实构建有告警且退出 0,但 PR 分类器返回空。
$ python -m sphinx --version
__main__.py 5.3.0
$ python -m sphinx -b html -n -q review-minimal/docs/source/explanations/visualization /tmp/out; echo sphinx_rc=$?
.../index.rst:4: WARNING: py:class reference target not found: pyfcstm.diagram.Sphinx53Gone
sphinx_rc=0
$ python probe_classifier.py
.../index.rst:4: WARNING: py:class reference target not found: pyfcstm.diagram.Sphinx53Gone
unresolved=[]
offenders=[]

TARGET 强制要求 \[ref\.,所以这里不是某一种目标没覆盖,而是该受支持 Sphinx 版本上的 nitpick 引用都无法进入分类结果。--check 的合成日志全部带后缀,也无法识别这个退化。

I:白名单只比较 target,不比较 role,错误 role 会被当成合法外部引用吞掉

  • 位置tools/check_diagram_nitpick.py:55-68:131-134
  • 公开到达路径:维护者给公开 Diagram API docstring 写入普通的 Sphinx 引用后运行两个文档门禁;文档用户随后从生成的公开 API 页看到无链接文本。具体反例是在 pyfcstm.diagram.api.Diagram 文档中加入 :meth:`hash```。`hash` 只因仓库中合法的 :func:hash``` 用法进入白名单,错误的 py:meth` 不应随之放行。
  • 判定依据实测确认。两个门禁均退出 0;真实 Sphinx 明确报告 [ref.meth],生成 HTML 是没有 <a>xref py py-meth
$ python tools/check_diagram_references.py; echo reference_rc=$?
diagram reference targets: 41 judged reference(s) all registered
reference_rc=0
$ python tools/check_diagram_nitpick.py --language en; echo nitpick_rc=$?
diagram nitpick: 1 build(s), no unresolved reference in the diagram package
nitpick_rc=0
$ python build_and_probe.py
.../pyfcstm/diagram/api.py:docstring of pyfcstm.diagram.api.Diagram:21: WARNING: py:meth reference target not found: hash [ref.meth]
offenders=[]
$ rg -n -C1 'Mutated dead|hash\(\)' /tmp/out/api_doc/diagram/api.html
411:<p>Mutated dead bare member using an allowed external target: <code class="xref py py-meth docutils literal notranslate"><span class="pre">hash()</span></code>.</p>

白名单需要至少以 (role, target) 为键;仅按 target 放行会把白名单中每个名字的 role 拼写/类型错误都变成漏判。

I:跨行 target 的续行只要含 WARNING,仍会被误当作下一条告警而漏判

  • 位置tools/check_diagram_nitpick.py:97-101
  • 公开到达路径:公开 Diagram API 的类型文档经 autodoc 进入双语 HTML。普通字段写成以下折行形状即可到达,不需要私有 helper 或异常注入:
:type source_text: pyfcstm.diagram.
    WARNINGGone
  • 判定依据实测确认。真实 Sphinx 把目标和 [ref.class] 放到第二行,但第 99 行按子串看到 WARNING 就提前截断。两门禁都退出 0,HTML 留下无链接的 pyfcstm.diagram. WARNINGGone
$ python tools/check_diagram_references.py; echo reference_rc=$?
diagram reference targets: 42 judged reference(s) all registered
reference_rc=0
$ python build_and_probe.py
.../docs/source/api_doc/diagram/api.rst:37: WARNING: py:class reference target not found: pyfcstm.diagram.
WARNINGGone [ref.class]
offenders=[]
$ python tools/check_diagram_nitpick.py --language en; echo nitpick_rc=$?
diagram nitpick: 1 build(s), no unresolved reference in the diagram package
nitpick_rc=0

块终止条件应识别一条新告警的结构,而不是在任意续行中搜索裸字符串 WARNING;否则任意合法 target 文本都能让分类器提前停读。

I:SCOPE 用子串和 POSIX 分隔符匹配,既漏掉 Windows,也误报相邻文件名

  • 位置tools/check_diagram_nitpick.py:44-50:109-119
  • 公开到达路径:一是在仓库声明支持的 Windows 上运行文档门禁,Sphinx 的源位置使用 \;二是在正常文档树增加 docs/source/api_doc/entry/diagrammatic.rst 这类与 diagram.rst 相邻、但不属于声明范围的文件并构建文档。
  • 判定依据:Windows 部分是读代码得出 + 分类器输入实测,我没有 Windows 主机,未冒充端到端实测;边界误报是真实 Sphinx 构建实测确认
$ python probe_scope.py
linux in_scope=True offenders=[('/repo/pyfcstm/diagram/api.py:docstring', 'gone')]
windows in_scope=False offenders=[]
$ python tools/check_diagram_nitpick.py --language en; echo nitpick_en_rc=$?
docs/source/api_doc/entry/diagrammatic.rst:6: pyfcstm.diagram.ScopeBoundaryGone does not resolve
1 unresolved reference(s) in the diagram package. A reference Sphinx cannot resolve renders as plain text; point it at the object's own module, or add it to the allowed list if it is genuinely external.
nitpick_en_rc=1

这里的 diagrammatic.rst 不在 docs/source/api_doc/entry/diagram 这个文件/目录范围内,仅因 any(part in normalised ...) 的前缀子串碰撞被报错。应先把路径规范化为可跨平台比较的组件,再做精确文件或目录边界匹配。

其余重点验证

以下均已实测,没有形成可报告发现:

  • 五种公开产品形态逐一经 StateMachine.diagram().to_html() 解析 payload:loaded=sourceAvailable:true,map:1,reason:"";AST ranges only=false,1,提示传 source_text;AST + source_text=true,1,"";programmatic neither=false,0,提示文本和区间皆无;programmatic text only=false,0,提示无 ranges。五条消息与实际 source/map 能力一致。
  • 基线 make diagram_nitpick_checkmake diagram_reference_targets_check 均退出 0。diagram_reference_targets_check --check 的 8 项逐条反向破坏均退出 1;nitpick 自检的 offender/allowed/block/scope 四项反向破坏也均退出 1。
  • Python 3.7.1, 3.8.1, 3.9.1, 3.10.1, 3.11.1, 3.12.1, 3.13.1, 3.14.1 逐版运行两个 --check,输出均为 reference_rc=0 nitpick_rc=0。这只证明分类器自检的语法兼容,不抵消上面的 Sphinx 5.3 真构建漏判。
  • 仅中文页死引用变异被 nitpick 捕获并退出 1;删掉的“空行段落续行”形状经真实 autodoc 构建只出现在 viewcode,不进入 API 正文,因此未发现用户可达死引用漏报。
  • 双语 HTML:rg -c 'class="problematic"' 分别为 en=0, zh=0;visualization reference 页 rg -c '``' 分别为 en=0, zh=0
  • ruff check tools/check_diagram_nitpick.py tools/check_diagram_references.pyruff format --check ...、Python 3.7 py_compilegit diff --check b028c885..6c7b32c1 均退出 0。
  • git rev-list --left-right --count origin/dev/python-diagram-umbrella...6c7b32c1 输出 0 118,且 merge-base --is-ancestor 退出 0。
  • 全量快照测试结果:46968 passed, 749 skipped, 106 deselected, 6 failed。6 个失败都集中在 source/build identity 对 .git 的判断;用户要求的 git archive 快照没有 .git,diagram 测试全部通过,因此我把它记录为验证环境限制,不列为本 PR 发现。

环境边界:本轮没有 Windows、macOS、NFS、root 或 LaTeX 环境;Windows 结论已按上文明确标成分类器输入实测与代码推导,没有声称完成平台端到端复现。

HansBug and others added 2 commits July 30, 2026 21:38
… branch's work

Neither tool belongs to the diagram feature. They exist because a reviewer suggested a
gate and I built one, twice, and the accounting is plain: in the eleven commits before
the first of them this branch changed 322 lines of package code; in the ten after, it
changed 59, against 1458 lines of tooling and 1050 of documentation. Over the last six
review rounds the findings point at documentation 94 times, at those two tools 54, and
at the package 19. The reviewers were attacking artifacts I had added in response to
being reviewed, and I was expanding them in response to that -- a loop I built, briefed
and fed. One of them also broke a required CI job by needing Sphinx in a job that does
not install it.

So they go, along with their Makefile targets and the paragraph in CLAUDE.md that
described them. What replaces that paragraph is the fact a reader actually needs: a
cross-reference has to name the module `api_doc` documents the object under, a wrong one
renders as plain text in silence, and there is no gate for it here -- a `-n` build
reports it among some 1700 pre-existing warnings elsewhere.

Nothing the tools found is reverted with them. The thirty cross-references they were
built for stay corrected, and a grep for the old spellings finds none.

Kept from the same round, because these are the package's own behaviour: an override
passed to `diagram()` on a model that carries ranges but no source text is now checked
against those ranges. `_validate_source_override` returns early when the model has no
`source_text`, which is exactly that shape -- and exactly the shape the viewer's message
tells the caller to pass `source_text` for -- so a mismatched override was accepted in
silence and the pane linked states to whichever line happened to be at that number. It
raises now, the recommended path still works, and a test pins both. The `source_text`
docstring said programmatic models accept any source, which was false of this shape.

Tests: 195 diagram and CLI tests; `make diagram_docstring_check` 81 examples;
`docs_terminology_check`; `make rst_auto` produced no change; `make -n
diagram_assets_verify` resolves and `make help` no longer advertises the removed gates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sub-PR plans list `ruff check`, `ruff format --check` and
`make rst_auto` as gates, but nothing ran them: the workflows install
flake8 without ever invoking it, and the generated RST was current only
because it had just been regenerated by hand to find out.

- add a `lint` job that runs the diagram surface's ruff contract and
  asserts `make rst_auto` leaves the tree clean
- read the ruff pin out of requirements-dev.txt so the version contract
  keeps living in exactly one place
- scope the paths to the diagram surface on purpose: `ruff check` over
  the whole package reports 941 pre-existing findings and `ruff format`
  would rewrite 66 files, so a repository-wide gate needs that debt paid
  off first rather than the rules relaxed to fit it
- mutation-tested both steps, since a gate that cannot fail is not one:
  an unused import fails the lint step, and a hand-edited RST file fails
  the drift step

`git diff --check`, which the same plans list, is deliberately left out.
This workflow triggers only on push, so there is no pull-request base to
diff against, and on a clean checkout the step would assert nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: editor jsfcstm, VS Code, LSP, and editor workflows. area: packaging Packages, bootstrap/self-check, frozen artifacts, resources, and publishing. area: visualization PlantUML, ELK, diagrams, and rendered images. kind: feature New user-facing or developer-facing capability. role: sub-pr Implements a bounded slice on an umbrella or stacked branch.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant