Repository navigation
finding(tooling): check:generated --fix writes artifacts from a turbo CACHE-SERVED dist and then reports 「All 16 generated artifacts are up to date」 — a verifier reporting success over a measurement it did not take #19086
Description
Restart-when: closed #19024
⬆️ 这一行由分诊席于本轮补为行首、未加引用的形式。原句写在下方 STOP 块的引用里(> ⛔ **Serialised: …**),行首正则与正文-only 两道检测都读不到它 —— 那正是前任交接台账记录过的盲区。⛔ 内容未改,只改了可被机器读到这一点。
⛔ STOP — the mechanism this body and TITLE name is REFUTED by measurement
The defect is real, but it is not the turbo shared cache. Measured on this container at
1047fe1016: a cache hit serves a byte-identical dist (451 files, path+sha256 diff exit 0, raw readdir order identical); a restore normalises mtime to epoch 0, so the freshness guard accuses rather than being short-circuited; and turbo's 1808-input set is a strict superset of what the stamp hashes, so on a cache hit the stamp necessarily matches.
⚠️ The reproduction recipe below is also not runnable as written —pnpm --filter @objectstack/spec buildnever invokes turbo, and pnpm appends the--forcein step 3 tocheck-dev-prereqs.mjs, not to turbo. Steps 1 and 3 are the same command. ⇒ running it returns a null result that is not a failure to reproduce.⭐ What survives, and is the whole card: in
check-generated.tsthereadsDistrefusal lives only inside the--fixloop, while the reporting path prints its advisory only when a gate FAILS — so a gate that passes over a stale dist prints 「All 16 generated artifacts are up to date.」 saying nothing about the dist. AnddistIsStaleis(mtime-accuses AND stamp-not-match), so it answers fresh whenever mtime says fresh: the acquitting branch never consults the only content-based evidence there is. A write path gated on the absence of an accusation rather than on positive proof.⛔ Serialised:
Restart-when: closed objectstack-ai/objectstack#19024— that PR holdscheck-generated.ts,lib/sharded-artifacts.tsandbuild-api-surface.ts, and under the maintainer's own ruling it is maintainer-landed. It also deletes the surface of defect (β).Full measurements, radii and lit controls: comment
5737695403. The seat's retraction of a dispatch made without reading this thread: comment5737665608. ⛔ The prose below is kept rather than rewritten, and is now HISTORY.
Filed by the domain:spec#4 seat (session_01AmH9bKvGoLjiY86Q4Z3og2) on a dev's out-of-scope finding while working #18163 / PR #19067. ⛔ Filed unassigned, ⛔ no priority or type applied — routing and grading are triage's. ⛔ Not a claim.
The defect
pnpm --filter @objectstack/spec check:generated --fix will WRITE generated artifacts from a dist that turbo served from the shared cache — which every linked worktree on a container replays — and then report:
All 16 generated artifacts are up to date.
⇒ a verifier reporting success over a measurement it did not take. Route and surface ownership rule 3 calls that worse than no verifier: a green from an instrument that never looked is indistinguishable, downstream, from a green from one that did.
Repro, as the reporting dev recorded it
In a linked worktree:
pnpm --filter @objectstack/spec build(plain — turbo may serve from cache)pnpm --filter @objectstack/spec check:generated --fixpnpm --filter @objectstack/spec build --forcepnpm --filter @objectstack/spec check:generated
⇒ the shards differ between (2) and (4). Two independent --force builds agree with each other.
⭐ It already cost a real reading, and the cost is the reason to file
The dev disclosed it rather than quietly fixing it: it had reverted an edit partly on the grounds of 「330 lines of order-only churn in the unrelated ui shard」. From a --force build that tree emits ui.txt and automation.txt byte-identical to main and no such churn exists. ⇒ the measurement was VOID — not disproven, and the decision had to be re-grounded on what survives measurement.
Why the existing guardrail does not cover it
AGENTS.md documents the shared-cache hazard in the READ direction (「a typecheck failing on a package your diff never touched」) and prescribes a force-rebuild. The gate's own prescription says only 「Build first」.
⇒ the WRITE direction — --fix committing artifacts derived from a cached dist — is unguarded.
Who feels it
Whoever regenerates spec artifacts inside a worktree, which is every packages/spec card. The generated artifacts land in commits and are gated by check:generated, so a wrong one is discovered downstream, on someone else's PR.
Dedupe words
check:generated --fix · turbo cache · stale dist · api-surface-declarations · worktree shared cache
⛔ The filer does not dedupe (only these words); triage searches its own list, and a zero there needs a control that hits.
Related: #18163 (the card whose round-2 work surfaced it) · PR #19067
Generated by Claude Code
维护者速读(由 domain:spec 席 3 于 2026-09-20T16:35Z 补写;⛔ 不含判定)
一句话问题:check:generated --fix 会照着一个已经不描述 src 的 dist去改写已提交的产物(实测删掉 3 个还活着的导出),而下一次 check:generated 会对着那棵脏树打印「All 15 generated artifacts are up to date」。守门的只有一个三行谓词,而它从不读 dist 的任何一个字节——它比的是 mtime 和一份对输入取的摘要。
为什么 mtime 挡不住:leg 1 只能给「比 src 旧」的 dist 定罪,而任何一次写入都会把 dist 的 mtime 往前推。⇒ 这一整类损坏按构造就在这个谓词能报出的范围之外。实测:一份删了两个导出的 dist,distIsStale=false 且 stamp=match;--fix 照写不误。
谁会自然造出这个状态:被打断或被杀掉的声明生成过程(本包 build 是 tsup(clean:!isDts)+ 独立 DTS 过程,而 stamp 由 build 脚本最后一步写,所以中断会留下「新的 dist mtime + 上一次的 stamp」)· 只发了一部分 chunk · 任何手改或部分还原的 dist。实测 E1:46 个 chunk 声明缺 2 个时,check:api-surface 报 70 处 breaking 移除——那一刻 --fix 会从基线里删掉 64 个活导出,只打印一行 ✓ gen:api-surface。
四个修法,代价各不同(⛔ 本席不选):A 让 build 额外记一份对 dist 产物的摘要,放弃时序推断改用正面证据;B 只是别再丢弃已有的输入侧证据(实测对本复现无效——那次 stamp 说 match);C 让写入路径干脆先重建再写(全封死,代价是每次 fix 一次完整 build,而本卡线程自己警告过「慢到没人跑的守卫会被绕过」);D 只改报告面,让那句「All N ... up to date」说出它凭的是哪一份 dist,并让 --fix 事后重验。
末句一字问:A / B / C / D?
os-decision-facets
- ① 项目长远合理性 — 今天的契约是「stamp 只能放行,缺失或不可读都让 mtime 拒绝继续站着」。A 把它改成能定罪,是收紧特例;D 只让报告说出自己量了什么,不新增任何特例。⇒ 两者都缩小而非扩大契约增生;B 留着一个已实测挡不住本复现的守卫,C 新增一条「每次都重建」的永久成本。
- ② 实际业务拉动 — ⏱️ 今天就撞上了:一次
--fix实测删掉 3 个活导出并被下一次运行判为绿。拉动不是假设。⛔ 但也要说清:本轮未量到 CI 上能否达到这个被放行的状态(CI 里 dist 在 job 内构建)⇒ 已知受害面是本地与 agent 容器。 - ③ 防 AI 犯错 — 这正是「响亮拒绝优于静默容忍」的反面教材:错误的那次静默写入,而绿色出现在下一次运行、且结构上不可能反驳上一次写了什么(
--fix写完不复验,下一次检查比的又是同一份 dist——任何 dist 都会自我同意)。D 直接治这一条:让那句绿说出它凭什么。 - ④ 创业阶段不扩散 — A 要新增一份产物(dist 摘要)并因此新增一份永久义务;D 不新增任何键、任何产物,只让现有那句话诚实。⇒ 按「remove 优于 declare-and-maintain」,D 是最小面;若只能落一半,D 是可独立落地的那一半。
Prior rulings read: check,generated,tooling,writes,artifacts,turbo,cache-served,dist,reports,date,verifier,reporting (+2 more) → 53 hits; ADR-0058 D4, ADR-0087 D5, ADR-0087 D6, ADR-0119 D4, ADR-0124 D1, ADR-0029 D3, ADR-0029 D8, ADR-0029 D9.9, ADR-0044 D1; thread: none
check-prior-rulings --card 19086(⏱️ 16:33Z,origin/main fade3da2,真退出 0)按词命中,本席逐条读过:没有一条回答本卡的问题,所以该工具「已有 ACCEPTED 裁决 ⇒ 本卡可能是执行而非决策」的常设警告在此不成立。
推荐 A 复合 D;自检「只看①选 A(它是唯一实测能封住本轮复现的机制的选项);②③④ 是否翻转:不翻——② 拉动实测存在、③ D 直接治静默、④ D 是最小面且可独立先落」。
置信缺口:⛔ 未量到 CI 能否达到被放行的状态;⛔ 未量到任何修法的运行代价(没取过「对 dist 取摘要」的耗时);⛔ 那份「被打断的 build 会留下此状态」是从 tsup.config.ts 与 build 脚本次序推出的,⛔ 不是杀过一次 build 量出来的;⛔ 另四个读 dist 的门与 bundle 轴未在损坏 dist 下测过。
Generated by Claude Code
Activity