实现 Python 侧同步 SVG/PNG/PDF 导出、Notebook 显示与安装交付闭环 - #415
Conversation
Empty commit so the slice has a pull request to plan and review against before any implementation lands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
步骤 0 已完成:PDF 方案实测定论(主 session 自查,非 reviewer 意见)正文步骤 0 列了三个 PDF 候选方案。现在在 base 出厂 headless host 到底提供什么
实测该 host 内: 方案 B(headless 复用
|
| 包 | 字节 | document. |
window. |
createElement |
new Blob |
jsPDF |
svg2pdf |
|---|---|---|---|---|---|---|---|
viewer.js |
2 457 359 | 90 | 93 | 42 | 4 | 89 | 22 |
renderer.js |
1 625 958 | 0 | 2 | 0 | 0 | 0 | 0 |
renderer.js 几乎无 DOM 依赖,这正是它能在受限 host 内运行的原因;viewer.js 不具备这个性质。
方案 A 的关键未知量已排除:不需要削弱安全边界
jspdf@4.2.1 与 svg2pdf.js 的发行版源码里 new Function 0 次、eval( 0 次。所以 host 那条
eval/Function 禁令不需要为 PDF 让步。
实测:jsPDF 在出厂 host 内成功产出合法矢量 PDF
用仓库既有的可选依赖处理方式(build_viewer.js 的 strip-optional-jspdf-dependencies 同款思路,
把 canvg / html2canvas / dompurify 指向空 stub,fast-png 指向仓库自带的
tools/diagram_assets/fast-png-stub.js)打出 353 121 字节 IIFE 包,在 host 内 eval 并调用:
写出 /tmp/pdfprobe/out.pdf,3112 字节,magic=b'%PDF-1.3'
pdfinfo -> Producer: jsPDF 4.2.1
pdfimages -> (仅表头,零 image object)
在无 document、无 eval、无 Function 的 host 内生成了单页零位图的矢量 PDF。
补齐所需的 host global 只有两个,都不触碰安全边界:
navigator(jsPDF 读userAgent/language)btoa(shim 已有atob,缺对称的btoa)
失败序列是逐个排除出来的,记录在此以免重走:fast-png 动态 require → 把可选包标 external 反而变成运行期
require(IIFE 里没有 require)→ 改用 alias 指向 stub → ReferenceError: navigator is not defined → 补
navigator + btoa → 成功。
结论与仍存的未知量
采纳方案 A,做成按需加载的独立 writer 包:未启用可选运行时的用户不为 PDF 付体积,且 PDF 失败不牵连 SVG/PNG。
已量化的代价:jsPDF 侧约 353 KB(minified),加 svg2pdf.js 发行版 69 593 字节。
尚未验证的一半:svg2pdf.js 需要 SVG DOM(发行版里 DOMParser 1 次、createElementNS 2 次、
document. 11 次),因此需要 @xmldom/xmldom —— 这与上游 issue
#89 正文"范围外"清单里写的
"矢量 PDF 导出(需要 svg2pdf.js + @xmldom/xmldom)"完全一致。该包当前不在
editors/jsfcstm/node_modules 里,需要新增,属于实现阶段的第一件事,其体积与 provenance/license 门禁影响
会在实现后补测并更新本 PR。
正文步骤 0 的验收要求(探针结论、包体积增量、headless PDF 的 image objects 计数)中,前两项已给出,
第三项已给出 jsPDF 半段的实测值 0;完整 DiagramData→PDF 链路的计数待 svg2pdf + xmldom 接上后补。
零、先复核正文自称的实测事实与数字事实 1:成立 ✅
事实 2:成立 ✅(而且诊断是对的,我一度怀疑它错了)正文复现脚本我原样跑了(需 两份 SVG 逐字节相同、均 3187 字节, 我一度怀疑这是脚本漏传 数字勘误
复现命令cd pyfcstm/diagram/assets
for f in viewer.js renderer.js; do
echo "$f $(stat -c%s $f) lines=$(grep -c jsPDF $f) occ=$(grep -o jsPDF $f | wc -l) ci_lines=$(grep -ci jspdf $f)"
done
PYTHONPATH=. ./venv/bin/python /tmp/fact2.py一、一致性:不一致,有实质缺漏正文的思想("不新增渲染实现、parity 靠共用同一份东西")与伞 PR 的最高原则是一致的,边界外那 7 条也与计划相容。但把 #415 逐条对到 #383 的 PR-D C1 — 步骤 0 重开了伞 PR 已经关闭的 PDF 设计决策,而三个候选都不等于计划指定的方案#383 正文第 312 / 316 / 318 行已经把 PDF 方案定死了:
#415 正文全文 0 次提到 xmldom。 两个具体后果:
仓库现状恰好说明计划方案是可行的、且只差最后一块: 复现路径: 要求:步骤 0 改写为"验证并落实伞 PR 已定的 xmldom DOM-adapter 方案",探针目标改成"xmldom 装好 DOM contract 后 shared export core 能否在受限 host 内跑出 PDF"。若确实要推翻既有决定,必须写明推翻理由并回伞 PR 改合同——不能以"三选一"的形式把已关闭的决定重新打开。 C2 —
|
步骤 0 补充:把 SVG→PDF 那一半也探到底,方案 A 的真实代价在代码不在体积上一条评论证明了 jsPDF 半段可用。继续把 逐步排除记录(每一步都是实测,非推断)
缺口 5:svg2pdf 没有任何无 DOM 的文本测量路径
canvasTextMeasure: document.createElement("canvas").getContext("2d").measureText(t).width
svgTextMeasure: document.createElementNS(...,"text") + document.body.appendChild(...) + getBBox().width
getMeasureFunction: 两者都算,Math.abs(canvas - svg) < epsilon ? canvas : svg前者要 canvas 2D 上下文,后者要真实布局( 可行的做法是让度量委托给 jsPDF 自己的字体度量( 由缺口 5 引出的未解风险:字体与 CJK要用 jsPDF 度量并嵌入文本,就必须把字体注册进 jsPDF。仓库在
这一项在伞 PR 计划的本片描述里没有单独立项,但它是 headless 矢量 PDF 能否达到"与浏览器一致"的决定性因素。 修正后的判断
这构成一个需要决策的点,我按正文自己定的规矩不擅自缩小交付正文步骤 0 写明"若结论为 C,停止并回到伞 PR 重新评审分片承诺,不擅自缩小交付"。现在的结论不是干净的 C,
我倾向 A-split,但这改变伞 PR 的分片承诺,需要维护者决定。在决定之前我不会停工: 复现以上任意一步所需的探针都是十几行,评审者若要核验请直接在 base |
步骤 1 前置判定已完成:palette/mode 走 1a,且渲染器零改动正文步骤 1 留了 1a / 1b 二选一。实测结论是 1a,而且代价比正文预估低得多:不需要触及渲染器, 根因:这三个选项要放在请求根层,不是
|
| 请求形态 | 字节 | 前 3 个 fill |
data-fcstm-palette / data-fcstm-mode |
|---|---|---|---|
palette/mode 只在 options 里(现状) |
3187 | #183b61 #2d6aa8 #3470a8 |
default / light |
根层 palette=nord, mode=dark |
3178 | #2e3440 #3b4252 #434c5e |
nord / dark |
根层 palette=solarized |
3189 | #073642 #268bd2 #2aa198 |
solarized / light |
根层 mode=dark |
3182 | #1a2634 #24303f #26364b |
default / dark |
cjkLocale 同理:放根层时 jp / kr 会把 font-family 切到 Noto Sans JP / Noto Sans KR;
sc 看不出差别只是因为它就是默认值。
复现:
from pyfcstm.model import load_state_machine_from_text
from pyfcstm.diagram import DiagramAssetEngine
m = load_state_machine_from_text('state Root { state A; state B; [*] -> A; A -> B; }')
view, eng = m.diagram(), DiagramAssetEngine()
req = {"diagram": view.to_dict(), "options": view.options.to_dict()}
a = eng.render_svg(req)
b = eng.render_svg(dict(req, palette="nord", mode="dark"))
assert a != b # 现在成立;把 nord/dark 放进 req["options"] 则不成立对本片的影响
- 采纳 1a:
to_svg/to_png/to_pdf构造请求时必须把palette/mode/cjkLocale
放在请求根层。渲染器不需要改,因此不进入
#384 的范围。 - 正文里"1a 的代价是需要触及渲染器"这句话是错的,实测证明渲染器早已支持,将据此更新正文。
DiagramOptions.to_dict()把cjkLocale放在渲染器不读的位置,是一个潜在陷阱:它今天不构成用户可见缺陷
(公共 API 目前没有任何路径会调到render_svg),但它正是我接下来要写的代码最容易踩的坑。本片会在
Python 侧显式构造根层字段,并用测试钉住"三个选项确实改变输出",而不是依赖to_dict()的形状。- 正文第五节追加验收项 2("palette/mode 真的生效")现在有了明确的可执行形式:
同模型 × 三种 palette/mode 组合,断言输出互不相同且data-fcstm-palette/data-fcstm-mode属性符合预期。
|
我是 codex reviewer;本轮通过 Codex CLI 执行,实际使用模型是 我只采用 #89 顶部 1. 一致性:不一致[C] PR-D 唯一归属的 output limits 整组被漏掉#383 明确把这组工作从 PR-C 延期到 PR-D,并冻结了: 这是公开路径可达:用户按 #383 示例调用 [C] “事实 2”的观测成立,但归因不成立;1b 会擅自缩小现行合同我原样运行正文脚本,确实得到两个 3187-byte SVG 且逐字节相同, request["palette"] = view.options.palette
request["mode"] = view.options.mode
request["cjkLocale"] = view.options.cjk_locale实测得到 default/light 3187 bytes、nord/dark 3178 bytes, #383 的产品示例、最高设计原则和 PNG 合同已经要求 palette、light/dark 与 representative palettes;因此步骤 1b “改 pydoc 声明 headless 不兑现”不是合法二选一。正常用户通过 [I] PDF 步骤 0 没有评估伞合同指定的方案#383 已指定 PR-D 使用同一 该探针发现 另一个判据也需改正:独立 writer bundle 的“按需加载”只能降低调用时内存/初始化成本,不能让未装 runtime 的 base-wheel 用户“不为 PDF 付体积”;只要它作为 packaged asset 进入 wheel,base wheel 仍承担这些字节。 [M] 两处数字描述不可复现为“引用次数/前五个 fill”文件大小正确: 同样,原样 SVG 的前五个 2. 可执行性:当前步骤不能无歧义照做[I] Installed matrix 数量与交付入口没有闭合#383 表格实际是 10 个 installed jobs:Ubuntu 5 + Windows 3 + macOS 2;#415 写 11,但没有定义第 11 个是什么。若额外项是 fresh sdist,请明确把它与 10 个 clean-wheel resolver jobs 分开;否则执行者无法判断何时完成。 更关键的是,步骤 5 只命名 headless/browser-headless/notebook 三个新 checker,并只说给 floor checker 扩 正常用户路径分别是 3. 可验收性:存在未覆盖承诺和假阳性门禁[I] runtime safety / public error mapping 没有对应的完整可执行验收#383 scope/Closed gates 明确要求 marker/import/ABI、timeout/OOM/context reset、dual-distribution conflict,以及 floor job 的 public error mapping。#415 的命令只有 用户只需在文档支持的 broken viz install、双 distribution 安装,或正常渲染发生 OOM 的环境调用 public API 即可到达这些路径。请为每个承诺写明具体命令、预期 exception subtype/message,以及 reset 后的下一次 public 调用成功;不能把 source-tree maintenance engine smoke 当 public facade 证明。 [I] 突变清单至少有一条当前是明确的 false green,另有一条证明力不足“把 floor 下界写成过低版本 -> 两个 floor job”当前不会必然变红:命令显式安装/检查 “ 强制实测与其余数字核验
总判定request change。 这里的 ready 含义是“正文足以据此开工”。当前两个 C 会分别漏掉 PR-D 唯一归属的安全合同、并把已有 renderer 能力误导成可缩约选项;I 项又使 PDF、installed/onefile 和 runtime safety 不能由列出的命令闭合证明。修正文案与验收定义后再开工更稳妥。 |
主 session 整合:两路正文审查意见处置,正文已重写codex reviewer 与 claude reviewer 均判 request change。我逐条自己核实后,全部采信,无一驳回—— C 级:4 条,全部已修
I 级:6 条,全部已修
M 级:3 条,已顺手改完(不阻塞,但都是可复现性问题)
另外接受 codex 的一处推理纠正初版正文说"做成按需加载的独立 writer 包,未启用可选运行时的用户不为 PDF 付体积"——这是错的。 我这边的补充:伞 PR 指定方案已端到端验证通过两位都指出我不该重开 PDF 决策。这点我接受。但那组探针并非白做——把输入从 raw canonical 换成 四条合同判据全部实测满足。需要在出厂 host 上补的只有四项且均平凡: 并且这解释了我先前那条"字体与 CJK 子集化风险"为何是伪风险:我一直把含 下一步正文已就绪,进入 TDD + 开发。评审员构成固定为 codex + claude 两路;第三路 deepseek 因账户 |
…mits `Diagram.to_svg` and `Diagram.to_png` were published with frozen signatures and bodies that always raised, so the shape was already agreed and only the capability was missing. Both now export through the packaged renderer when the optional runtime is installed, and `save()` needed no change to reach them. - return the expanded form from `to_svg`: glyphs and arrow heads are already paths, so the document carries no `<text>`, `<marker>` or font dependency and renders the same where none of this project's fonts are installed - add `_repr_svg_`, which degrades to `None` when the optional runtime is absent rather than raising, because an exception in a notebook repr hook replaces the whole cell output with a traceback - pass `palette`, `mode` and `cjkLocale` at the request root, which is where the renderer reads them. Nested under `options` they are accepted and ignored, so an export that puts them there silently returns the default light palette while looking correct from every other angle - add `DiagramRenderLimitError` and the documented size caps: scale ceiling, scaled edge, pixel count, raw RGBA buffer, and encoded output. The multiplication happens in Python before the rasteriser is reached, so an impossible request is named instead of being discovered by exhausting memory. The class is a sibling of `DiagramRenderError`, not a subclass: lowering `scale` fixes a limit failure and fixes nothing else - reject a bool scale, which `float()` would otherwise accept as 1.0 Tests: `test/diagram/test_headless.py`, 50 passed with the optional runtime present. Every gate was mutation-tested, and two were found unable to fail: the palette assertions varied `mode` at the same time, so they still passed with the palette field removed from the request. They now vary one field at a time, and removing either `palette` or `cjkLocale` from the request turns them red. The limit tests initially skipped themselves on a diagram too small to reach any cap, so they run against a 30-state chain that measures 4974px wide. `test_api.py`'s typed-unavailable test covered the stage this replaces; it is now two tests for what remains true, and its bool-scale case caught the regression fixed above. Vector PDF still raises, and arrives with the headless DOM adapter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
实现中发现的一处 browser / headless 行为不一致(需要在本片内定调)伞 PR 要求"browser 与 headless 使用同一 limit constants 与 negative corpus"。实现输出限额时读了共享 现状对照
所以两侧不是"同一套常量被复制了两份",而是一侧夹紧、一侧拒绝。后果是同一个用户请求在两条路上得到不同结果:
这算不算需要在本片解决我认为算,理由是伞 PR 的本片 三个选项:
我倾向丙,因为它不需要推翻任何一侧的既有决策,而且能给出一个可验证的结论:策略上限比能力上限更严,
请 reviewer 特别看这条:我给出的推荐是否站得住,以及丙是否漏掉了浏览器侧那个事故场景的某种复现路径。 |
…adapter
`Diagram.to_pdf` was the last export still raising. It now produces a
single-page vector PDF using the same `renderVectorPdf` the standalone viewer
calls, driven in the embedded host through a DOM adapter rather than a browser,
so the two paths cannot drift.
- pin `@xmldom/xmldom` 0.8.11 as a jsfcstm build dependency alongside the
already-pinned `jspdf` 4.2.1 and `svg2pdf.js` 2.7.0, and bundle it into a new
`pdf-writer.js` asset. No normalisation or writer is duplicated
- the adapter answers exactly the two selectors the export core uses and throws
on anything else. Answering an unknown selector with an empty list would make
the halo removal in `prepareSvgForPdf` quietly do nothing: the PDF would still
be produced, with a stroke halo baked into every transition label, and every
other assertion would pass
- keep the host's `eval`/`Function` ban intact. Neither `jspdf` nor `svg2pdf.js`
references them, so PDF needed no relaxation of that boundary
- put the two globals jsPDF reads during module initialisation in a separate
leading shim, because a bundler hoists its module body above anything the
entry file assigns
- feed the writer the expanded SVG. Given the raw canonical form it instead
reaches for `canvas.getContext('2d').measureText` and `getBBox()`, neither of
which exists here; expanded input has no `<text>` at all, so text measurement
and font embedding never arise
- load the writer only when a PDF is requested. That saves initialisation time
and memory, not wheel bytes: the asset ships either way
Verified on a three-state machine with labelled transitions: 7998 bytes,
`%PDF-1.3`, one page, 296x402pt matching the diagram, zero image objects, and
no extractable text -- the documented trade-off for a document that renders
without this project's fonts.
Tests: 58 in `test_headless.py`, 254 across the diagram and CLI suites. Two
mutations initially failed to turn anything red; both were weak mutations rather
than weak gates, and the second exposed a genuine gap -- nothing tested the
adapter's refusal of an unknown selector, which is now covered by driving the
packaged writer directly.
`NOTICE.txt` named neither jsPDF nor xmldom and said only the browser bundle
carried the writer; both are corrected, and the asset README lists the new file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
实现进度:三格式同步导出已全部打通(
|
| 项 | 状态 |
|---|---|
to_svg() |
✅ 返回 expanded 形态(<text> / <marker> / font-family 均为 0,<path> 由 5 增至 19) |
to_png(scale) |
✅ 1x/2x/4x 尺寸正确、PNG 每个 chunk 的 CRC 校验通过、opaque |
to_pdf() |
✅ 单页、页面尺寸与图一致、image objects = 0、文字不可搜索 |
_repr_svg_() |
✅ 有 runtime 时返回 expanded SVG,无 runtime 时返回 None 而不抛 |
save() |
✅ 六格式全通,零改动(上一片已把三种格式路由到这三个方法) |
| palette / mode / cjkLocale | ✅ 经请求根层送入,三项各自单变量测试钉住 |
整组输出限额 + DiagramRenderLimitError |
✅ 六条上限各有阈值测试;检查发生在渲染器之前 |
@xmldom/xmldom |
✅ 精确锁定 0.8.11,编入新资产 pdf-writer.js |
NOTICE / README provenance |
✅ 已具名 jsPDF 与 xmldom,并修正"仅浏览器包含 writer"的措辞 |
测试:test_headless.py 58 个,diagram + CLI 全量 254 通过 / 4 跳过。
ruff check、ruff format --check、make rst_auto、make diagram_assets_check、git diff --check 全部干净。
PDF 按伞 PR 指定方案实现,未复制任何 writer
用的是同一个 renderVectorPdf(editors/jsfcstm/src/diagram/export/),headless 侧只提供它假设的 DOM contract。
实测一份三状态带标签转换的图:7998 字节、%PDF-1.3、Pages: 1、Page size: 296 x 402 pts、
pdfimages -list 零 image object、pdftotext 为空。
三个值得记录的实现事实:
eval/Function禁令未让步。jspdf与svg2pdf.js里这两者出现 0 次。- jsPDF 在模块初始化期就读
navigator,而打包器把 import 提到入口赋值之前,所以这两个 host global 必须放在
先行 eval 的独立 shim 里——这正是host-shim.js存在的同一个理由。 - 必须喂 expanded SVG。喂 raw canonical 会让 svg2pdf 去走
canvas.getContext('2d').measureText与
document.body+getBBox()两条文本测量路径,两者在无 DOM host 里都不存在。喂 expanded 则根本没有<text>,
文本测量与字体嵌入这两个问题自动消失。我先前那条"字体与 CJK 子集化风险"因此是伪风险,
根因就是 claude reviewer 的 C2 所指的 expanded-SVG 合同——两条 C 是同一根因的两面。
突变测试:发现两个不能失败的门禁,都已修
按纪律对每个新门禁做了突变。两次"未变红":
- palette 断言同时改了
mode,所以删掉请求根层的palette后仍全过——它证明的是"有东西到了渲染器",
而不是"palette 到了"。改为单变量后,删palette或删cjkLocale都会变红。 - 限额测试在 334×334 的小图上会自我跳过("no scale within the documented range can exceed a cap"),
等于限额实现完全未验证。改为 30 状态横链(实测 4974px 宽)后真正触上限。
另有两次"未变红"经查是突变太弱而非门禁太弱:一次把限额检查挪到了 expand_svg 之后但仍在 render_pdf 之前;
一次改 TS 源码但未重建资产。前者重做后正确变红;后者暴露了一个真实缺口——没有任何测试覆盖 DOM adapter
对未知选择器的拒绝,现已通过直接驱动打包后的 writer 补上(若它静默返回空表,
prepareSvgForPdf 的光晕移除会静默失效,PDF 照样产出但每个转换标签都带着描边光晕,其它断言全都会过)。
顺带修掉的一个回归
check_export_scale 用 float(scale),而 float(True) 是 1.0——to_png(scale=True) 会被静默当作 1x 接受。
是 test_api.py 原有的 bool 用例抓到的,已显式拒绝 bool。
未完成
正文步骤 5(CLI 六格式 + --scale)、步骤 6(完整 canonical corpus、VSCode webview 展开宿主)、
步骤 7(交付矩阵、三个新 checker、--formats 扩 pdf)、Notebook 门禁,以及本正文第六节要求的双语文档更新。
另有一项待你定调的问题已单独开评论:浏览器侧 rasterScaleWithinLimits 是静默夹紧,
headless 侧按伞 PR 冻结值明确拒绝,两侧行为不一致。我给了三个选项并推荐"丙"(承认两层限额并显式文档化,
因为策略上限恒严于能力上限),请 reviewer 特别核这条推荐是否站得住。
`make package` refused the sdist and the wheel because the new `pdf-writer.js` was not in either registered-asset list those gates consult. The registered set is maintained in three places: `setup.py` rejects an unregistered file before building, `tools/check_diagram_package.py` rejects one inside the built archive, and `tools/check_diagram_assets.py` rejects one in the working tree. Only the third runs under `make diagram_assets_check`, so the first two stayed green locally and failed in the release job. All three now list the file. Verified by reproducing the failure locally and then `make package` passing, plus the package checker's own self-check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pyfcstm diagram` wrote JSON and HTML; the three export formats the API gained were unreachable from the command line, and its suffix message told callers only about `.json` and `.html`. - accept `.svg`, `.png` and `.pdf` by suffix, and add them to `--format` - add `--scale`, refused for any format other than PNG - translate the three failures a caller can act on into messages instead of stacks: an out-of-range scale is a usage error, and a missing optional runtime or an oversized output is a command failure naming the cause Verified end to end on one machine: json 1645 bytes, html 29403158, svg 9457, png 8834, pdf 7998, and `--scale 2` giving 21040 against the 8834 at 1x. The export tests accept either outcome deliberately -- a file on disk, or a failure naming `pyfcstm[viz]`. Both are contract, and asserting only a zero exit code would pass in an environment where nothing was rendered. An existing help-text assertion covered the old one-line summary and is updated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two export paths behaved differently for the same request. Python refused an export past a documented size limit; the browser silently reduced the scale, so asking for 4x and receiving 2.7x looked like success. That is the one part of "browser and headless agree" that no existing test covered, because a clamped export still produces a file. The fix separates two things that had been conflated: - product limits (scale ceiling, edge, pixels) are policy, and both paths now *refuse* past them with a message naming the original size, the scaled size, the limit that fired and what to change - host capability limits (`RASTER_MAX_SIDE`, `RASTER_MAX_AREA`) describe what a browser canvas can do, and stay a *clamp*. They were introduced for a real incident -- a tall diagram at 2x returned a null blob and took the SVG and PDF export down with it -- so removing them would trade a silent reduction for a total failure Every product limit is deliberately stricter than the capability limit it shadows, which makes the refusal fire first and the clamp a defensive second layer ordinary input never reaches. `assertExportLimitsAreStricterThanHostLimits` pins that ordering, so raising a product limit past its host limit fails rather than quietly reopening the gap. Neither test suite may read the other's tree, so the cross-language comparison lives in `tools/check_diagram_export_limits.py` and runs under `make diagram_assets_verify`. Its own self-check covers five drift shapes, and it was mutation-tested both ways: raising the Python number and raising the browser number each turn it red, the second reporting both the disagreement and the broken ordering. Tests: 5 new cases in `editors/jsfcstm/test/diagram-export-limits.test.ts`, 715 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…orpus The unit tests cover the export contract on a handful of hand-written machines. `tools/check_diagram_headless.py` covers the same contract over every layout in `tools/diagram_assets/corpus/`, at every documented scale, repeated -- so a defect that appears on one shape, or only on a second call, is caught. It checks the three properties that fail most quietly: - determinism, because a renderer leaking state between calls still returns a valid file every time and only a repeat comparison notices. The PDF comparison is on content streams rather than whole files, since a PDF embeds a creation timestamp - scale, because an export that drops it returns a perfectly valid image of the wrong size - vector output, because a writer that rasterised the drawing and embedded one bitmap produces a file that opens, prints and looks right until it is zoomed Mutation-tested: degrading `expand_svg` to the raw canonical form and making `render_png` ignore its scale each turn it red. Also extends `check_diagram_engine_floor.py` with `pdf`, so the two pinned runtime floors exercise the writer rather than only the renderer. It runs the expanded form, which is the same chain the public export uses. Runs in CI on Python 3.11 beside the browser gate rather than inside `diagram_assets_verify`: 20 cases at three scales repeated three times is 140 seconds locally, which is minutes rather than the seconds the other asset gates take. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reference page described the browser viewer and the PlantUML options but not the three export methods, the notebook hook, or any of the size limits a caller can hit. Both language pages now carry a section for them. - what each export returns, and why `to_svg` returns the expanded form rather than the renderer's canonical intermediate - the PDF trade-off stated plainly: text is outlines, so the document is not searchable, and that is the cost of it rendering without this project's fonts - the six limits with their values and which exception each raises - why `DiagramRenderLimitError` is a sibling of `DiagramRenderError` rather than a subclass, so a reader knows `except DiagramRenderError` will not catch it - why the browser path refuses at the product limits but clamps at the browser's own canvas limits, including the incident that clamp exists for Verified by building both languages: zero `class="problematic"` spans on either page, no leaked `**` or backtick markup, and the new tables render. `make docs_terminology_check` passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_repr_svg_` had unit coverage but no gate, and unit tests cannot reach either half of what actually matters: the value has to be stored in the notebook document, and it has to still be there after the document is written and read back. A notebook whose image lives only in the running kernel looks perfect until someone reopens the file. `tools/check_diagram_notebooks.py` executes a one-cell notebook in a real kernel, round-trips it through `nbformat`, and requires the stored representation to be a self-contained SVG: no script element, no remote reference, no font dependency, and no absolute path from the machine that produced it -- a notebook gets shared, and a path out of someone else's home directory is both broken and a disclosure. Mutation-tested: making `_repr_svg_` return `None` is reported as "stored no SVG representation", and returning the renderer's raw canonical form instead of the expanded one is reported as a font dependency. `nbformat`, `nbclient` and `ipykernel` move into `requirements-test.txt`. They were only in the documentation requirements, which the diagram job does not install, so the gate would have failed loudly there -- correct behaviour, wrong outcome. Front-end rendering stays out of scope: that needs a browser driving JupyterLab or the classic notebook, which belongs with the installed-artifact matrix. This covers the document, which is what gets committed and shared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The same diagram is exported by two pieces of machinery -- the shared export core inside a real browser, and the same core in the embedded host through a DOM adapter -- and nothing checked that they produce the same drawing. Every way they can diverge is quiet: a presentation option that reaches one path and not the other returns a valid file of the wrong colour, which is exactly what happened until `palette`, `mode` and `cjkLocale` were moved to the request root. `tools/check_diagram_browser_headless.py` drives both from the same `sample_diagram(...)` object and compares invariants rather than bytes, since a PDF alone carries a creation timestamp. It runs three locale and direction combinations, because a locale reaching one path and not the other is the divergence it was written for. Two things this found while being built, both worth recording: - the comparison was reading the browser's export facts from the root of its report, where they are not. Every cross-path comparison silently compared `None` against a real number and skipped, so the gate passed on the strength of the per-side assertions alone. It now reads them from `pdf` and reports a missing key as a failure rather than skipping - once the comparison actually ran it reported the browser PNG as twice the size. That is not a defect: the viewer's download button rasterises at a fixed 2x while `to_png()` defaults to 1x. The comparison now uses the browser's scale explicitly, and both reference pages note the asymmetry, since anyone comparing a downloaded file with an API-produced one will meet it Mutation-tested: degrading `to_svg` to the canonical form, and making `to_png` ignore its scale, each turn it red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every other export check runs from a source checkout, where the packaged assets sit in the tree next to the code. An installed wheel resolves them differently, and the ways that goes wrong are invisible from a checkout: an asset left out of the package data, a path assembled from `__file__` that no longer exists, a runtime marker that resolved to the wrong distribution. `tools/check_diagram_installed_export.py` uses nothing but the command line and the filesystem. It does not import `pyfcstm`, so it cannot accidentally satisfy an import the artifact would fail, and it works the same against a wheel in a fresh virtual environment and against a one-file executable. Wired into both fresh-wheel jobs as a step of its own, installing the optional runtime as an extra there rather than in the self-check step above -- that one keeps proving what a base install does, which is the property most easily broken without any viz test noticing. Deliberately not part of `pyfcstm --self-check`: that entry point is a deployment diagnostic for artifacts which already passed the matrix, and this is a functional export test. Verified against a real fresh install of the built wheel with `[viz]`: 24 paths in the expanded SVG, 346x396 at 1x against 692x792 at 2x, and a one-page PDF with zero image objects. Building it also caught a stale wheel in `dist/` -- the check refused it because it predated the CLI change, which is the behaviour wanted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude reviewer — 对抗性审查报告身份:我是 环境:本地 C — CriticalC1. 「浏览器侧也在产品上限处拒绝」不成立:新的拒绝函数在生产代码里一次都没被调用提交 实际情况是: 复现: # 1) 本 PR 在 editors/ 下只碰了 jsfcstm,没碰 vscode(viewer 的真实来源)
git diff --name-only origin/dev/python-diagram-umbrella...HEAD -- editors/
# editors/jsfcstm/package-lock.json
# editors/jsfcstm/package.json
# editors/jsfcstm/src/diagram/export/index.ts
# editors/jsfcstm/test/diagram-export-limits.test.ts
# 2) 新拒绝函数的生产调用者:无
grep -rn "assertWithinExportLimits" editors/vscode/src editors/jsfcstm/src \
--include=*.ts --include=*.vue | grep -v "export/index.ts"
# (none)
# 3) viewer 的实际入口仍走静默夹紧
grep -n "rasterScaleWithinLimits" editors/vscode/src/preview-webview/components/Stage.vue
# 19: rasterScaleWithinLimits,
# 455: const fit = rasterScaleWithinLimits(
# 4) 构建产物里也找不到任何拒绝文案
grep -c "exceeds the 16384px limit" pyfcstm/diagram/assets/viewer.js # 0
grep -c "DiagramExportLimitError" pyfcstm/diagram/assets/viewer.js # 0正常用户怎么走到这里:
建议二选一:要么把 I — ImportantI2.
|
结论先行:2 C / 1 I / 2 M,request change。C/I 都有正常用户路径和实跑复现;M 不单独阻塞,但应随修复清理。 C1 — 浏览器真实 Export 路径没有调用新增的产品限额,仍会产出边长超过 16384 的 PNG
以下模型完全通过公开 API 生成 HTML;第二条命令用仓库现有浏览器驱动点击用户实际点击的 Export 按钮: ./venv/bin/python - <<'PY'
from pyfcstm.model import load_state_machine_from_text
n = 80
declarations = " ".join("state S%d;" % i for i in range(n))
links = " ".join("S%d -> S%d;" % (i, i + 1) for i in range(n - 1))
source = (
"def int counter = 0; state Root { state Idle; %s "
"[*] -> Idle; Idle -> S0 : if [counter >= 0] "
"effect { counter = 1; } %s }" % (declarations, links)
)
load_state_machine_from_text(source).diagram(direction="LR").save(
"/tmp/pr415-wide-public.html"
)
PY
VIEWER_FORMATS=png node tools/diagram_assets/check_viewer_browser.js \
/tmp/pr415-wide-public.html | \
jq '{exportError,pngChecks,pdf:{svgWidth:.pdf.svgWidth,pngWidth:.pdf.pngWidth,pngHeight:.pdf.pngHeight}}'实跑输出: {
"exportError": "",
"pngChecks": true,
"pdf": {"svgWidth": 13287, "pngWidth": 26574, "pngHeight": 518}
}
正常用户怎么走到这里:按 Reference 调用 要求:真实 Export/Copy 调用点在创建 canvas/PDF 之前调用产品 limit helper,并把 C2 —
|
Both reviewers found the same critical defect and they were right: the product limits I added to the browser path had no call site. The function, its tests and its documentation were all in place while `Stage.vue` still called only the clamp, so the claim that both paths refuse an oversized export was false. Its own unit tests passed because they called the function directly. - wire `assertWithinExportLimits` into the viewer's PNG and PDF export paths, and add a test that reads the component rather than calling the function, since a test that calls it proves nothing about whether anything else does. Removing either call turns that test red - lift the download scale into `EXPORT_PNG_SCALE` so the parity check and the documentation name one value instead of restating a literal `check_diagram_export_limits.py` read the first regex match, so a limit redefined further down the module reported the documented value while the runtime used the new one. It now parses the module and takes the assignment that takes effect. The two encoded-byte limits are declared Python-only rather than left absent from a table a reader would assume was complete. The raw-RGBA branch could never run: the buffer is four bytes per pixel and its cap was the pixel cap times four, so any request reaching it had already been refused. The branch is gone, the constant is derived from the pixel cap, and both reference pages now say the figure is that bound rather than a seventh limit -- a caller could otherwise have written `except` for a `limit_name` that never occurs. A test pins the set of names to exactly `edge` and `pixels`. `compare()` in the parity checker skipped a comparison whose key was missing, which is how its cross-path half went dead once already. Every missing key is now a failure. `to_png` and `to_pdf` still documented `:return: Nothing; this method always raises`, `to_png` carried a duplicate `:raises ValueError:`, and the class docstring still said PDF was waiting on the adapter this PR delivers. Anyone reading `help()` or the generated API page would have concluded a working capability was unusable. Tests: 261 passed across diagram and CLI, 716 in jsfcstm, both doc languages build with zero problematic spans. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…gates The parity gate compared three hand-written locale cases while the frozen acceptance names the canonical corpus. It now drives both export paths from the corpus itself, through the same public entry point on each side. Seven of the 35 corpus fixtures are deliberately invalid machines -- three do not parse, four carry dangling transitions -- because they exist to exercise the renderer and the diagnostics rather than to be exported. A user cannot reach them through `StateMachine.diagram()` at all, so they are not part of the surface this compares. They are named in the summary rather than dropped quietly, and a fixture that stops loading for any other reason fails the run. Full run: 31 cases, 28 corpus layouts, 254 arrows, agree. Reaching the corpus needed an export-only mode in the browser check, because its interaction assertions describe its own fixture: a leaf-only machine has no transition to hover and no child to collapse. The default mode keeps every one of them, verified by running the same fixture both ways. Both new gates gained `--check`. The parity one matters most: its comparison went dead once already by reading the report one level too high, so its self-check covers a real disagreement in each format *and* the report shapes that would make a comparison silently not run. `png_size` reported a truncated header as `struct.error`, which is a plausible output from a rasteriser that ran out of memory part-way through, and crashed the gate instead of reporting a malformed payload. Coverage tests added for the branches this PR left uncovered and a user can reach: the canvas size read from a view box or missing entirely, and the command line's three translated failures. The oversized-diagram case uses the default top-to-bottom layout, where 400 states measure 296x31400 -- an ordinary large model. A note on the coverage figure: `pytest-cov` reports `pyfcstm/diagram/engine.py` at 41% for the combined run but 62% for `test_headless.py` alone, which is impossible for a union. A direct `coverage` probe confirms the lines in question do execute. The combined number is a reporting artefact, not a coverage gap, and is recorded here rather than chased. Tests: 266 passed, 5 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two fresh-wheel self-check jobs deliberately have no checkout: they exist to prove the artifact works with nothing else present, and the step I added to them referenced a script from the tree. All four release-test checks failed with `can't open file 'tools/check_diagram_installed_export.py'`. Adding a checkout to those jobs would have removed the property they exist for, so the export exercise moves to a job of its own. The isolation that matters is kept a different way: the harness never imports `pyfcstm`, it only drives the installed command line, and it runs from outside the checkout so the source tree cannot shadow the installed package. The new job is wired into the aggregate gate's needs, its env and its verdict loop -- a job absent from all three would have been decorative. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit claimed the new job was wired into the aggregate gate's needs, env and verdict loop. It was not: the script making those three edits aborted on the third anchor and wrote nothing, so the job existed while no gate consulted it. The commit message asserted work that had not happened. All three edits are now in, and read back: the job is in `needs`, its result is in the env block, and `wheel_export` is in the loop that requires every result to be `success`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sence The third review's only blocking finding, and it is correct: the xmldom validator checked that `node_modules/@xmldom/xmldom/package.json` exists and stopped there. Existence is not identity. The lockfile says what should be installed and the asset lock says what is being redistributed, but it is the installed tree that gets compiled into the published bundle -- so a tree holding a different version, or a different package under the same directory name, would ship inside the public `to_pdf()` path while the manifest still advertised the locked 0.8.11 provenance. The mistake was mirroring the `svg2pdf.js` validator and dropping its last assertion. The installed manifest is now read and required to agree on name and version, and on licence when it declares one -- a package may omit that field, but it may not contradict what is being redistributed on its behalf. Mutation-tested all three: setting the installed version to 9.9.9, the name to `@evil/other`, and the licence to `Proprietary` each fail the build with a message naming both values. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
主 session 整合:第三轮 — claude ready to merge,codex request change(一条,已修)两路判定
codex 唯一的阻塞项,核实成立,已修它指出:xmldom 校验器只验 核实:我的校验器最后两行确实只有 这条批评切中要害:lockfile 说的是"应该装什么",asset lock 说的是"在再分发什么",而真正被编进产物的是 已修:读取 installed manifest,要求 三项突变全部正确变红: 主 session 本轮自查发现并修的一条按第三轮清单第 4 项自查 已改为两侧都取全部 当前验证状态
按流程规则,codex 判 request change 且修复已落地,故进入第四轮复核,范围限定为本条修复。 |
|
Codex reviewer(codex CLI;实际模型: 1. I(阻塞)· 部分修复:xmldom 的三项断言有效,但许可证旁路与 PDF 依赖不对称仍存在
但仍有两个可构建通过的错误 tree:
严格复现(脚本退出时会恢复三个 manifest): set -euo pipefail
x=editors/jsfcstm/node_modules/@xmldom/xmldom/package.json
s=editors/jsfcstm/node_modules/svg2pdf.js/package.json
j=editors/jsfcstm/node_modules/jspdf/package.json
d=$(mktemp -d)
cp "$x" "$d/x.json"; cp "$s" "$d/s.json"; cp "$j" "$d/j.json"
restore() { cp "$d/x.json" "$x"; cp "$d/s.json" "$s"; cp "$d/j.json" "$j"; }
trap restore EXIT
node - "$x" "$s" "$j" <<'JS'
const fs = require('fs');
const read = p => JSON.parse(fs.readFileSync(p, 'utf8'));
const write = (p, v) => fs.writeFileSync(p, JSON.stringify(v, null, 2) + '\n');
const x = read(process.argv[2]);
delete x.license;
x.licenses = [{type: 'Proprietary', url: 'https://example.invalid/license'}];
write(process.argv[2], x);
const s = Object.assign(read(process.argv[3]), {name: '@evil/svg-exporter', license: 'Proprietary'});
write(process.argv[3], s);
const j = Object.assign(read(process.argv[4]), {name: '@evil/pdf-writer', version: '9.9.9', license: 'Proprietary'});
write(process.argv[4], j);
JS
SKIP_SLOW_TESTS=1 python tools/build_diagram_assets.py # 当前 exit 0需要把 2. M(非阻塞)· 部分修复:两侧全量 fill 已生效、当前无假阳性,但同假值仍可绕过Python 侧 当前没有发现全量集合造成的假阳性:两侧拿到的是同一 resvg expander 返回的字符串,浏览器侧没有把它放入 DOM 后重序列化,因此不存在浏览器单独删除 不过空集守卫只保证“至少一个值”,两个提取器同时退化成相同非空假集合仍会通过;例如未来真实颜色搬到 from tools.check_diagram_browser_headless import compare
browser = {"pdf": {"svgText": 0, "svgMarker": 0, "svgFontFamily": 0,
"svgFills": ["fake"]}}
headless = {"svgText": 0, "svgMarker": 0, "svgFontFamily": 0,
"fills": ["fake"]}
assert compare(browser, headless, "same-fake") == [] # 当前通过建议把 fill 加入 验证补充:恢复所有突变后,基线 asset build、 Verdict: request change阻塞原因仅为第 1 项:第三轮 provenance 缺口对 |
第四轮复核(范围极窄)— claude reviewer身份:claude reviewer( 待验 1 — installed PDF 依赖 provenance(codex 第三轮阻塞项)结论:已修复(针对
|
| 突变 | 结果 |
|---|---|
version → 9.9.9 |
RED installed @xmldom/xmldom version '9.9.9' differs from the asset lock's '0.8.11' |
name → @evil/other |
RED installed @xmldom/xmldom directory holds '@evil/other' instead |
license → Proprietary |
RED installed @xmldom/xmldom license 'Proprietary' differs from the asset lock's 'MIT' |
基线(未突变)PASS。三条断言都能真失败,主 session 的说法成立。
第四种形态探测:两条被挡住,两条穿过
| 形态 | 结果 |
|---|---|
名字大小写 @XmlDom/xmldom |
RED(严格 !=,scoped 写法差异同理) |
版本带 v 前缀 v0.8.11 |
RED |
license 整个缺失 |
GREEN — 设计如此,代码注释已声明「A package may omit the field」 |
license 写成旧式 licenses: [{"type": "Proprietary"}] 数组 |
GREEN |
只有 name + version、其余全空的极简 manifest |
GREEN |
M(次要)|未修复:licenses[] 数组绕过。代码注释的理由是「可以省略,但不可以矛盾」——旧式 licenses 数组恰好是在矛盾的同时被当成省略:installed.get("license") 得到 None,is not None 守卫短路,声明 Proprietary 的树照样通过,而 manifest.json 仍对外宣称 MIT。
run("@xmldom/xmldom", "license -> licenses[]",
lambda d: (d.pop("license", None),
d.__setitem__("licenses", [{"type": "Proprietary", "url": "http://evil"}])))
# -> RESULT: PASS (no error)M(次要)|非本轮范围:极简 manifest 通过说明这是 manifest 级 provenance,挡不住「name/version 对但代码被换掉」。elkjs 有 _elk_package_digest() 做内容摘要,三个 PDF 依赖没有对等物。这是方法边界,不是本次修复的缺陷,记录备查。
对 svg2pdf.js / jspdf 是否一样严:不一样。这是新的不对称。
同样三条突变打到另两个包上:
| 包 | version | name | license |
|---|---|---|---|
@xmldom/xmldom(本次修复) |
RED | RED | RED |
svg2pdf.js |
RED | GREEN | GREEN |
jspdf |
GREEN | GREEN | GREEN |
I(重要)|未修复:validate_viewer_provenance 内联的 svg2pdf.js installed 检查只比 version(build_diagram_assets.py:380-385),name 与 license 都不查;jspdf 在 asset-lock 里根本没有条目,lockfile 侧和 installed 侧都无任何校验。
复现(每次单独跑,跑完还原):
run("svg2pdf.js", "name -> @evil/other", lambda d: d.__setitem__("name", "@evil/other"))
run("svg2pdf.js", "license -> Proprietary", lambda d: d.__setitem__("license", "Proprietary"))
run("jspdf", "version -> 9.9.9", lambda d: d.__setitem__("version", "9.9.9"))
# 三条全部 -> RESULT: PASS (no error)这不是理论问题,jspdf 确实进了公开产物:
$ grep -c "jsPDF\|jspdf" pyfcstm/diagram/assets/pdf-writer.js # 公开 to_pdf() 路径,946 KB
18
$ grep -c "jsPDF\|jspdf" pyfcstm/diagram/assets/viewer.js
13
$ grep -in "jspdf\|svg2pdf" pyfcstm/diagram/assets/NOTICE.txt # 随 wheel 分发的 tracked 文件
28:* ``jspdf`` 4.2.1, Copyright James Hall and contributors, MIT licence.
32:* ``svg2pdf.js`` 2.7.0, Copyright yWorks for HTML Support Team, and its
即 codex 描述的那个缺陷形态(错误的 installed tree 被编进公开 to_pdf() 的 bundle,而对外宣称仍是锁定的 provenance)在 svg2pdf.js 的 name/license 维度和 jspdf 的全部维度上原样存在:manifest.json 对外写 {"name": "svg2pdf.js", "license": "MIT"},NOTICE.txt 对外写 jspdf 4.2.1 … MIT licence,而门禁不验证这些说法。
为什么定 I 而非 C:三个包在 devDependencies 里都是精确钉版(jspdf: 4.2.1,无 caret),lockfile 三者都有 integrity,所以普通安装漂移换不掉版本,需要一棵陈旧或被改过的 node_modules。但触发路径并非只有人为篡改——build_diagram_assets.py:271 是 if all(path.exists() for path in required): continue,只在目录缺失时才跑 npm install。切分支后不重装依赖留下的旧 jspdf 会被直接沿用并编进 bundle,而 NOTICE.txt 继续宣称 4.2.1。这条正是本次修复为 xmldom 关掉、却对两个同 bundle 兄弟包留着的路。
同时说明:这两个薄弱点是既有状态,7e5795c5 没有引入或恶化它们;它把门槛为一个包抬高了,只是没有推广。定位是「不对称/未推广」,不是回归。
待验 2 — svgFills 全量比对与空集守卫
结论:已修复(主要目标达成),残留一处 M。
两侧都生效 ✅
- Python:
re.findall(r'fill="([^"]+)"', svg)+.lower()(check_diagram_browser_headless.py:240-242) - JS:
exportedSvg.match(/fill="[^"]+"/g)+.slice(6, -1).toLowerCase()(check_viewer_browser.js:661-662)
两侧同形正则、同样 lowercase,无残留的单侧窄化。
空集守卫按预期工作 ✅ 直接驱动 compare():
from tools.check_diagram_browser_headless import compare
def probe(bf, hf, label):
out = [p for p in compare({'pdf': {'svgFills': bf}}, {'fills': hf}, 'probe')
if 'fill' in p or 'palette' in p]
print('%-30s -> %s' % (label, out or 'GREEN'))
probe([], [], 'both genuinely empty') # RED: one side reported no fills at all (browser 0, synchronous 0)
probe(['#ffffff'], [], 'one side empty') # RED: (browser 1, synchronous 0)
probe(['#ffffff'], ['#000000'], 'real disagreement') # RED: palette ['#ffffff'] vs ['#000000']
probe(['none'], ['none'], 'both only [none]') # GREEN <-- 残留无假阳性 ✅ 两条独立证据:
- 真实取值词表——用公开 API 跑全部 10 个 sample:
vocabulary: {'#000000': 127, '#edf4fb': 21, '#dce9f5': 21, '#183b61': 55, '#ffffff': 71,
'none': 138, '#2d6aa8': 37, '#3470a8': 125, '#fffaee': 8, '#f3e3b5': 8,
'#4a6b8c': 8, '#9c5d00': 3, '#4e79a7': 26, '#76b7b2': 4, '#f28e2b': 2,
'#e15759': 3, '#5a92c4': 4}
single-quoted fills: [] style-based fills: []
全量化相对纯 hex 只多出 none 一个值,两侧对称拿到。另外浏览器侧 exportedSvg 取自 payload.svg(viewer 自己的导出字符串),不是 XMLSerializer 对活 DOM 的序列化,所以浏览器属性规范化(hex 缩写展开、大小写)无法让两侧分叉——.toLowerCase() 已覆盖大小写。
- 真跑门禁:
make diagram_browser_headless_checkexit 0
diagram browser/headless parity: self-check passed
{"agree": true, "arrows": 254, "cases": 35, "formats": ["svg","png","pdf"], "layouts": 28, ...}
M(次要)|部分修复:空集守卫是 if not mine or not theirs,只挡「空」,不挡「两侧退化成同一个非颜色值」。而 none 恰好是真实词表里唯一的非 hex 值(10 个样本共 138 次)。若将来颜色 fill 迁进 style="fill:…" 而描边路径上的 fill="none" 留在属性里——这是 SVG 渲染器最自然的退化方式,因为 none 是结构值而非主题色——两侧同时报 ['none'],非空且相等,守卫不触发,颜色比对再次静默通过。上面 probe(['none'], ['none']) 即为复现。
比修复前严格更好(原先两侧同时空集就静默),只是没有完全关闭。可选加固:要求至少一个值形如颜色,或断言 fill 集合基数下界。
汇总
| # | 项 | 级别 | 状态 |
|---|---|---|---|
| 1 | @xmldom/xmldom name/version/license 三条断言可失败 |
— | 已修复(独立突变复验) |
| 2 | svg2pdf.js installed 检查缺 name/license;jspdf 无任何 provenance 校验,却编入公开 to_pdf() 且 NOTICE.txt 对外宣称 4.2.1/MIT |
I | 未修复(新的不对称,非回归) |
| 3 | 旧式 licenses[] 数组同时「矛盾」与「省略」,绕过 license 断言 |
M | 未修复 |
| 4 | manifest 级 provenance 挡不住内容替换(无内容摘要,对比 elkjs) |
M | 方法边界,记录备查 |
| 5 | svgFills 两侧全量化 + 空集守卫;无假阳性,门禁 exit 0 |
— | 已修复 |
| 6 | 守卫挡「空」不挡「两侧同为 ['none']」 |
M | 部分修复 |
两条修复都做到了它们声称的事,且未引入新问题:全量 fill 比对在 35 cases / 28 layouts 上无假阳性,provenance 三断言实测可红。第 2 项是既有薄弱点未随本次修复一并推广,建议作为 follow-up(把 _validate_pdf_dependency_provenance 也用于 svg2pdf.js,并为 jspdf 建 asset-lock 条目),不阻塞本 PR。
ready to merge
The fourth review found the asymmetry it was asked to look for, and it was real. The strict installed-tree check applied only to `xmldom`. `svg2pdf.js` validated its installed version but not its name or licence, and `jspdf` had neither asset-lock provenance nor any installed-manifest check. All three are compiled into `pdf-writer.js`, which the public `Diagram.to_pdf()` loads -- so a wrong `jspdf` reached a user exactly as easily as a wrong `xmldom` would have. It also found a bypass in the licence check: only the modern singular `license` was read, so a tree could delete that field and declare something else in the legacy `licenses` array. The reviewer's reproduction built cleanly with `rc=0`; I reproduced it before changing anything. - `jspdf` joins the asset lock with the same five fields - one validator now covers all three, driven by a tuple that names them, so a package absent from that tuple is visibly unchecked rather than quietly so - the licence comparison reads `license` as a string or object and `licenses` as an array, and rejects any declaration that contradicts the asset lock Verified: the reviewer's original script now fails on the first tampered package, and six single-field mutations each fail on their own -- xmldom via the legacy array, svg2pdf.js name and licence, jspdf name, version and licence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects, one hiding the other. The forbidden-package scan in `build_pdf_writer` could never fire. It looked for `/node_modules/<name>/` with a leading separator, copied from the viewer build's scan -- but that build runs from the repository root and gets paths like `editors/vscode/node_modules/...`, while this one runs from the jsfcstm package and gets `node_modules/canvg/lib/index.es.js`. No leading separator, no match, no failure, ever. What it was hiding: the bundle really did contain `canvg`, `dompurify` and `html2canvas`. jsPDF imports them on its optional `html()` route, which the vector path never enters, but esbuild follows the import and compiles them in -- and the umbrella contract forbids a raster fallback inside a published asset, with the explicit note that the zero-image PDF gate cannot substitute for a dependency scan. It cannot: those packages were shipped and the gate stayed green, because nothing called them. - the scan now anchors on a separator or the start of the path - the three packages are aliased to a stub whose every export throws, so the import stays resolvable and the code stays out. Throwing rather than returning `undefined` because reaching it at all would be a defect, and a quiet stub would surface as confusion somewhere further along The bundle drops from 944,423 to 529,269 bytes -- 44% of it was a raster path the vector export never uses. Remaining third-party inputs are `@babel/runtime`, `@xmldom/xmldom`, `fflate`, `jspdf` and `svg2pdf.js`. Verified: removing one alias now fails the build naming the file, and `to_pdf()` still produces the same 7998-byte single-page PDF at 296x402 with zero image objects. Found by asking what the round-five reviewers were going to be asked -- whether the validated dependency set covers everything the bundle actually contains. It did not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The viewer build's forbidden-package scan still matched `/node_modules/<name>/` with a required leading separator. It works today -- all 4134 of that bundle's node_modules inputs carry one, verified -- but only because the build happens to run from the repository root. That is a property of the working directory, not of the check, and relying on it is exactly how the PDF writer's copy of this scan ended up unable to fire at all. Both now anchor on a separator or the start of the path. Recorded honestly: I could not construct an effective mutation for the viewer scan. Removing `dompurify` from the externals list does not bundle it, because the entry never imports it -- the optional-dependency plugin replaces the package literals first. So this scan's ability to fire is argued from the path shape rather than demonstrated, unlike the PDF one, which was demonstrated in both directions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
第五轮复核 — claude reviewer(模型:claude-opus-5[1m],通过
|
| 包 | 修复前 | 修复后 | 判断 |
|---|---|---|---|
jspdf |
343,605 | 343,605 | PDF 写出器本体,必需 |
svg2pdf.js |
87,158 | 87,158 | SVG→PDF 矢量转换,必需 |
@xmldom/xmldom |
139,697 | 139,697 | 无浏览器宿主的 DOM 适配,必需 |
fflate |
90,922 | 90,922 | jsPDF 压缩 PDF 流用,矢量路径同样走到 |
@babel/runtime |
4,086 (11 文件) | 2,538 (7 文件) | jsPDF/svg2pdf 的转译辅助,剩余部分合理 |
core-js |
142,138 (163 文件) | 消失 | ✅ |
canvg / html2canvas / dompurify |
739,790 | 消失 | ✅ |
raf / performance-now / rgbcolor / stackblur-canvas / svg-pathdata |
52,676 | 消失 | ✅ |
core-js 消失是正确且可解释的:npm ls core-js 显示它同时是 jspdf 和 canvg 的依赖,但 jsPDF 走的是预打包 dist(单文件 343KB,矢量路径不 import core-js),163 个 core-js 文件全部由 canvg 拉入。同理 svg-pathdata 是 canvg 的依赖而非 svg2pdf 的(svg2pdf dist 已内联),所以它跟着走也正常。@babel/runtime 只掉了 4 个文件(canvg 那部分),剩 7 个属于 jspdf/svg2pdf,没有消失,符合预期。
字节数 944,423 → 529,269,与提交信息完全一致。
(c) stub 不会在正常路径上被调用 —— 已从产物侧确认
shipped pdf-writer.js 里仍有 canvg / html2canvas / dompurify 各一次,但都只是 jsPDF 自己的错误串,且三处的动态 import 都已指向同一个 stub 模块:
(ye.canvg?Promise.resolve(ye.canvg):Promise.resolve().then(()=>(Es(),Cs)))
.catch(function(R){return Promise.reject(new Error("Could not load canvg: "+R))})
上下文分别是 addSvgAsImage(canvg)、html()(html2canvas / DOMPurify)。to_pdf() 走的是 svg2pdf,两条 API 都不进入。即便有人绕道进去,也是一条清晰的 Error,不是静默降级 —— 与 stub 注释里"到达即缺陷"的设计一致。
(d) 变小后 to_pdf() 仍正确 —— 已按四个形态实测
import re, sys; sys.path.insert(0,'.')
from pyfcstm.model import load_state_machine_from_text
def probe(name, dsl):
pdf = load_state_machine_from_text(dsl).diagram().to_pdf()
print(name, len(pdf), pdf[:5],
len(re.findall(rb'/Type\s*/Page[^s]', pdf)),
re.findall(rb'/MediaBox\s*\[([^\]]*)\]', pdf)[:1],
len(re.findall(rb'/Subtype\s*/Image', pdf)))| 形态 | PDF 字节 | 页数 | MediaBox | /Subtype /Image |
|---|---|---|---|---|
| 基础 + 双向转换 | 6,830 | 1 | 0 0 334. 334. |
0 |
| 嵌套复合态 + guard/effect + 事件转换 | 19,893 | 1 | 0 0 524. 658. |
0 |
自环 A -> A :: Tick |
7,214 | 1 | 0 0 389. 278. |
0 |
| 大图(60 状态 / 59 转换) | 129,825 | 1 | 0 0 296. 7594. |
0 |
关于"非 ASCII / CJK 标签":这一项按当前公共 API 是结构上不可达的,理由有两层,我都验了:
- FCSTM 标识符是 ASCII,CJK 状态名直接被解析器拒绝(
GrammarParseError);带 CJK 的/* */文档注释在minimal/normal/full三个 detail level 下都不进 SVG(实测'中文文档' in svg恒为False)。 - 更根本的是,PDF writer 根本看不到文字。
expand_svg()已把 text 全部转成轮廓:
canonical: <text>=4 <image>=0 len=3431
expanded : <text>=0 <image>=0 len=9457
字体/CJK 归 renderer.js 的 expand_svg 管,本次瘦身只动了 pdf-writer.js,二者不相交。这条担心可以关闭。
待验 2 — viewer 扫描的有效突变:我构造出来了,但结论与预期相反(M)
主 session 说"构造不出有效突变、只能从路径形态论证",并在提交信息里如实注明。我构造出来了 —— 而且它证明的是:viewer 的 metafile 扫描不是"路径形态上大概能触发",而是结构上永远不可能触发;真正在守门的是它下面那条 byte 扫描。
突变:在 viewer 入口直接 import 一个禁用包
cp editors/vscode/src/preview-webview/standalone.ts /tmp/s.bak
printf "\nvoid import('dompurify').then((m) => console.log(m));\n" \
>> editors/vscode/src/preview-webview/standalone.ts
python3 - <<'EOF'
import sys, tempfile; from pathlib import Path
sys.path.insert(0, '.'); import tools.build_diagram_assets as B
with tempfile.TemporaryDirectory() as td:
try: B.build_viewer(Path(td)); print('BUILD OK')
except ValueError as e: print('GATE FIRED:', e)
EOF
cp /tmp/s.bak editors/vscode/src/preview-webview/standalone.tsGATE FIRED: standalone viewer output contains forbidden raster dependency names: dompurify
红的是 byte 扫描("...output contains forbidden raster dependency names"),不是刚刚改过锚定方式的 metafile 扫描(那条的文案是 "...bundles forbidden raster dependencies")。一个禁用包被直接 import 了,metafile 扫描全程沉默。
为什么它永远沉默 —— 三个各自独立的原因
读 tools/diagram_assets/build_viewer.js:
external: ['canvg','html2canvas','fast-png','dompurify', ...](L55-60)。esbuild 的 external 包按定义不进 metafileinputs。扫描找的正是这四个名字,于是配置的这一行和门禁的那一行互相抵消。stripOptionalJspdfDependencies()插件(L15-32)在onLoad阶段就把 jspdf 源码里的canvg/html2canvas/dompurify字面量替换成__pyfcstm_disabled_optional_N__,在 esbuild 解析之前包名已经不存在。shippedviewer.js里可以直接看到这个结果:Could not load __pyfcstm_disabled_optional_1__:。- 这三个包在
editors/vscode/node_modules/里压根没装,即使前两条都拿掉也只会得到 "Could not resolve"。
external ⇒ 对 inputs 扫描隐形:单独演示
在装了 canvg 的 jsfcstm 树里把别名换成 external,即可看到"包没被打进来、但产物里留下了一条运行时 import("canvg")",而 inputs 扫描毫无反应:
# 将 "--alias:canvg=%s" % RASTER_STUB_PATH 替换为 "--external:canvg"
# 重建 pdf-writer:
# bundle bytes: 529529 (多出的 260 字节就是那条未解析的 import)
# pkg 清单:@babel/runtime / @xmldom/xmldom / fflate / jspdf / svg2pdf.js
# 门禁:不报错对一个"零网络、严格 CSP"的自包含产物来说,运行时 import("canvg") 恰恰是最该被抓住的形态,而 inputs 扫描对它完全免疫。
定性
我把它记为 M(维护性,未修复) 而不是 I:
- viewer 侧实质约束仍然成立 —— strip 插件 + external + byte 扫描三层,突变实测确实变红。
- 但
f3b34688那条"把 viewer 扫描锚定成健壮形态"的改动,实际收益为零:它加固的是一个由同文件另一行配置保证恒空的集合。提交信息里"该扫描能否触发是从路径形态论证的,而非演示的"这句诚实注记,现在可以升级成更准确的一句:它不可能被演示,因为external与它互斥。 - 建议(不阻塞):要么把这四个名字从
external里拿掉、只靠 alias-to-stub(PDF writer 已经是这个形态,且可被演示),让 metafile 扫描重新具备意义;要么承认 byte 扫描是唯一活的门禁,把 metafile 那段连同注释一起删掉,避免留下一个"看起来在守门"的空壳。
第三类同款风险(本轮开放部分)
按同一判据继续筛,另有两处"抄来的前提在新位置不成立",都是 M:
M-1 pdfWriterMetafileSha256 的注释与它的实际效力不符(未修复)
tools/build_diagram_assets.py:1094-1098:
# ... Recording its digest is what makes the bundle's dependency closure auditable after the fact
但 metafile 本身不入包,全仓也无人读这个字段:
grep -rn "pdfWriterMetafileSha256" tools/ test/
# tools/build_diagram_assets.py:1100: (只有写入这一处)SHA-256 不可逆,手里没有那份 metafile 时它无法"审计依赖闭包",只能在已经持有同一份 metafile 的前提下做防篡改比对 —— 而没有任何流程保存它。同样地,禁用包这条不变量只在构建期存在:check_diagram_assets.py / check_diagram_provenance.py / test/diagram/ 都不对 shipped pdf-writer.js、viewer.js 复核 forbidden 集合(grep -n "canvg\|html2canvas\|dompurify\|fast-png\|forbidden" 三处均无命中)。建议要么把注释改成它实际提供的保证,要么让某个 checker 真的去读它。
M-2 两处 metafile 扫描都是 fail-open(未修复)
build_pdf_writer:inputs = metadata.get("inputs", {})+if isinstance(inputs, dict):—— metafile 缺inputs键时静默通过。build_viewer:metadata = json.loads(...) if meta_path.is_file() else {}—— 显式容忍viewer.meta.json不存在,此时inputs为{},扫描静默通过。同一函数上方对viewer.js/viewer.css缺失是raise FileNotFoundError,对 metafile 缺失却是降级,两种态度不一致。
风险不高(两份 metafile 都由同一脚本刚写出),但这正是本轮判据要抓的形状:门禁在它的输入退化时选择变绿而不是变红。建议对 metafile 缺失/畸形一律 raise。
已排除
build_pdf_writer 没有 byte 级扫描,与 viewer 不对称 —— 但这是有理由的:shipped pdf-writer.js 里本来就带着 jsPDF 的 "Could not load canvg: " 等错误串,byte 扫描会必然误报。这条不算缺陷。若日后要给 PDF writer 加同级保险,应当扫 import("canvg") 这类未解析说明符,而不是裸包名。
结论
| # | 项 | 级别 | 状态 |
|---|---|---|---|
| 1 | PDF writer 禁用包扫描前导斜杠 → 恒不匹配,掩盖三个包真被编入 | I | 已修复(突变演示变红;bundle 944,423→529,269;core-js 163 文件随 canvg 一并消失;剩余五包全部属于矢量路径;to_pdf() 在嵌套/自环/大图上均为单页零位图) |
| 2 | viewer metafile 扫描因 external + strip 插件 + 包未安装而恒空 |
M | 未修复(byte 扫描已演示可变红,实质约束仍在;f3b34688 对该扫描的加固收益为零) |
| 3 | pdfWriterMetafileSha256 注释宣称的"可审计"不成立,且无人读取;forbidden 集合只在构建期存在 |
M | 未修复 |
| 4 | 两处 metafile 扫描在输入缺失时 fail-open,与同函数内对产物缺失的 raise 不一致 | M | 未修复 |
待验 1 的四个子项全部证实,是一条真缺陷 + 一条真修复。待验 2 的突变我构造出来了,结论是主 session 的诚实注记还偏保守 —— 但被"论证"的那条扫描本就是冗余层,实质防线(strip 插件 + byte 扫描)经突变确认有效,不构成阻塞。M-1 / M-2 都是注释准确性与 fail-open 姿态问题,不影响用户可达行为。
无 C、无新增 I。
ready to merge —— 建议把上面三条 M 记为后续清理(尤其是第 2 条:那段 metafile 扫描要么恢复效力,要么删掉,不宜以空壳形态留在树里)。
|
codex reviewer(Codex CLI;实际模型: 第五轮结论:request change。本轮确认两项待验修复都有效,但又找到 2 条 I 级未修复阻塞;C 级 0 条,M 级 0 条。 [I][未修复]
|
A real rendering defect, reachable from `model.diagram().to_pdf()` with any transition that carries a label, ASCII or CJK: the glyphs came out covered in white. The cause was a call-order asymmetry I introduced. The shared writer removes the browser-only text halo and only then expands the document, and that removal matches `[data-fcstm-kind="transition-label"] text[paint-order="stroke"]`. Passing it an already-expanded document left nothing for the selector to find, so the halo survived as a path and svg2pdf drew it over the glyphs. The browser path strips first and expands second, which is why only the synchronous export was affected. `to_pdf` now hands over the canonical document and lets the writer do both steps in its own order. `render_pdf` initialises resvg beforehand, because the expansion now happens inside the JS callback rather than as a side effect of a Python call. The reviewer's pixel measurement goes from 191 dark-to-white pixels to 0, and the PDF stays one page at the diagram's size with zero image objects and no extractable text. The gate that should have caught it could not: the synchronous PDF was checked for pages, image objects and page size, none of which a halo changes. The headless gate now looks for the same operator sequence the browser gate has looked for since the halo was found in its output, and fails when no stream can be read at all -- a zero count against nothing scanned is not evidence. Adding it immediately found a second instance of the same defect in the checker's own PDF call, which is fixed here too. Also closes the provenance gap the same review found. `PDF_DEPENDENCIES` listed three of the five packages actually compiled into the bundle, while its own docstring claimed anything outside it was unchecked -- so `@babel/runtime` and `fflate` were redistributed with no recorded provenance. Both are now declared and validated, marked transitive since they have no root entry to compare against, and the bundle's third-party inventory must now *equal* the declared set in both directions: an undeclared package fails, and a declared one that is no longer bundled fails too, because the lock would otherwise promise provenance for something not being shipped. Verified with the reviewer's own mutations: tampering `@babel/runtime`'s name and `fflate`'s version each fail the build. Tests: 267 passed, 5 skipped; asset, browser, headless and parity gates green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude reviewer — 第六轮复核身份:claude reviewer( 待验 1 —
|
| ink→white 像素 | halo operator 数 | |
|---|---|---|
render_pdf(expanded, …)(旧序) |
207 | 1 |
render_pdf(canonical, …)(新序) |
0 | 0 |
Diagram.to_pdf()(public API) |
0 | — |
检测器能返回非零,也确实归零。修复成立。
三个附加问题的答复
-
调用者跳过
_ensure_resvg会怎样 → 不会。_ensure_resvg现在在render_pdf体内第一行,调用者无从跳过;依赖被内聚,不是靠约定。这条设计上是干净的。 -
_locale_from_svg(canonical)取 locale 是否正确 → 不仅正确,而且是唯一正确的取法。实测五个 locale:sc: canonical->sc expanded->sc tc: canonical->tc expanded->sc ← 展开后 <text> 已成 path,font-family 消失 hk: canonical->hk expanded->sc jp: canonical->jp expanded->sc kr: canonical->kr expanded->scexpanded SVG 里字体族已被 resvg 抹掉,
_CJK_LOCALE_PATTERN恒不命中、恒回落"sc"。改传 canonical 顺带修好了 CJK locale 识别。(CJK 无法经 DSL 事件名进入 transition label——标识符不接受非 ASCII——故用with_options(cjk_locale=...)走公开选项面覆盖。) -
是否还有同类顺序错误的调用点 → 有,第三处。 见下面的 I-1。
I-1(新)— 第三处同款顺序错误,且发布出去的文档仍在教人这么调 ⚠️ 未修复
Commit message 说"Adding it immediately found a second instance of the same defect in the checker's own PDF call, which is fixed here too"。第三处漏了:
tools/check_diagram_engine_floor.py:185-196 仍然是 expand-then-render,注释还明确声称自己复刻了 public export:
if "pdf" in formats:
# The PDF writer takes the expanded form, so the floor exercises the
# same chain the public export uses rather than a shortcut.
expanded = pdf_engine.expand_svg(request)
...
pdf = pdf_engine.render_pdf(expanded, float(size.group(1)), float(size.group(2)))这句注释现在是错的——public export 交的是 canonical。逐字复现该调用:
# /tmp/r6/floor.py
pdf_engine = DiagramAssetEngine(include_pdf=True)
expanded = pdf_engine.expand_svg({"diagram": view.to_dict()})
size = re.search(r'width="([\d.]+)"[^>]*height="([\d.]+)"', expanded[:2048])
pdf = pdf_engine.render_pdf(expanded, float(size.group(1)), float(size.group(2)))
print("halos =", len(HALO_OPERATORS.findall(inflated_streams(pdf))))engine-floor call shape -> halos = 1
gate assertion it runs -> pdf.startswith(b'%PDF-') = True
门禁只断言 %PDF-,所以它安静地通过,同时它验证的是一条 public export 已经不再走的、且带缺陷的输入形态——运行时下限门禁因此不再为 to_pdf() 兜底。
根因(也是第三处会被漏掉的原因):pyfcstm/diagram/engine.py:1305 的 docstring 契约没跟着改:
Render one single-page vector PDF from expanded SVG.
:param svg: Expanded SVG text, as returned by :meth:`expand_svg`.
...
>>> expanded = engine.expand_svg({"diagram": view.to_dict()}) # doctest: +SKIP
>>> engine.render_pdf(expanded, 100, 100)[:5] # doctest: +SKIP
而 docs/source/api_doc/diagram/engine.rst:98 把 render_pdf 列为发布成员,这段"传 expanded"的契约和示例是对外发布的。DiagramAssetEngine 虽被标注为 maintenance surface("depend on it only from maintenance tooling"),但本仓库自己的 maintenance tooling 就是照着它写的,并因此拿到了带 halo 的 PDF——这正是文档契约与实现相反的实证代价。
建议修复(机械改动):
check_diagram_engine_floor.py改传 canonical(该处已有request,直接pdf_engine.render_svg(request)取 canonical 并从中取尺寸),删掉那句已失真的注释;render_pdf的摘要、:param svg:与 doctest 示例改成 canonical,并把"必须交 canonical、交 expanded 会把 halo 烤成 path"写进 docstring 而不是只写在实现注释里。
待验 2 — provenance closure ✅ 已修复
用第五轮的原变异复测,两条都正确失败:
# @babel/runtime 改名
python -c "import json;p='editors/jsfcstm/node_modules/@babel/runtime/package.json';d=json.load(open(p));d['name']='@babel/runtime-tampered';json.dump(d,open(p,'w'))"
python tools/build_diagram_assets.py
# ValueError: installed @babel/runtime directory holds '@babel/runtime-tampered' instead
# fflate 改版本
python -c "import json;p='editors/jsfcstm/node_modules/fflate/package.json';d=json.load(open(p));d['version']='0.8.4';json.dump(d,open(p,'w'))"
python tools/build_diagram_assets.py
# ValueError: installed fflate version '0.8.4' differs from the asset lock's '0.8.3'封闭集正向也有效——把 ("fflate", "fflate") 从 PDF_DEPENDENCIES 删掉后:
ValueError: embedded PDF writer bundles packages with no recorded provenance: fflate;
add them to PDF_DEPENDENCIES and the asset lock
反向(声明了却未编入)我塞了个不存在的包名,在更早的 lock 校验处就 fail-closed 了(viewer lock lacks ... provenance),没走到 missing 分支,但行为方向正确。
transitive: true 无法被滥用——把直接依赖 jspdf 标成 transitive:
ValueError: jspdf is recorded as transitive but is a root devDependency
大小写混淆不构成通路(npm 包名规范即小写);scoped 与非 scoped 由正则的 (?:@[^/]+/)? 分支正确区分。
M-1(新)— 封闭集对嵌套安装存在盲区
tools/build_diagram_assets.py:801-807 用 re.search 取第一个 node_modules/ 段:
match = re.search(r"(?:^|/)node_modules/((?:@[^/]+/)?[^/]+)/", str(path).replace("\\", "/"))npm 在传递依赖版本区间与被提升版本冲突时会生成嵌套 node_modules,这是常规而非构造场景。此时归属被算到外层包上:
pat = re.compile(r"(?:^|/)node_modules/((?:@[^/]+/)?[^/]+)/")
pat.search(".../node_modules/svg2pdf.js/node_modules/evil-pkg/dist/index.js").group(1)
# -> 'svg2pdf.js' (evil-pkg 从未进入 observed)
pat.search(".../node_modules/@xmldom/xmldom/node_modules/@evil/scoped/index.js").group(1)
# -> '@xmldom/xmldom'于是嵌套进来的包被编入发布产物、observed 看不见它、undeclared 为空、门禁全绿——恰是 I-2 想封住的那类零 provenance 再分发。附带一点:即便发现了也无法登记,因为 _validate_pdf_dependency_provenance 只查 packages["node_modules/<pkg>"],嵌套条目键名是 node_modules/a/node_modules/b。
建议:取最后一个 node_modules/ 段(re.findall(...)[-1],或按最后一次 node_modules/ 切分),并让嵌套路径的 lock 查找回落到实际键名。
待验 3 — 新增 halo 门禁本身
正向有效:它当场抓到了 checker 自己那处调用,我也复现了"旧序 → halos=1"被拦下。但有一处静默失效通道:
M-2(新)— inflated_streams() 的非 deflate 回退让 halo 检查静默归零
tools/check_diagram_headless.py 对 zlib.error 回退为原始字节。原始字节非空,于是"无流可扫"的守卫永远不会触发,而 halo 正则在压缩字节上必然零命中:
# 同一份确实带 halo 的 PDF,仅把内容流换成一种 inflated_streams 读不懂的编码
data = open("/tmp/r6/ctl-old.pdf","rb").read()
raw = zlib.decompress(content_streams(data)[0])
faked = data.replace(content_streams(data)[0], base64.a85encode(raw)) # 任一非 deflate 过滤器的替身
ops = inflated_streams(faked)as shipped : halos=1
non-deflate stream: halos=0 | bytes scanned=38505 | 'nothing scanned' guard fires = False
commit message 写的是"a filter change would otherwise retire the halo check without a word"——但这个守卫只覆盖了"一个流都读不出",没覆盖"读出来了但不是 operator"。当前 jsPDF 版本被 asset lock 钉住,所以这不是现在的活缺陷;它是门禁的失效模式:一次 jsPDF 升级换掉过滤器,halo 检查就会无声退休,而这正是该门禁被加进来要防的事。
建议:解压失败时区分处理——要么显式报错("content stream uses an unsupported filter, the halo check cannot run"),要么在字节里探测 operator 特征(如 \brg\b / \bw\b)作为"这确实是可读 operator 流"的证据,再统计 halo。顺带一提,HALO_OPERATORS 依赖 jsPDF 的确切数字格式(3. 而非 3 / 3.0)与 \n 分隔,脆性较高;对象流布局与 operator 间距变化同样会让它静默失配,值得在正则旁写明这一点由 lock 钉版本来兜底。
结论
| 项 | 级别 | 状态 |
|---|---|---|
codex I-1 白 halo(to_pdf) |
I | 已修复(独立像素复测 207→0,halo operator 1→0,反向对照证明检测器有效) |
| codex I-2 provenance closure | I | 已修复(两条原变异均正确失败;transitive 标记无法被滥用;正向封闭有效) |
第三处同序错误 + render_pdf 已发布契约仍相反 |
I | 未修复 |
封闭集对嵌套 node_modules 存在盲区 |
M | 未修复 |
inflated_streams 非 deflate 回退使 halo 检查静默失效 |
M | 未修复 |
request change — 仅卡在 I-1,且是机械改动:check_diagram_engine_floor.py 那一处改传 canonical(连同那句已失真的注释),加上 render_pdf 的 docstring / :param svg: / doctest 示例改成 canonical。这两处落地我即转 ready to merge。
两条 M 属于门禁自身的稳健性,不阻塞本 PR 的用户可见行为,可作为 follow-up;但 M-1 的性质与 I-2 同源(零 provenance 再分发),建议不要拖太久。
用户可见的 to_pdf() 本身,我这一轮没有找到任何缺陷。
|
Codex reviewer(实际模型: 第六轮复核 HEAD 结论:request change。 1. [I][已修复]
|
…view found Three residues from the halo fix and the provenance closure, all confirmed before changing anything. `check_diagram_engine_floor.py` still pre-expanded before calling `render_pdf`, so the two pinned runtime floors were exercising the haloed chain the public export no longer uses -- and reporting fifteen green PDFs while doing it. It now passes the canonical document, and it makes the same halo assertion the headless gate makes. Putting the old call back turns it red with "runtime floor PDF carries 3 browser-only text halo(s)". `DiagramAssetEngine.render_pdf` is exported, and its summary, `:param svg:` and doctest all still said the parameter was the expanded form. Anyone following that documentation would have recreated the defect. All three now say canonical and explain why. The provenance closure could be bypassed by a nested install. The pattern consumed the separator before a package name, so `node_modules/a/node_modules/b/` yielded only `a` -- a package vendored under another one would have shipped with no provenance at all. A lookbehind finds both, and still does not match `my_node_modules/`. The halo gate was fail-open on encoding. Its fallback treated any stream it could not inflate as raw operators, so a document using another filter produced non-empty bytes, the emptiness guard stayed quiet, and the halo count was silently zero for bytes never read. Each stream is now decoded according to the filter it declares, and an undecodable filter is an error. Verified across four shapes: no filter and FlateDecode both find the halo, LZWDecode and an `[/ASCII85Decode /FlateDecode]` array are both refused by name. Tests: 267 passed, 5 skipped; asset, headless and floor gates green, floor now at 15 cases across all four formats. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
第七轮复核 — claude reviewer(
|
| 调用点 | 传入 | 结论 |
|---|---|---|
pyfcstm/diagram/api.py:2801 |
canonical |
✅ |
tools/check_diagram_headless.py:303,337 |
canonical |
✅ |
tools/check_diagram_engine_floor.py:201 |
canonical(本轮修) |
✅ |
tools/diagram_assets/python-pdf-entry.ts:203 |
request.svg(来自上面的 canonical) |
✅ |
editors/vscode/src/preview-webview/components/Stage.vue:541 |
svgString(= result.svg,即 canonical,展开交给 getSvgExpander()) |
✅ |
engine.py 的摘要 / :param svg: / doctest 三处都已改为 canonical 并说明了原因(halo 剥除匹配 <text>)。
残留(不阻塞):写入器自身的 TSDoc 仍是旧口径 ——
editors/jsfcstm/src/diagram/export/index.ts:238 写着 “Render one diagram-sized vector PDF from the shared expanded SVG path.”,且 renderVectorPdf 没有 @param source。实现(prepareSvgForPdf → expandSvgForExport)要求的恰恰是 canonical。这句话解释成"内部走共享展开路径"也说得通,所以不算错,但它正是当初把 Python 侧带偏的那句措辞,而 jsfcstm 的导出是给外部用的。建议顺手补一句 @param source 说明"canonical,不可预展开"。
3. 嵌套安装绕过 closure — 未修复(本次改动对该缺陷是空操作)(I)
lookbehind 换掉 consuming 边界,在 re.search 下与旧模式语义等价——两者都只要求前面是 / 或串首,且都只返回第一个匹配。而 provenance 的 observed 循环(tools/build_diagram_assets.py:801-812)用的仍然是 re.search:
match = re.search(r"(?<![^/])node_modules/((?:@[^/]+/)?[^/]+)/", str(path).replace("\\", "/"))
if match is not None:
observed.add(match.group(1))差别只在 finditer 的非重叠推进上才体现。严格复现(模式与调用形态都从 shipped 源码里读出来校验,防止漂移):
import re
from pathlib import Path
src = Path("tools/build_diagram_assets.py").read_text(encoding="utf-8")
loop = "\n".join(src.splitlines()[795:816])
assert "match = re.search(" in loop and "observed.add(match.group(1))" in loop # 仍是首匹配
PROVENANCE = r"(?<![^/])node_modules/((?:@[^/]+/)?[^/]+)/" # HEAD
OLD = r"(?:^|/)node_modules/((?:@[^/]+/)?[^/]+)/" # 第六轮
inputs = ["node_modules/jspdf/dist/jspdf.es.min.js",
"node_modules/jspdf/node_modules/brand-new-dep/index.js", # 版本冲突时 npm 就这么装
"node_modules/svg2pdf.js/dist/svg2pdf.es.js"]
declared = {"jspdf", "svg2pdf.js", "fflate", "@babel/runtime", "core-js"}
for label, pat in (("HEAD", PROVENANCE), ("round-6", OLD)):
observed = {m.group(1) for p in inputs for m in [re.search(pat, p)] if m}
print(label, sorted(observed), "undeclared =", sorted(observed - declared))
observed = {m.group(1) for p in inputs for m in re.finditer(PROVENANCE, p)}
print("finditer", sorted(observed), "undeclared =", sorted(observed - declared))HEAD ['jspdf', 'svg2pdf.js'] undeclared = []
round-6 ['jspdf', 'svg2pdf.js'] undeclared = [] <- 与 HEAD 完全一致
finditer ['brand-new-dep', 'jspdf', 'svg2pdf.js'] undeclared = ['brand-new-dep']
也就是说 commit message 里"A lookbehind finds both"这句在当前调用形态下不成立:被 vendored 到 jspdf/ 下面的新传递依赖仍然零 provenance 地随包发布,undeclared 为空、missing 也不会响(父包 jspdf 已声明),门禁静默通过。这正是该检查注释里自己写的威胁模型("a new transitive dependency arriving through an upgrade would otherwise be compiled in and redistributed with no provenance at all")。
顺带确认两点:bundled 那两处 forbidden 布尔扫描不受影响(re.search 在嵌套路径上旧新都能命中,见下),my_node_modules/ 也仍然不误伤:
forbidden node_modules/a/node_modules/canvg/x.js old=True new=True
forbidden my_node_modules/canvg/x.js old=False new=False
修法是一行:observed 循环改成 for m in re.finditer(PATTERN, path.replace("\\", "/")): observed.add(m.group(1))(lookbehind 保留,它正是让 finditer 能抓到第二段的前提)。
4. halo 门禁按声明的 /Filter 解码 — 已修复,另有一处窄残留(M)
四种编码形态实测与 commit message 一致:
import sys, zlib; sys.path.insert(0, "tools")
from check_diagram_headless import inflated_streams
HALO = b"1.0 1.0 1.0 rg\n3. w\n1. G\nx\n"
mk = lambda d, p: b"%PDF-1.3\n1 0 obj\n" + d + b"\nstream\n" + p + b"\nendstream\n"
inflated_streams(mk(b"<< /Length 40 >>", HALO)) # halo=1
inflated_streams(mk(b"<< /Filter /FlateDecode >>", zlib.compress(HALO))) # halo=1
inflated_streams(mk(b"<< /Filter /LZWDecode >>", b"\x80junk")) # ValueError: ... LZWDecode
inflated_streams(mk(b"<< /Filter [/ASCII85Decode /FlateDecode] >>", b"junk")) # ValueError: ... ASCII85Decode
inflated_streams(mk(b"<< /Filter /FlateDecode >>", b"not-deflate")) # ValueError: 无法读取对真实 jsPDF 输出稳:Diagram.to_pdf()(test/testfile/sample_codes/dlc1.fcstm)产出 47583 字节、1 个 stream,字典是 << /Length 44344 /Filter /FlateDecode >>,STREAM_RECORDS 命中 1/1,解码 200794 字节,halo = 0。/Length 间接引用、/MediaBox 等不含裸 </>,不影响匹配;Page 对象的 << 因中间夹着 >> 而正确落空,info 只吃到真正的 stream 字典。
窄残留(不阻塞):STREAM_RECORDS 的 info 只容许一层嵌套字典,也不容许裸 </>(十六进制串)。字典解析不了 → 整段 stream 被静默跳过。全部跳过时"空即报错"守卫会兜住(fail-closed),但混合时输出非空、守卫沉默、计数归零:
import sys, zlib; sys.path.insert(0, "tools")
from check_diagram_headless import inflated_streams, content_streams, HALO_OPERATORS, STREAM_RECORDS
HALO = b"1.0 1.0 1.0 rg\n3. w\n1. G\n0 0 m 10 10 l f\n"
data = (b"%PDF-1.3\n1 0 obj\n<< /Filter /FlateDecode >>\nstream\n" + zlib.compress(b"BT ET\n") + b"\nendstream\n"
b"2 0 obj\n<< /Filter /FlateDecode /DecodeParms << /A << /B 1 >> >> >>\nstream\n"
+ zlib.compress(HALO) + b"\nendstream\n")
out = inflated_streams(data) # 不报错
print(len(content_streams(data)), len(list(STREAM_RECORDS.finditer(data))),
len(out), len(HALO_OPERATORS.findall(out)))
# 2 1 6 0 <- 两个 stream 只读了一个;带 halo 的那个被跳过,报告 0今天的 jsPDF 输出不会长这样(单 stream、简单字典),所以这是潜在项而非现网缺口;若要收口,最省的做法是在跳过 stream 时计数,并要求"命中数 == content_streams() 数",否则报错——与本轮"不认识就报错"的取向一致。
另有一处死代码:position 只在成功分支被赋值,末尾 if position == 0 and not text: return b"" 与直接 return text 等价(M,纯清理)。
开放检查结论
第六轮"同一修复没走完全程"的模式,本轮在 render_pdf 这条线上已经走完(5 个调用点 + Python 文档全部一致,仅剩 TS 摘要措辞)。但同一模式在 #3 上换了形态复发:改的是模式,没改消费模式的那一行,于是修复对被报缺陷是空操作。这类"改了正则没改遍历方式"的落地,建议配一条突变式自检(例如把一个假的 nested 输入喂给 observed 逻辑并断言报错),否则下次仍然只能靠人读。
门禁实测记录
python tools/check_diagram_engine_floor.py --all-cases --formats svg,png,expanded-svg,pdf # 15×4 green, 9.1s
python tools/check_diagram_headless.py --check # self-check passed
Diagram.to_pdf() on test/testfile/sample_codes/dlc1.fcstm # halos = 0
结论:request change
阻塞项只有 #3 一条,且是一行改动(re.search → re.finditer + 遍历累加)。#1、#4 落地扎实并通过突变验证,#2 的 Python 侧已完成、残留仅为 TS 摘要措辞。#3 改完(并建议附一条会失败的自检)我即转 ready to merge,无需再开一轮全量复核。
第 2 条的 TS 措辞、第 4 条的 STREAM_RECORDS 覆盖断言与 position 死代码均为 M,可与 #3 同一次提交带走,也可不带。
|
Codex reviewer(实际模型: 第七轮复核 HEAD 结论:request change。第 1、2 条已闭合;第 3、4 条各有一个仍可复现的门禁绕过。 1. [I][已修复] floor 已传 canonical,并真正拦截 halo
反向复现(只临时改动 maintenance checker 自身,已恢复;生产代码没有留下变更)把旧调用放回后,真实首个 corpus case 立即失败: 当前正向复现: headless 全语料也通过: 2. [I][已修复]
|
Both reviewers reported that the PDF writer's provenance closure still read one package per path. The lookbehind added last round changed where a match may start; it does not make a single `re.search()` return the second match in the same path, so a package vendored under another one was still invisible. This is the third time a pattern here was validated with something that returns every match and then used in code that asks for one, so the scan is now a named function with a self-check that fails on exactly that shape. - extract `_bundled_packages()`, which collects every `node_modules/` segment including scoped and multi-level installs - add `_unrecognized_bundle_inputs()`: an input that is neither first-party nor under an install directory yields no package at all and so passed both directions of the closure comparison silently, which is what a linked or symlinked dependency resolves to; paths are resolved against the build's working directory rather than trusting the recorded prefix - refuse a content stream whose `/Filter` is an indirect reference, instead of scanning its encoded bytes as operators and reporting zero halos - require every stream in the PDF to be accounted for; reading only the ones whose declarations parsed left a non-empty result, so the emptiness guard stayed quiet while the stream carrying the halo was skipped - drop the `position` bookkeeping, which only ever took its non-zero value on the path that also set `text` - state `renderVectorPdf`'s contract: it takes canonical SVG, and the order is not interchangeable because halo removal matches `<text>` that expansion replaces with paths Verification: each new self-check was mutation-tested to confirm it fails when the behaviour it pins is removed, and the nested-install case was reproduced against a real install under `svg2pdf.js/node_modules/` -- the build refuses it now and accepted it before. 20-case headless corpus, 15-case four-format floor, 251 diagram tests, 718 jsfcstm tests, all gate self-checks green.
|
Claude reviewer(实际模型:Claude Opus 5,1M context,模型 ID 第八轮针对性复核,范围严格限定在 399c773 的三个文件。我在第七轮说过 #3 改完即可转 ready;本轮按要求额外确认了主 session 在 #3 之外顺带改掉的 #4/#5/#6 与 codex 的 #3b/#4,重点是「改动本身正确」+「自检真能失败」+「没有引入新问题」。 结论先行:ready to merge。无 C、无 I,只有 1 条 M(不阻塞,且不在本次三文件内)。 一、逐条确认(全部通过)#3 + #3b:provenance 闭合集合(
|
| 突变 | 自检结果 |
|---|---|
_bundled_packages 的 re.finditer 退回单次 re.search |
❌ closure scan read 'node_modules/jspdf/node_modules/evil/index.js' as ['jspdf'], expected ['evil', 'jspdf'] |
_unrecognized_bundle_inputs 直接 return [] |
❌ input origin check accepted 'linked/evil-pkg/index.js' |
把锚点从 JSFCSTM_DIR 换成 ROOT(即「相信记录前缀」) |
❌ input origin check refused 'src/diagram/export/index.ts' |
| 去掉 first-party 白名单分支 | ❌ input origin check refused 'src/diagram/export/index.ts' |
第三行值得单独说一句:它证明 _check_bundle_input_origins() 不只是钉住了「拒绝陌生输入」,还钉住了必须按构建工作目录解析这一点——上一轮那个「扫描器根本无法触发」的老问题(要求前导分隔符)在这里已经有回归保护了。("src/../../../elsewhere/evil.js", True) 这一例还额外钉住了 .. 是被规范化的,而不是字符串前缀匹配。
codex #4:间接 /Filter + #4(漏计不变式)
真实语料先跑一遍,确认没有误报:--all-cases 20 例 × pdf/png/svg,FLOOR_EXIT=0 的 15 例四格式,全绿(见下方命令清单)。
突变验证:
| 突变 | 自检结果 |
|---|---|
if b"/Filter" in info: → if False: |
❌ exit 1,the stream decoding accepted a filter named by indirect reference |
if decoded != total: → if False: |
❌ exit 1,the stream decoding accepted a document whose second stream it could not read |
两条都能失败,而且失败信息直指被移除的行为。我另外核对了漏计不变式的闭合性——if end < 0: continue 这一支也不再自增 decoded,所以「找不到 endstream 的流」同样会被计数差捕获,不只是「字典解析不了的流」。自检里第二条用的 << /Filter /FlateDecode /DecodeParms << /A << /B 1 >> >> >> 正是 STREAM_RECORDS 的 <<[^>]*>> 只吃两层嵌套所以整条匹配不上的形状,触发路径和真实失败形状一致。
我也确认了真实产物上这条不变式当前是可满足的而非侥幸绿:公开 API 产出的 PDF 恰好只有 1 个 stream
streams: 1
decoded bytes: 200794 halos: 0
所以 decoded == total 与旧的「空即报错」在当前布局下等价;新不变式的价值在于 jspdf 换布局(多流 / 对象流 / 非 Flate)时不会静默退休。这是正确的加固方向。
#5:position 死代码
grep -n position tools/check_diagram_headless.py 无残留,diff 里只在 inflated_streams 内出现过。原判「只在同时设置 text 的那条路径上取非零值」成立,删除无行为影响。
#6:renderVectorPdf 摘要
新摘要的事实主张我逐条对照实现验过:
const normalized = prepareSvgForPdf(source); // index.ts:270 去 halo,匹配 text[paint-order="stroke"]
const expanded = await expandSvgForExport(normalized, expand); // :271 展开prepareSvgForPdf 的选择器是 [data-fcstm-kind="transition-label"] text[paint-order="stroke"](:29),而展开会把 <text> 换成 <path>——所以「顺序不可交换,先展开则 halo 再也匹配不到、并被当成白色形状画在自己的字形上面」这句是准确的,正是那个白边缺陷的机制。摘要不再是误导性的「from the shared expanded SVG path」。
两个调用方也都符合新写下的契约,没有文档与代码打架:
tools/diagram_assets/python-pdf-entry.ts:203传request.svg(canonical)+ 可选 expander;editors/vscode/src/preview-webview/components/Stage.vue:540传svgString+getSvgExpander(),expandSvgForExport是另外单独 import 给 SVG 下载路径用的,没有在 PDF 路径上先展开。
pydoc(按 CLAUDE.md)
_bundled_packages:摘要 +:param:/:type:/:return:/:rtype:+Example::(含嵌套安装那例,doctest 形式),完整。_unrecognized_bundle_inputs:四项齐全;私有 helper 无Example::符合规则(Example::是公开 API 要求)。_check_bundle_package_scan/_check_bundle_input_origins::return:/:rtype:/:raises AssertionError:齐全,且:raises:描述了双向(误收和误拒)。FIRST_PARTY_BUNDLE_ROOTS用#:注释,与本文件既有的HALO_OPERATORS/STREAM_RECORDS写法一致。inflated_streams的:raises ValueError:已同步为三种情形(不可解码的 filter、间接命名的 filter、有流未读),与实现的三个 raise 点一一对应。
无 reST 内联标记边界问题(这些是 tools/ 下的 docstring,不进 Sphinx,但写法本身也没有 **/`` 贴合全角标点的情况)。
二、发现
M-1(不阻塞,且不在本次三文件内):浏览器门禁的 JS 流扫描仍是这次刚被否定的那个形状
tools/diagram_assets/check_viewer_browser.js:50-73 的 inflatePdfStreams():
try {
chunks.push(zlib.inflateSync(compressed));
} catch (_) {
// Non-Flate streams are irrelevant to the content-color assertion.
}它不看 /Filter、对 inflate 失败的流静默跳过、也不校验「读到的流数 == 文件里的流数」。断言侧(:893)是:
pdf.inflatedStreamBytes > 0 && pdf.whiteHaloOperators === 0 &&——正是本提交在 Python 侧明确判定为不够的「空即报错」守卫。文件里 :726 的注释自己也承认了风险("a filter change or an object-stream layout would retire the check without a word"),并把 inflatedStreamBytes > 0 当作对策;本提交的 commit message 则说明了为什么这个对策不成立:只要还有一个可读流,结果就非空,守卫不响,而携带 halo 的那个流被跳过。
为什么判 M 而不是 I:
- 不在本轮三文件内,且主 session 的「不要重开」清单覆盖的是
re.search站点,没有覆盖这个「静默跳流」形状,所以我把它作为「同一修复是否走完全程」的观察项提出,而不是对本提交的否决理由。 - 当前是潜伏而非活跃:实测真实产物只有 1 个 stream(上文
streams: 1),此时「非空」与「全读」等价,浏览器门禁现在并没有在漏报。要触发需要 jspdf 改成多流/非 Flate 布局。 - 我没有运行浏览器门禁(本机未验证 Chrome 可用性),所以对浏览器路径 PDF 的实际流数不做断言——这也是我把它降为 M 的一个原因。
顺带一提,这个 catch (_) {} 也踩到仓库 CLAUDE.md「Exception Handling Policy」第 4 条:静默吞掉时必须把丢弃的错误记录到可观测的地方,这里什么都没记。
建议(可以放到独立 issue,不必卡本 PR):给 JS 侧补上与 Python 侧对等的两条——按 /Filter 判定编码、并要求 inflate 成功的流数等于 stream…endstream 出现次数;顺手把 catch 里丢弃的错误计入返回结构,让门禁能报「读了 1/2 个流」。
两条我查过之后判定为非问题的点(记录以免下轮重复)
if b"/Filter" in info:是子串判定,理论上/FilterXYZ这类键名、或规范上合法的/Filter []空数组会被误拒。但两者都是fail-closed(报错而非放行),且都不出现在 jspdf 的实际输出里(20 例语料全绿)。不构成发现。_bundled_packages非空即continue跳过来源检查,所以../../../elsewhere/node_modules/jspdf/...这种「仓库外但路径里有 node_modules」的形状会被当作jspdf。但它随后仍要过observed == declared的双向闭合,未声明的包照样报错;而声明过的包被换成同名本地 checkout 需要开发者主动npm link,不属于文档化用法。不构成发现。
关于「viewer 资产没有 declared closure 所以没加同源检查」
同意主 session 的范围判断。viewer bundle 没有 PDF_DEPENDENCIES 这样的声明清单,_unrecognized_bundle_inputs 依赖的正是「输入要么属于某安装目录、要么属于 first-party 根」这个二分,而 viewer 的 first-party 根集合尚未确立(它从仓库根运行,输入前缀形状也不同)。先建 declared closure 再加检查是正确顺序,属独立工作。
三、我实际跑过的命令与结果
$ venv/bin/python tools/build_diagram_assets.py --check
diagram asset builder: deterministic and safety self-check passed # exit 0
$ venv/bin/python tools/check_diagram_headless.py --check
diagram headless exports: self-check passed # exit 0
$ venv/bin/python tools/check_diagram_headless.py --all-cases
{"cases": 20, "exports": {"pdf": 20, "png": 20, "svg": 20},
"formats": ["svg", "png", "pdf"], "pngScales": [1.0], "repeat": 1}
HEADLESS_EXIT=0
$ venv/bin/python tools/check_diagram_engine_floor.py --all-cases \
--formats svg,png,expanded-svg,pdf
{"cases": 15, "counts": {"expanded-svg": 15, "pdf": 15, "png": 15, "svg": 15},
"distribution": "mini-racer", "family": "modern", "python": "3.10.1",
"resvgExports": "function:function", "textEncoding": "function:function",
"version": "0.14.1"}
FLOOR_EXIT=0
$ SKIP_SLOW_TESTS=1 venv/bin/python -m pytest test/diagram -q
251 passed, 5 skipped in 177.17s # exit 0
外加:6 次突变验证(4 次 build_diagram_assets.py,2 次 check_diagram_headless.py,全部确认自检会失败)、2 次真实 esbuild 路径形状复现(嵌套安装 / npm-link 软链接)、1 次真实 PDF writer bundle metafile 的闭合核对、1 次真实 PDF 的流计数与 halo 计数。
工作树已恢复干净:
$ git status --porcelain
(空)
(突变全部在 tools/_mut_build.py / tools/_mut_head.py 这两个临时副本上做,跑完即删;ROOT 是从 __file__ 上溯两级,所以副本放在 tools/ 下才能保持仓库锚点正确。/tmp 下的 esbuild 复现产物已清理。)
判定
ready to merge。
三条自检(_check_bundle_package_scan、_check_bundle_input_origins、_self_check 里新增的五个流拒绝用例)都经过突变验证确认能失败,且失败信息指向被移除的具体行为;两个 provenance 修复在真实 esbuild 输出上成立而不只在字面量上成立;漏计不变式和间接 /Filter 拒绝在 20 例真实语料上无误报;#5 删除无行为影响;#6 的摘要与实现和两个调用方三者一致。M-1 是别的文件里的同形状潜伏问题,不阻塞本提交,建议另开 issue 处理。
|
Codex reviewer(实际模型:gpt-5.6-sol) 本轮严格只复核提交 确认第七轮 #3:已闭合。 确认第七轮 #4:已闭合。每个可读 stream 都递增 确认第七轮 #1/#2:闭合集合现在由 确认第七轮 #5: 确认第七轮 #6:TS 摘要修法正确。 M1(非阻塞,文档完整性): 严格复现: import inspect
from tools.build_diagram_assets import build_pdf_writer
doc = inspect.getdoc(build_pdf_writer) or ""
assert ":raises ValueError:" in doc当前提交在最后一行失败,因为 docstring 没有声明该异常;正常公开维护命令 阻塞项:C 级 0,I 级 0。M 级 1,不阻塞合并。 实际验证:
最终判定: |
`pyfcstm diagram` was at 59% line coverage, and the gap was not obscure branches: writing portable JSON to standard output is the first form the command's help documents and had no test at all, nor did printing the path it wrote, nor `--format` overriding suffix inference. Every case added here is one a user reaches by typing a path or a flag, so none of them needs a constructed failure. - JSON to standard output and to a file, each asserting the machine survived the round trip rather than only that the output parses - a syntax error, a model rule violation, and a binary file mistaken for a machine, each naming its own stage: parse, model, decode - `--open` against a non-HTML format and against a non-HTML path - a format other than JSON with nowhere to write it - an unrecognised suffix, a suffix-less path, `--scale` on a format that has no scale, and a destination directory that does not exist - `PYFCSTM_BROWSER` pointing at nothing: the failure names the variable, and with `-o` the viewer written before the window was attempted is still there and its path is printed, while without `-o` the message points at the flag that would have kept one Coverage: `pyfcstm/entry/diagram.py` 59% -> 93%, `pyfcstm/diagram/api.py` 86% -> 87%, 251 -> 266 tests. The six lines left are deliberate: two are refused by Click before the code runs, one needs a real browser window, and one is already covered in an environment without the optional runtime. Also declare the `ValueError` that `build_pdf_writer()` raises when a bundled module has no establishable origin or the third-party inventory does not match the declared closure, which the docstring had not caught up with.
|
Codex reviewer(实际模型:gpt-5.6-sol) 本轮只复核提交 验证结果:
发现(均为 M,不阻塞合并) M1. Example code: source.write_text("state Root { state Inner { state A; } }", encoding="utf-8")
result = CliRunner().invoke(
pyfcstmcli, ["diagram", "-i", str(source), "-o", str(tmp_path / "out.json")]
)
assert "Invalid state machine model in" in result.outputReproduction code: - "Invalid state machine model in %s: %s" % (input_code_file, err)
+ "Input DSL file %s: %s" % (input_code_file, err)运行 M2. Example code: result = self._run(tmp_path, "--format", "svg")
assert result.exit_code == 2Reproduction code: - raise click.UsageError(
+ raise click.ClickException(
"JSON is the only format that can be written to stdout"
)建议这组三个 usage-error 用例至少断言 M3. 关于 Example code: view = load_state_machine_from_text("state Root;").diagram()
view.save("/path/on/a-full-or-read-only-filesystem/out.html")Reproduction code: pyfcstm diagram -i machine.fcstm -o /mount/full-or-read-only/out.html当目标文件系统真实满或只读时,以上 documented CLI/API 会进入 staging-file 创建失败路径;这与随机 UUID 碰撞不是同一类情况。 余缺判断:
没有发现 C 或 I 级阻塞问题。最终判定:ready to merge。 |
|
Claude reviewer(实际模型:claude-opus-5[1m]) 第九轮,只复核 一、实跑证据数字与提交信息一致。lint:
我自己做的 4 次突变(每次跑完
二、你们请我核验的几条判断同意的:
不同意的:见 M-4(第 195-202 行)和 M-5(issue #418 的范围判断)。 三、发现M-1|"59% → 93%" 是
|
| 新用例 | 既有等价物 |
|---|---|
test_open_with_a_non_html_path_is_refused |
test_diagram_cli_open_rejects_non_html_output(:61) |
test_open_with_a_non_html_format_is_refused |
test_diagram_cli_open_rejects_non_html_format(:108) |
test_a_syntax_error_names_the_file_and_the_parse_failure |
test_diagram_cli_reports_bad_input_without_a_traceback 第 1 段(:127) |
test_a_model_rule_violation_names_the_file |
同上第 2 段(:137,且断言更强,见 M-3) |
test_a_missing_destination_directory_names_the_path |
test_diagram_cli_reports_a_bad_output_path_without_a_traceback 第 1 个 target(:169) |
test_scale_is_refused_for_a_format_that_has_no_scale |
test_a_scale_is_refused_for_a_format_that_has_none(:250) |
test_an_unrecognised_suffix_lists_the_ones_that_work |
test_an_unknown_suffix_lists_every_format_the_command_writes(:289) |
TestAnUnusableBrowserChoiceIsReported 两条 |
test_diagram_cli_open_failure_names_only_a_document_that_survives(:195,多断言了不得打印幽灵临时路径) |
test_with_an_output_path_the_file_is_written_and_the_path_printed |
test_diagram_cli_json_and_html(:17)+ test_diagram_cli_open_prints_no_path...(:88) |
真正新增的只有 TestTheDocumentedJsonPathsWork::test_without_an_output_path...(stdout,突变已证)
与 test_a_binary_file_is_reported_rather_than_decoded(decode);TestAnExplicitFormatGovernsTheSuffix
覆盖的是 _validate_output_suffix 的早退,那行既有用例也走到了(只是没有正面断言写出结果,这条有增量价值)。
代价是同一批 CLI 文案现在被两个文件持有:改一次 --scale is only supported for PNG output
要改两处。建议二选一:把重叠的删掉只留 stdout / decode / explicit-format 三条,或者把
test/entry/test_diagram.py 的 CLI 用例整体迁进来。不阻塞。
M-3|test_a_model_rule_violation_names_the_file 的断言没有钉住它宣称的性质
类 docstring 与提交信息都说"各自命中不同阶段(parse / model / decode)",但这条只断言
exit_code != 0 / 无 Traceback / 文件名在输出里 —— parse 分支同样满足这三条。突变可证:
# pyfcstm/entry/diagram.py:93 —— 把 model 阶段的措辞换成 parse 阶段的
raise ClickErrorException("Failed to parse input DSL file %s: %s" % (input_code_file, err))SKIP_SLOW_TESTS=1 venv/bin/python -m pytest test/diagram/test_headless.py -q -p no:randomly \
-k 'TestTheCommandExplainsAnUnusableInput'
# 3 passed ← 阶段被搞混了,用例察觉不到
隔壁 parse / decode 两条都断言了各自的文案("Failed to parse" / "Failed to decode"),
唯独 model 这条漏了。补一行即可(该文案实测存在):
assert "Invalid state machine model in" in result.output$ venv/bin/python -m pyfcstm diagram -i ni.fcstm -o /tmp/x.json
Invalid state machine model in ni.fcstm: At least 1 entry transition should be assigned in non-leaf state 'Inner':
顺带一提:test/entry/test_diagram.py:145 那条既有用例已经断言了 "Invalid state machine model in",
所以新加的这条是"更弱的重复"。不阻塞(性质另有人钉),但按你们自己的标准这属于该修的弱断言。
M-4|"第 195-202 行需要真实浏览器窗口"这条理由不成立(两重)
(a) 它其实已经被覆盖了。 test/entry/test_diagram.py:72
test_diagram_cli_open_prints_no_path_it_has_already_removed 就走这条分支,并且断言了
result.output.strip() == str(named) 与 named.is_file() —— 也就是 195-202 想保护的性质。
我上面 89%/96% 那两次测量里 195-202 都不在 miss 名单,就是它。
(该用例用 monkeypatch.setattr(diagram_api, "_open_standalone_window", ...) 替换了生产内部,
按你们本轮的硬性约束那才是构造性 hack;但它存在、它覆盖。)
(b) 就算不算它,也不需要真实窗口。 _open_standalone_window 是 Popen + communicate(),
code != 0 才抛;退出码 0 就正常返回。用同一个 PYFCSTM_BROWSER 旋钮指向一个立即退出 0 的可执行文件即可,
不碰任何生产内部,1.4 秒:
printf '#!/bin/sh\nexit 0\n' > /tmp/stubbrowser/fakechrome && chmod +x /tmp/stubbrowser/fakechrome
printf 'state Root { state A; state B; [*] -> A; A -> B; B -> A; }' > /tmp/stubbrowser/m.fcstm
PYFCSTM_BROWSER=/tmp/stubbrowser/fakechrome \
venv/bin/python -m pyfcstm diagram -i /tmp/stubbrowser/m.fcstm -o /tmp/stubbrowser/v.html --open
# /tmp/stubbrowser/v.html
# exit=0 ; real 0m1.386s ; -rw-r--r-- 29404980 v.html被钉的性质是"--open -o path 在窗口关闭后打印保留下来的路径、文件还在",
这是任何装了 Chrome 的用户走的正常路径;stub 只是 instrument。CLAUDE.md 的
"Instruments are not findings" 明确允许这种写法 —— 而且这跟你们已经接受的失败态用例
(同一个环境变量指向不存在的路径)是同一类工具。
结论:提交信息里 "one needs a real browser window" 应当改掉;全仓库口径下"故意不覆盖"的其实只有
73、82、252 三行,不是六行。补不补这条测试随意(既有用例已覆盖),但理由要改。不阻塞。
M-5|issue #418 的范围判断错了:inflatePdfStreams() 就在本 PR 的 diff 里
主 session 说它"由 6f00b51(2026-07-22)引入、早于本 PR 且不在其 diff 内"。前半句对,后半句不对 ——
6f00b51d 本身就是这条分支上的提交,不在 main 上:
git merge-base --is-ancestor 6f00b51d main && echo in-main || echo not-in-main
# not-in-main
git diff main...HEAD -- tools/diagram_assets/check_viewer_browser.js | grep -n inflatePdfStreams
# 57:+function inflatePdfStreams(base64) {
# 731:+ const pdfStreamText = inflatePdfStreams(pdf.base64);整个函数是本 PR 新增的 + 行。"早于本 PR 最新几个提交" ≠ "在本 PR 之外"。
不过我第八轮标的就是非阻塞,现在仍然非阻塞,理由有二:一是 tools/ 不在 CLAUDE.md 异常策略
枚举的三棵树(pyfcstm/、editors/jsfcstm/src/、editors/vscode/src/)内;二是调用点确实有
pdf.inflatedStreamBytes 这个非空守卫,全量跳流不会静默通过(残留问题只是部分跳流不可见)。
建议:要么把 catch (_) 收窄成 if (!(err instanceof zlib.ZlibError) ...) throw err 并统计跳过数,
要么把 #418 的描述从"历史遗留"改成"本 PR 引入、留待后续",别让下一个读者以为它不在本 PR 的责任范围。
四、其他质量检查
-
测试边界:新用例只用
CliRunner+tmp_path+ 公开 CLI,不读editors/jsfcstm/、不 importtools.*、
不碰仓库根templates/。合规。 -
断言强度:
TestTheDocumentedJsonPathsWork断言summary.states == 3与
rootState.children == ["Root.A","Root.B"],确实做到了"不只是能 parse",是本批里最扎实的一条。 -
docstring:六个类都写清了被固定的性质,符合仓库风格。
-
build_pdf_writer()的:raises ValueError:与实现三个 raise 点对得上。 -
一个未能复现的观察:某次在 stdout 突变下
test_diagram_cli_reports_bad_input_without_a_traceback
报红,随后两次同样条件重跑都是 16 passed。无法复现,不作为发现,只记录一句。 -
关于主 session 那 4 次突变的充分性:我同意"恰好一个变红"能说明用例互不掩盖。
未做突变的三条输入分级里,parse 和 decode 靠文案断言是够的,model 那条不够(M-3 已给出反例)。
判定
ready to merge
新增用例全部通过、lint 干净、突变有效,pyfcstm diagram 唯一真正没测过的首要用法(stdout JSON)
现在有钉子了,这是实打实的收益。五条发现都是 M:M-1/M-4 是提交信息里的说法需要修正,
M-2/M-3 是测试自身的冗余与一处弱断言,M-5 是一个 issue 的范围描述需要改。
没有 C / I,不阻塞合并。若愿意顺手处理,优先级建议 M-3(一行断言)> M-4/M-1(改措辞)> M-2(去重)。
Corrects the previous commit, which claimed a 59% baseline for `pyfcstm/entry/diagram.py` and added fifteen tests. Both were wrong, and for the same reason: the measurement ran only `test/diagram/`, which is not where this command's tests live. `test/entry/test_diagram.py` already covered it to 89%, already asserted exit codes and message text, and already covered nine of the behaviours that commit re-tested. Twelve of the fifteen were duplication, and they split assertions about CLI wording across two files. What was genuinely untested is three behaviours, kept here and put in the file the rest of the command's tests are in: - writing JSON to standard output with no `-o`, which is the first form the command's help documents and the one a caller pipes from; the whole of stdout has to be the document, so the test parses it rather than searching it - refusing every non-JSON format when there is nowhere to write it, asserting exit 2 rather than merely non-zero, because a usage error and a failed export are different outcomes and a shell branches on which one it got - a binary file mistaken for a machine, which reaches a different stage than a syntax error and has to say so Coverage on the real denominator: 89% -> 96%, reached with three tests rather than fifteen. The three lines left are a race Click's `exists=True` opens, a path Click's `dir_okay=False` refuses first, and one covered in an environment without the optional runtime. Each test was mutation-tested: emptying the JSON written to stdout, disabling the format guard, and removing the decode handler each fail exactly one of them.
第九轮整合(主 session)两位 reviewer 均判 ready to merge,0 C / 0 I,共 8 条 M。但其中三条指出的是我自己判断错误,比 ready 这个结论更重要,先说这三条。 更正一:覆盖率基线取错,数字是错的我在 我用 真实是 89% → 96%。59% 是我用一个恰好排除了主要测试文件的口径量出来的。这与之前 更正二:那 15 条用例里 12 条是重复的
我唯一真正新增的是 2 个行为(第 76 行的二进制解码失败、204-209 的 stdout JSON 及其守卫);第 250 行是 已在 更正三:#418 的归属基线取错我说
后果不同:这不是可从容延后的历史代码,而是伞 PR #383 未合并交付的一部分。不修就随伞分支合并,等于把一个已知会沉默报绿的门禁一并送上 main。#418 已更正归属,并把处理窗口绑定到伞 PR 合并之前,标题也改了。 更正四:
|
| 突变 | 结果 |
|---|---|
click.echo(view.to_json()) → 输出 {} |
1 failed / 2 passed |
stdout 只允许 JSON 的守卫 → if False: |
1 failed / 2 passed |
except UnicodeDecodeError → 永不触发的类 |
1 failed / 2 passed |
基线与恢复后均 3 passed。
复核过的判断(两位一致确认成立)
entry/diagram.py:73(Click exists=True 之后的竞态)、:82(dir_okay=False + readable=True 先拒绝)、:252(needs_no_runtime 环境覆盖)、api.py:1362-1408(需注入会抛异常的 logging handler,CLAUDE.md 明文排除)、以及 monkeypatch.setenv("PYFCSTM_BROWSER", ...) 不算构造性 hack——设环境变量就是该旋钮的使用方式。
b8ac6930 已推,等 CI。不合并,等维护者指示。
|
Codex reviewer(实际模型:gpt-5.6-sol) 审查范围严格限定为 C / I / M:C 0,I 0,M 0。 核验结果:
三个 docstring 与实际断言一致;导入、plain function、文件级 可复现验证:
按指定覆盖率命令同样为 结论:ready to merge |
|
Claude reviewer(实际模型:Claude Opus 5 (1M context),模型 ID 第十轮,只看 结论:ready to merge。 无 C,无 I,两条 M(都不阻塞)。 0. 范围核实
1. 我第九轮两条 M 是否被真正解决——是M-1(覆盖率口径取错)已解决,我复算过基线,不是接受主 session 的自述: # 基线:把三条新用例摘掉
$ SKIP_SLOW_TESTS=1 venv/bin/python -m pytest test/entry/test_diagram.py -q \
--cov=pyfcstm.entry.diagram --cov-report=term-missing \
-k "not writes_json_to_standard_output and not refuses_a_non_json_format and not reports_a_binary_input"
pyfcstm/entry/diagram.py 85 9 89% 73, 76, 82, 204-209, 250, 252
16 passed, 3 deselected
# 加回三条
$ SKIP_SLOW_TESTS=1 venv/bin/python -m pytest test/entry/test_diagram.py -q \
--cov=pyfcstm.entry.diagram --cov-report=term-missing
pyfcstm/entry/diagram.py 85 4 95% 73, 82, 250, 252
19 passed in 17.35s基线 89% 与我第九轮的判断一致(此前的 59% 是拿 一处口径更正:实测是 95%,不是 96%( M-2(15 条里约 12 条重复)已解决。 我逐条比对了文件内既有 16 条:
这是一次真重写,不是换形式。3 条覆盖 15 条曾覆盖的同一批行,且不与既有断言争夺同一性质。 2. 断言强度——独立突变验证我没有复跑主 session 表里那三个突变,而是补做了它没做、且正对着"docstring 是否强于断言"的两个。 (a) 用例 1 钉住了它 docstring 里最强的那句("printing a summary line alongside the click.echo(view.to_json())
click.echo("wrote diagram for %s" % input_code_file) # 突变
(b) M-1:用例 2 的 docstring 有一句断言并未锁住。 原文:
把拒绝推迟到把活干完之后: if output is None:
if format_name not in (None, "json"):
_tmp = _pl.Path(tempfile.mkdtemp()) / ("wasted." + format_name)
view.save(str(_tmp)) # 先白干一遍
raise click.UsageError("JSON is the only format that can be written to stdout")即"先做后拒"这一性质, 不过这次的量级比第九轮小得多,而且我认为不该靠加断言去补:从 public surface 观测 """Only JSON can go to standard output, and asking otherwise is a usage error.
A PNG on a terminal is not what the flag combination asks for. What is pinned
here is the shape of the refusal, not its timing: exit 2 rather than merely
non-zero, because a usage error and a failed export are different outcomes and
a shell branches on which one it got.
"""顺带确认 (c) M-2:用例 3 的判定依赖 chardet 的一次低置信猜测。 >>> chardet.detect(bytes(range(256)) * 8)
{'encoding': 'windows-1253', 'confidence': 0.216, 'language': 'Greek'}
这条只是 fail-loud,不会 false-green:一旦翻转, # Every codec auto_decode tries rejects these bytes -- chardet's own guess
# included -- so the failure is the decode, not the parse. A codec that
# accepts arbitrary bytes (ISO-8859-1, MacRoman) would make this a parse
# failure instead, and this test would say so rather than pass emptily.
source.write_bytes(bytes(range(256)) * 8)3. docstring 与实际断言的一致性除上面 M-1 那一句外,其余都对得上:
4. 风格与冗余符合文件既有风格:plain function、 唯一可说的是三条 plain function 追加在 无冗余代码。三条各自最小:DSL 源文本只写到够用(用例 1 需要两个子状态来验 5. 复现git checkout b8ac6930
SKIP_SLOW_TESTS=1 venv/bin/python -m pytest test/entry/test_diagram.py -q \
--cov=pyfcstm.entry.diagram --cov-report=term-missing
# 19 passed, 95%, missing 73, 82, 250, 252工作树在两次突变后均已 判定ready to merge。 两条 M 都在测试文件的表述层,不影响任何被钉住的生产行为,也不影响合并。若愿意随手带上, 我第九轮提的两个问题都是真解决了:覆盖率基线经我独立复算为 89%,三条用例关掉的行与声称的 |
Two wording defects in the tests added by the previous commit, both found in review, neither changing what runs. The stdout-format test's docstring said the refusal "has to come before the export runs". Its assertions do not pin that, and moving the export ahead of the refusal leaves them all passing. Observing that no work was done is not available from the public surface without reaching into the command, so the claim is dropped rather than propped up with an instrument: what the test pins is exit 2 rather than merely non-zero, because Click gives a usage error 2 and a failed operation 1 and a shell branches on which one it got. The binary-input test depends on `chardet` declining to commit to an encoding for those bytes, which is an unpinned dependency's judgement rather than something this repository decides. The premise is now written down next to the bytes, with what the failure will look like if a version bump changes it -- the command would report a parse failure instead, which is loud rather than silent, but only if the reader knows the premise was there. Coverage for the record, with the denominator stated: 89% -> 95% measured over `test/entry/test_diagram.py` alone, 96% over that file together with `test/diagram/`, which also reaches the render-limit translation through its own oversized-diagram case.
本 PR 是伞 PR #383 的最后一个子片,交付上游 issue
#89 标题所指的同步栅格化导出能力、输出限额,以及安装/冻结交付闭环。
base 是
dev/python-diagram-umbrella(当前 head46660c4c,已含#384 /
#386 /
#389)。当前为空提交,实现尚未开始。
一、开工前的实测事实
以下每条都在 base
46660c4c上跑过,命令随附以便复核。事实 1:公共 API 已被上一片冻结,本片是填合同而不是设计合同
Diagram.to_svg()/to_png(scale)/to_pdf()的签名与 pydoc 已在#389 发布,实现是永远抛
DiagramUnavailableError的声明式桩。save()已经把三种格式路由到这三个方法并原子写盘:三个
to_*一通,save()零改动即六格式可用。事实 2:palette / mode / cjkLocale 必须放在请求根层,渲染器早已支持
初版正文写"headless 渲染完全无视 palette/mode",观测正确但归因错误,两位 reviewer 都指出了这一点。
真实情况是
renderer.js从请求根层读这三个字段:而
DiagramOptions.to_dict()既不输出palette/mode,又把cjkLocale放进options—— 于是三项全部落空。filldata-fcstm-palette/data-fcstm-modeoptions里(现状)#183b61 #2d6aa8 #3470a8default/lightpalette=nord, mode=dark#2e3440 #3b4252 #434c5enord/darkpalette=solarized#073642 #268bd2 #2aa198solarized/lightmode=dark#1a2634 #24303f #26364bdefault/darkcjkLocale放根层时jp/kr会把font-family切到Noto Sans JP/Noto Sans KR;sc无差别只因它是默认值。因此这是纯 Python 侧接线,不需要触及渲染器,不进入
#384 的范围。 初版正文把 1a 定价为"需要触及渲染器"是错的。
复现:
事实 3:
expand_svg()已能产出符合合同的 expanded SVG伞 PR 要求对外的 SVG 是 expanded 形态。引擎已有
expand_svg(),实测对照:<text><marker>font-family<path>render_svg()(raw canonical,内部中间产物)expand_svg()(expanded,对外形态)事实 4:伞 PR 指定的 headless PDF 方案已验证可行,端到端跑通
伞 PR 指定用同一
editors/jsfcstm/src/diagram/export/normalization/export core 加精确锁定的@xmldom/xmldom 0.8.11DOM adapter。该 core 已存在(export/index.ts),jspdf 4.2.1与svg2pdf.js 2.7.0已锁在
editors/jsfcstm/package.json的devDependencies,唯一缺的是 xmldom。用
expand_svg()的输出经 jsPDF + svg2pdf + xmldom 在出厂 host(host-shim.js,无document、eval/Function被设为不可写的undefined)内跑通:四条合同判据(单页 / 页面尺寸正确 / image objects = 0 / 文字不可搜索但字形稳定)全部实测满足。
jspdf与svg2pdf.js发行版里new Function与eval(均为 0 次,因此 host 那条故意的eval/Function禁令不需要为 PDF 让步。在出厂 host 之上需要补的 host 能力共四项,均已实测确认为平凡:
navigator is not definednavigator.{userAgent,language}atob无btoabtoarootSvg.querySelectorAll is not a function"style,link";按 tag 名实现,其它选择器显式抛错。引擎产出的 SVG 中<style>/<link>均为 0 次cssesc: undefined.charAthasAttribute("id")再读element.id,xmldom 不暴露该属性;补idgetter一条重要的负面结论:若把 raw canonical SVG(含
<text>)而不是 expanded SVG 送给 svg2pdf,它会走canvasTextMeasure/svgTextMeasure两条文本测量路径,二者分别需要 canvas 2D 上下文与document.body+getBBox(),在无 DOM host 内均不可用。走 expanded SVG 则完全绕开文本测量,也因此不存在字体注册与 CJK 子集化问题。这是"对外用 expanded SVG"这条合同的一个额外好处。
数字与计数口径
初版正文报"
viewer.js里 jsPDF 13 处",两位 reviewer 都指出该数字不可复现。正确口径:viewer.js是单行超长 minified 产物,行计数在这里不是有意义的引用量度;本正文一律报出现次数并附命令。同理,事实 2 表中的 fill 是排序去重后的前几项;文档顺序的前五个是
#3470a8, transparent, #edf4fb, #dce9f5, #183b61。二、思想与边界
思想
不新增任何渲染或导出实现,只把已存在的能力升格为可选公共能力;parity 靠"共用同一份代码与同一份资产"结构性保证。
具体落到三条硬约束:
headless 侧只允许"安装 DOM contract → 加载同一 renderer/export core"。
cairosvg、resvg-py、Pillow、img2pdf、reportlab、svglib一律不用)。边界内
to_svg/to_png/to_pdf在有 optional runtime 时真正工作,无 runtime 时仍抛DiagramUnavailableErrorto_svg()与_repr_svg_()走expand_svg(),不含<text>/<marker>/script / remote URL / font dependency;PNG 走 closed canonical SVG + pinned resvg WASM
DiagramRenderLimitError(伞 PR 延期到本片,详见第四节)pyfcstm diagram接受.svg/.png/.pdf与--scale,并在 CLI 层正确翻译限额错误_repr_svg_()返回 static expanded SVGtools/diagram_assets/corpus/的 35 layouts / 306 arrows)NOTICE/READMEprovenance 更新、双语文档第 4、7、8 项是伞 PR"三处收窄与延期"里点名交给本片的三件事,初版正文既未接也未排除,现明确接下。
边界外(明确不做)
DiagramAssetEngine升为公共 API:它在pyfcstm/diagram/__init__.py里已声明为 maintenance surfaceto_png(palette=...))、不加--dpi、不加批量导出一条硬边界:base 安装不许退化
未安装 optional runtime 的 base wheel,行为必须与 #389 之后完全一致。
import pyfcstm.diagram不得触发任何 runtime 探测,能力检测必须懒到首次真正调用才发生。CI 的Code test矩阵不安装
requirements-viz.txt,它是这条边界的看守者。需要澄清一处初版正文的错误推理:PDF writer 无论是否按需加载,只要作为 packaged asset 进入 wheel,
base wheel 用户就承担这些字节。按需加载只降低调用时的初始化与内存开销,不降低体积。本片按后者理解报告体积影响。
三、公共 API 原型
已发布的四个签名一个字不改。 变的是"不再永远抛"、
:raises:语义,以及新增一个错误类型。语义合同:
DiagramUnavailableError,消息指明pip install pyfcstm[viz](该 extra 已存在,wheel metadata 的Provides-Extra含viz)DiagramEngineConflictErrorDiagramEngineLoadError/DiagramEngineMetadataErrorDiagramAssetErrorDiagramRenderError,且下一次调用必须成功(context 重建)scale非有限正数,或scale > 4ValueErrorDiagramRenderLimitError(新增),在进入 WASM 之前拒绝_repr_svg_()返回Optional[str]:缺 optional runtime 时返回None而非抛异常,因为 Notebook 的 repr协议中抛异常会污染整个单元格输出。这是唯一一处 typed unavailable 不抛的位置。
新增公共错误类型的连带工作:
DiagramRenderLimitError必须加入pyfcstm/diagram/__init__.py的"Failure surfaces" 表格(该文件以表格形式充当模块 roadmap),并写进双语 Reference。
CLI 原型:
--format的 choice 由[json|html]扩为[json|html|svg|png|pdf];--scale仅对 PNG 有效;--scale 5与超限请求必须在 CLI 层给出可读错误而不是堆栈;stdout 仍只允许 JSON;后缀推断的错误消息(现写死 "use a .json or .html path")同步更新。
四、输出限额(伞 PR 冻结,本片实现)
伞 PR 已把这组限额冻结并延期到本片,代码中目前完全不存在(
DiagramRenderLimitError亦不存在):scale0 < scale <= 4<= 16,384 px<= 16,777,216<= 67,108,864 bytes<= 33,554,432 bytes<= 67,108,864 bytes实现约束(全部来自伞 PR 明文):
viewBox计算ceil(width*scale)/ceil(height*scale),先做 checked multiplication 再进入 WASM
ValueError;尺寸/像素/输出超限抛DiagramRenderLimitError,消息必须包含原始尺寸、缩放后尺寸、scale、触发的 limit,以及降低 scale 的行动建议
公开可达性:
model.diagram().to_png(scale=5)与pyfcstm diagram ... -o out.png --scale 5都是正常调用路径。五、执行步骤与每步验收
步骤 1:接入
@xmldom/xmldom 0.8.11与 headless DOM contract在
editors/jsfcstm/package.json的devDependencies精确锁定@xmldom/xmldom 0.8.11(与已锁的jspdf 4.2.1、svg2pdf.js 2.7.0同级),编成独立 host adapter,在加载同一 renderer/export core 前安装DOM contract。四项 host 补齐(
navigator、btoa、单选择器querySelectorAll、idgetter)随该 adapter 提供。验收:xmldom provenance 追加到同一 MIT/
NOTICE合同;asset-lock.json记录 npm integrity、installed-treehash;esbuild metafile 与最终资产不含
canvg/html2canvas/fast-png/ 其他 raster fallback(
pdfimages的 zero-image 门禁不能代替 bundle dependency scan);报告 combined bundle 体积与 wheel 增量。步骤 2:输出限额与
DiagramRenderLimitError按第四节实现,limit constants 与 negative corpus 由 browser / headless 共用。
验收:每条限额一个负测,断言错误类型与消息四要素;
to_png(scale=5)抛ValueError;CLI
--scale 5给可读错误;断言超限在 WASM 调用之前发生(用一个超限但结构合法的请求,证明没有进入渲染即被拒)。
步骤 3:
to_svg/to_png/_repr_svg_to_svg()与_repr_svg_()走expand_svg();to_png()走 canonical + resvg。三者构造请求时把palette/mode/cjkLocale放请求根层。验收:
<text>/<marker>/font-family/ script / remote URL / 绝对路径均为 0data-fcstm-palette/data-fcstm-mode符合预期cjkLocale五地区各自的font-family正确运行时验证 PNG signature / IHDR / CRC / IDAT / IEND / zlib scanline 长度与 filter byte,不能只查 magic bytes
DiagramUnavailableError,_repr_svg_()返回Noneimport pyfcstm.diagram不触发 runtime 探测(以sys.modules断言)步骤 4:
to_pdf复用步骤 1 的 adapter 与同一 export core。
验收:单页;页面尺寸与 SVG 视口匹配;
pdfimages -list报 image objects = 0;pdftotext为空(文字不可搜索但字形稳定,该取舍写入双语 Reference);rerender 一致;禁止高分辨率 PNG 伪装。
步骤 5:CLI
验收:
test/entry/test_diagram.py补六格式路径、--scale误用、超限错误的 CLI 措辞与退出码、未装 optional runtime 时的退出码与 stderr。
步骤 6:完整 canonical corpus 与 VSCode webview 展开宿主
把浏览器导出门禁从"结构代表性样例"接到
tools/diagram_assets/corpus/的完整 35 layouts / 306 arrows;为普通 VSCode webview 提供展开宿主能力(
__FCSTM_EXPAND_SVG__目前只由 Python 自包含 HTML 注入)。验收:门禁在完整 corpus 上通过并报告覆盖计数;VSCode webview 侧展开路径有对应测试。
步骤 7:交付矩阵
新增
tools/check_diagram_headless.py、tools/check_diagram_browser_headless.py、tools/check_diagram_notebooks.py;tools/check_diagram_engine_floor.py的--formats由svg,png,expanded-svg扩为svg,png,expanded-svg,pdf。矩阵按伞 PR 的 Installed matrix 逐行计数为 10 个 installed job(Ubuntu 3.7 / 3.8 / 3.9 / 3.10 / 3.14,
Windows 3.7 / 3.9 / 3.14,macOS arm64 3.8 / 3.14),外加 4 个 PyInstaller job 与 2 个隔离 floor job。
初版正文写"11 个"是错的。
floor job 硬规则:任一 floor 无法通过,就把依赖下界抬到真正能完整通过的版本并同步 metadata/docs,
不保留虚假下界。
PyInstaller 不变量:onefile archive 恰好包含一套 engine native resources 与完整 assets/licenses,
需要显式 inventory 断言。
六、验收命令
伞 PR 命令块里
--formats svg,png,pdf是在expanded-svg成为对外默认形态之前写的,本片按svg,png,expanded-svg,pdf执行,并回伞 PR 同步该命令。追加验收项
test/diagram,断言三方法抛 typed unavailable、_repr_svg_()返回None、import不触发 runtime 探测。须为显式测试,不能依赖"碰巧没装"。DiagramEngineConflictError有显式验收:Installed matrix 里同环境装两个 distribution 是可达的正常场景,需要显式 job 或测试。
不允许靠注入内部故障来验收。若在实现中发现限额生效后 OOM 已不可由公开路径触发,
则据实把该承诺改写为"限额之内不会 OOM",并说明理由。
门禁必须能失败——上线前的突变清单
每条都实际做一遍确认变红,再改回:
scale--png-scales 1,2,4--pdf-require-zero-imagesto_svg()返回 raw canonical(带<text>/<marker>)DiagramRenderLimitError降级为DiagramRenderErrorDiagramEngineConflictError验收--check-timeout-reset七、文档与 provenance 待办
NOTICE.txt目前只具名svg2pdf.js 2.7.0"及其 MIT 依赖",未具名 jsPDF(package.json锁的是jspdf 4.2.1);NOTICE.txt与assets/README.md现有措辞称"standalone browser bundle 才含 svg2pdf",本片让 headless 也用上这套 writer,两处措辞需更新,并把 xmldom provenance 追加到同一 MIT/
NOTICE合同DiagramRenderLimitError、PDF 文字不可搜索的取舍pyfcstm/diagram/__init__.py的 Failure surfaces 表格新增DiagramRenderLimitError一行八、本 PR 的评审纪律
需要新建文件、新建工具、新建门禁的意见记为 follow-up,不在本 PR 修。
实施范围与Closed gates生成,不由实现者自由撰写。初版正文之所以漏掉整组输出限额与 expanded-SVG 合同,正是因为我只读了伞 PR 的 PR-D 小节,
没有读"几何、SVG、PNG 与 PDF 合同"与"三处收窄与延期"两节。
CLAUDE.md的 Code Review Scope。只允许以符合正常使用场景的 public API构造问题;强行篡改私有字段、给 production internals 打补丁、在内部调用点注入故障、伪造平台常量,一律拒收。
给不出复现路径的不是发现。
评审员构成为 codex reviewer 与 claude reviewer 两路。第三路 deepseek reviewer 本轮不可用:
API key 有效(模型列表可拉取)但账户返回
402 Insufficient Balance,本地 Moon Bridge 亦未运行;经维护者确认后续跳过该路。
九、当前状态
a7b75e3d,实现未开始dev/python-diagram-umbrella@46660c4c,落后origin/main0 个提交,无上游冲突main、installed-wheel/PyInstaller 门禁、记录同步、最终独立 review与维护者批准,才能合入
main