Skip to content

Commit 2a4d21c

Browse files
authored
The jump arrow follows the scroll, clamped at both ends; and the shipped sources stop carrying private numbers (#17)
* Stop the shipped comments carrying a defect registry the reader does not have Forty-one of these comments opened with a label from a private numbering -- D40: as a section header, R1 inside a sentence, L2 and L3 naming layers that are defined in a document which is no longer in the package. The explanation was usually on the next line, so a reader was never going to go looking for it. What they did say, to someone reading the installed plugin, is that the project keeps a numbered defect list they cannot see. They are gone, and what replaces each one is the thing the number was standing in for: the rule as it reads. `/* ---- the inspector's own viewport ---- */` instead of `/* ---- D40: the inspector's own viewport ---- */`; the narrow-screen rule described as the narrow-screen rule; the prohibition this function enforces instead of the prohibition D13 enforces. Twenty of these were pointers, and they went in the previous commit. These are the other kind -- the label with no file name -- so the previous commit's assertion could not see them, which is why it took a second assertion rather than a wider pattern. The four layers are gone with them, and they were the same leak from the same missing document: `L0`-`L6` is a layer model with no key. node tests/run.mjs 57/57. * Pin the bare numbering, and re-anchor the one mutation that broke on it Two new assertions, one new pattern, and the boundary between them written down because the next person will trip over it. The new check covers the runtime sources and the two shipped test files, and deliberately does not cover the guard and mutation files -- which carry five times as much of this numbering as the runtime did. They are not the same shape. In index.html the number was a label on a section header with the explanation elsewhere. In mutate-inspector-dock.py it is "D40 gave the wide panel a ceiling and an inner scroller, so the sentence that scoped the header ... stopped being true": the number names the defect and the same sentence says what it did. That is the provenance that tells a reader why a control is in the suite at all, and METHODOLOGY.md is written the same way. Taking it out would cost the reason a mutation exists to remove a word nobody follows. So the pattern has to be careful in the other direction too: `#D8D8` is a colour and `R1` could be an identifier, so the word boundaries are load bearing. Anything looser is a guard that fails for the wrong reason. One mutation anchor broke and was re-anchored rather than dropped. mutate-cross-layer.py spells out the whole panel body so its replacement can put the window bar outside the closing `</section>`, and that body carried one of the rewritten comments. The harness refuses to start on an unresolved anchor, which is the right behaviour and still a failure this change caused. verify-review16.mjs 33 -> 34 assertions. mutate-review16.py 30 -> 32 mutations, all caught. mutate-cross-layer.py unchanged in count, re-anchored. * D47: 跳转箭头跟随滚动方向,两端夹取 报告者指出:滚轮往上滚箭头就向上,到最顶端则向下,到最低端反之;滚出轨迹 区域时按位置夹取,不按滚动方向。 这一版替换掉上一版为此加的 ledgerJumpUsed 标志。新的判据没有「首屏」这个 特殊状态 —— 箭头任何时候都有答案,所以标志连同「先问目的地再武装」的顺序 规则一起消失。 ledgerJumpGoesUp() 换成四个具名分支,顺序是有意义的: 两端都不可达(整页放得下)→ 默认朝下,这是唯一没有否决权可施加的一格 底端已到或已过 → 朝上 顶端在屏 → 朝下 都在屏外 → 跟随方向 顺序就是规则,所以守卫按次序断言,不只断言四个分支都在。 方向信号不发到 <body>。当年那个用 body.is-reading-down 驱动控件可见性、后来 被撤回的信号,其撤回理由是「带内控件的可见性由带外的信号决定」—— 那个理由 在这里不成立:这是导航控件的朝向,而朝向本来就是滚动方向的直接后果,且没有 第二个读者。判据是用途,不是信号本身。 canDown 改用 ledgerEndBelow(),不再取 !ledgerEndInView():后者问的是「哨兵在 不在视口里」,那一个布尔值同时对应「还没到」和「已经滚过去」两个相反的状态, 而只有前者意味着还能往下跳。atLedgerEnd 与观察器对它的写入随之消失 —— 谓词 每帧重读,那个标志只剩一个写手没有读者。 这一格在当前布局下结构上不可达,所以不能写成实测结论:检视栏的高度上限按视口 算(窄屏按 INSPECTOR_LEDGER_SHARE = 0.25 给轨迹留四分之一视口),而这一格要求 检视栏比轨迹栏再高出整个视口。布局恰好让旧谓词成立,那是运气不是正确性,所以 谓词按它真正问的问题改写,并由守卫把「问的是哪个问题」本身钉住。 滚动监听器只读 window.scrollY(不触发布局),重活过 rAF 批一次,与本页 scheduleStickyOffsets 同一惯用法:该文档实测每帧 617ms 布局,滚动事件比帧密。 监听器是具名函数,不是内联的。verify-generation-pin 要求每个 scroll 处理器都指向一个 真实存在的函数,因为那条守卫要防的变异会重新加回注册而函数已随撤回删掉 —— 那正是部分 撤回留在磁盘上的样子。内联的 function () {} 有同样的隐患却毫无可审计性:没有名字给读者、 给 grep、给那条守卫去核对,而「每个 scroll 处理器指向的东西都存在」这条规则按构造就看不见它。 * D47: 重写 verify-ledger-jump.mjs 与 mutate-ledger-jump.py 42 → 57 条断言,39 → 52 条变异,全捕,文件逐字节还原,还原后再跑一遍绿。 3 条守 ledgerJumpUsed 的变异随规则一起删。那个字段是被报告者的决定删掉的,不是 为了让变异通过而删 —— 这两件事必须分开写,否则下一个读提交的人会以为又一次犯了 「为了让变异通过而改断言」。相应断言的替代物是「方向有第三个值 null 而不是默认值」 和「规则不能塌成两个由顺序暗定的项」,各配一条变异。 两条变异重锚到新代码:哨兵判据从 atLedgerEnd = … 变成 return r.top >= viewH …, 跳转目标从 ledgerEndInView() 变成 ledgerEndBelow()。 新增 15 条断言覆盖新规则,其中三处是写这一版时才暴露的: - 「先问后写」那条断言第一版比的是 buildLedgerJump() 全函数里首次出现的位置, 而上面那个 scroll 监听器也写 ledgerScrollDir,于是断言被一个与它无关的写入 满足,配套的变异没被抓住、报出来的是另一条。改成限定在 click 处理器内部。 - 探针的现场条件一律报「量不到」而不是失败。2026-10-14 那一轮拿 1 条消息的会话 跑时,输出里 9 处 FAIL 没有一处是功能不对:整条轨迹只有 1014 高、文档总共 1146, 中段、底端、真滚轮、点按那几站的前提全都构不成。把「量不到」写成「坏了」的报告 比没有报告更坏,它会把人引去改本来正确的代码。 - 探针切会话原来是个无声的 no-op:找不到就 return undefined,页面停在默认会话上, 探针照样把后面所有数字打出来。换成短会话那一次,输出与大会话逐字节相同(轨迹 底都是 8192),而那个会话只有 1 条消息,轨迹底不可能还是 8192。现在切会话 当断言,并且要等下拉真的有选项 —— 它是异步填的,readyState 变 complete 时 还是 0 项。 新变异里最该留下的一条是把 var canDown = ledgerEndBelow() 改回取负:它在任何可达 滚动位置上测不出差别,靠的是守卫钉住「问的是哪个问题」。布局恰好让错的答案成立 是运气,不是正确性;只钉可达行为的守卫永远发现不了这种错。 这一轮还有两条是别的守卫逼出来的,都不是本守卫的问题: - verify-generation-pin 要求每个 scroll 处理器都指向一个真实存在的函数。第一版把 监听器写成内联的 function () {},那条规则按构造看不见它,整套因此 55/57。改成 具名函数 ledgerJumpOnScroll。变异的锚点跟着改成注册本身而不是函数体:把函数 一起删掉会被 generation-pin 以错误的理由抓住,等于没证明方向这件事。 - 写具名函数时在注释里原样引了那个被撤回的注册,verify-generation-pin 随即报一个 只存在于散文里的悬空处理器。它是裸文本扫描,分不清一次调用和一条提到该调用的注释。 注释改成描述那个注册而不是抄写它,这个坑也写进了源码注释。 * 让套件看出上一次运行留下了什么 外评审指出:`run.mjs` 里那个「跑完检查 shipped 文件是否还原」的守卫,比的是 **本次起点和本次终点**。被 kill -9 掉的那一次什么也写不下,所以它留下的变异态要等到 下一次运行才会被发现 —— 而那时它只表现为一堆无关的红。实测是 kill -9 掉一个变异控制后, 下一次 `node tests/run.mjs verify-anomaly` 报了一堆不相干的失败,**没有出现 shipped files 的任何提示**。 查下来是三处毛病,任何一处单独存在都足以让这个比对完全失效: 1. 快照是「已存在时才写」,全新检出永远没有快照可比,那行等于从来没执行过; 2. 写的是本次运行的**起点**,不是它结束时的字节; 3. **没有任何地方读它** —— 那个常量在 run.mjs 里出现 4 次,全是写。 第 3 条是根因。前两条只让它没内容可比,第 3 条让它连被读的资格都没有。 **改之前先确认它还在**:跑一次 `node tests/run.mjs verify-ledger-jump`,之后那个快照 文件不存在;手工造一份「上次干净结束」的快照,再把 index.html 改成一个真实变异(拆掉 四分支里「顶端在屏 → 朝下」那一条),同一条命令输出 `0/1 guards passed / failed: verify-ledger-jump.mjs`,一句都没提残留;改过的文件逐字节还原。 现在启动时读快照,逐文件与记录下来的摘要比对,不同就在结果**之前**打一行 WARN,点名 文件并说明两种可能。写快照的条件一并修好:无条件写、记**终点**、且只在本次没有变脏时 更新基线 —— 记一个变脏的终点会让下一次比对和变异意见一致,正是这个文件要防的事。 **这是报告而不是失败**,取舍和理由写在那行输出里:这套件会跑在有未提交改动的机器上, 「和上次结束时不一样」既可能是残留的变异态,也可能是有人在跑完之后正常改了代码,两者 字节上无法区分;一律判失败会把正在写代码的 contributor 报成坏掉的检出。而评审给的另一条 `git status --porcelain` 在装包副本里拿不到 —— 套件设计上就要能在没有 git 的地方跑。 改完之后同一份复现:快照会被创建,输出顶部出现 `WARN shipped files differ from the last run miniapp/client/index.html, …`。 node tests/run.mjs 57/57。 * 钉住跨启动快照的三处退化 一个比对可以被三种方式悄悄去掉,而这三种读起来都完全正常:把读的那一行删掉(快照 从此只是写)、把写的条件改回「已存在时才写」(全新检出永远没有基线)、把记的内容 换回起点(等于什么都没记)。 verify-review16.mjs 加 7 条断言,mutate-review16.py 加 5 条变异,全捕。 其中两条值得单说,因为它们是「只有比较两个名字才发现」的那一类: - 把两条消息**改成同名**。`shipped files restored` 与 `shipped files differ from the last run` 合成一个名字,读者就无法分辨是哪一次运行弄脏的源码 —— 而这两种情况的 处置完全相反:一个是本次运行的守卫失败,另一个是启动时就带着上一轮的残留。 - 把快照改成**记起点**。起点与终点只在没有变脏时相等,所以这一条在每一次干净的运行 上都与正确版本没有区别。 另外三条分别是:不读快照、把逐文件比对换成常量、把「只在干净时写」改成总是写。最后 一条是唯一会**把问题藏两次**的 —— 记一个变脏的终点,下一次比对就与变异意见一致了。 断言里有一条值得单说:「缺失或读不出来的基线既不算差异也不算失败」。没有基线的是全新 检出,拿它当「全都不一样」会让每一次首跑都报警,拿它当失败会让每一个新克隆都红。 verify-review16.mjs 34 -> 41 assertions. mutate-review16.py 32 -> 37 mutations, all caught. node tests/run.mjs 57/57. * The top's arrival test cannot be a zero-tolerance comparison Reported in review, and it holds: at the top of the ledger the arrow pointed up and a second press moved the page by zero pixels. The landing and the test for it read one number, and the browser does not keep it. The offset is answered in 1/64px by getBoundingClientRect(); scrollTo() lands on a whole pixel, rounded to nearest. Landing at 503.7656 therefore puts the reader at 504, and a strict 「greater than 503.7656」 reads that as 「there is more above」. Measured across viewport widths the gap never reached half a pixel — and it decided the outcome. The same code froze the control at 1300, 1440, 1100 and 760, and behaved at 897, because 897's offset lands on a fraction below the nearest integer rather than above it. A defect that only some widths produce is harder to recognise as one than a defect that always happens. Rounding the offset up is what 「arrived」 means, rather than a tolerance chosen to cover the measurement. Rounding down fails the moment the browser rounds up; rounding to nearest is right only for the rule in force today, and nothing in a green run would say otherwise. Up holds for all three. The bottom end is deliberately left alone. Its landing is the end at the top of the viewport while its test asks whether the end is still below the fold — a whole viewport apart, so no rounding error reaches across it — and its offset happened to be a whole number at every width tried. That is why the two ends are treated differently rather than symmetrically. * Pin the arrival boundary, including the assertion that was pinning the bug The first version of this file asserted the zero-tolerance form as if it were the rule: /var canUp = y > ledgerTopOffset\(\);/ so 57 assertions were green and one of them was holding the defect in place. That is the other half of 「a guard that cannot fail is not a guard」 — a guard can pass while naming the wrong thing as right, and nothing in a green run distinguishes the two. So this fix is not only to add assertions. It is to take back the one that was asserting the bug. verify-ledger-jump.mjs 57 -> 61 assertions. Four new ones: the zero-tolerance form cannot come back; the boundary is rounded UP, with floor and nearest both rejected — nearest rejected even though it is the rounding this browser actually does; the bottom test is left alone; and the landing keeps the un-rounded offset, so the same fix cannot be reached a second way by rounding where a jump ends instead of how 「arrived」 is read. mutate-ledger-jump.py 53 -> 58 mutations, all caught; file restored byte for byte and green again afterwards. Two of the five are worth naming. Dropping the boundary is the defect itself, and the only one here a reader could walk back into by accident: the shorter form reads as the obvious way to ask 「is there more above」, and nothing in the source says a landing does not land where it was aimed. Changing it to Math.round is kept precisely because it is CORRECT on this browser — it holds only for the one rounding rule in force, and a green run says nothing about that. This is also why what is pinned is 「up」 rather than 「a rounding that works」. The probe had to change too, for the same shape of reason. Its reader carried its own copy of the predicate under test, so the first run after the fix reported the landing as 「still more above」 while the product had already answered no — it was measuring its own copy, which would never have moved no matter what shipped. Verdicts now read the shipped function's own answer, and the old form is kept in the output only as a contrast. node tests/run.mjs 57/57. Fixture check OK.
1 parent 40f8627 commit 2a4d21c

8 files changed

Lines changed: 802 additions & 213 deletions

File tree

‎plugins/avatasia/mmc-trajectory/miniapp/client/index.html‎

Lines changed: 212 additions & 95 deletions
Large diffs are not rendered by default.

‎plugins/avatasia/mmc-trajectory/miniapp/node/server.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ const GENERATION_PROBE_BYTES = 64 * 1024;
3838
* costs accuracy, not correctness — 256 KB was reading four times what the label needed, on every
3939
* cache miss.
4040
*
41-
* The last clause used to end "for counts nobody treats as exact", and that is what D45 found
42-
* false: the page printed the message count in the session picker (`(3 分钟前 · 10 条)`), so it
41+
* The last clause used to end "for counts nobody treats as exact", and that is what turned out
42+
* to be false: the page printed the message count in the session picker (`(3 分钟前 · 10 条)`), so it
4343
* was treated as exact. On a session whose active file is 1 MB, the first 64 KB held 10 messages
4444
* out of 23,642 — a number that was wrong by three orders of magnitude and carried no hint of it.
4545
* So the prefix stays bounded and the counters it buys are only REPORTED when the prefix was the

‎plugins/avatasia/mmc-trajectory/tests/mutate-cross-layer.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
("the window bar is presented outside the ledger panel",
3434
' <div class="window-bar" id="window-bar" aria-label="列表视图控制"></div>\n'
3535
' <div class="ledger-body" id="ledger"></div>\n'
36-
' <!-- D44: the two ends of the ledger, and the one reading that says which end the reader is\n'
36+
' <!-- The two ends of the ledger, and the one reading that says which end the reader is\n'
3737
' at. See `.ledger-end` and `.ledger-jump` for why the sentinel is outside #ledger. -->\n'
3838
' <div class="ledger-end" id="ledger-end" aria-hidden="true"></div>\n'
3939
' <!-- No text and no glyph in the markup: buildLedgerJump() fills the arrow in, and the\n'
@@ -43,7 +43,7 @@
4343
' <button type="button" class="ledger-jump" id="ledger-jump" hidden aria-label="跳到轨迹的最低"></button>\n'
4444
' </section>',
4545
' <div class="ledger-body" id="ledger"></div>\n'
46-
' <!-- D44: the two ends of the ledger, and the one reading that says which end the reader is\n'
46+
' <!-- The two ends of the ledger, and the one reading that says which end the reader is\n'
4747
' at. See `.ledger-end` and `.ledger-jump` for why the sentinel is outside #ledger. -->\n'
4848
' <div class="ledger-end" id="ledger-end" aria-hidden="true"></div>\n'
4949
' <!-- No text and no glyph in the markup: buildLedgerJump() fills the arrow in, and the\n'

‎plugins/avatasia/mmc-trajectory/tests/mutate-ledger-jump.py‎

Lines changed: 223 additions & 59 deletions
Large diffs are not rendered by default.

‎plugins/avatasia/mmc-trajectory/tests/mutate-review16.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,21 @@
157157
"const key = ids.join('" + chr(0) + "');",
158158
"no shipped source file carries a NUL byte"),
159159

160+
# The other half of the same defect: the numbering on its own, with no file name for the first
161+
# check to match against. These were labels on comments that explain themselves one line later,
162+
# so no reader goes looking -- which is the reason they need a check rather than an argument.
163+
# The layer numbers (L0-L6) are the same leak from the same missing document, so one pattern
164+
# covers both and the assertion names them together.
165+
("a private defect number comes back into a shipped comment",
166+
"/* ---------------- the ledger's two ends ---------------- */",
167+
"/* ---------------- D44: the ledger's two ends ---------------- */",
168+
"no shipped file carries a private defect or layer number"),
169+
170+
("a private layer number comes back into a shipped comment",
171+
" * read rather than re-measured here: the band is filters, the overview is the strip, and",
172+
" * read rather than re-measured here: band is L2 and overview is L3, and",
173+
"no shipped file carries a private defect or layer number"),
174+
160175
# ---- 5. the cache stops being released ----
161176
# Anchored without any line ending on purpose. The first version of these two used \r\n because
162177
# server.mjs is CRLF in the working tree — and that is exactly the trap: it only matched because
@@ -217,6 +232,44 @@
217232
'"version": "1.1.0",',
218233
'"version": "1.2.0",',
219234
"the English README carries the shipped version"),
235+
236+
# ---- the snapshot the last run left, and the run that never looked at it ----
237+
# All five break the cross-start check in tests/run.mjs, which existed on paper and did nothing:
238+
# the snapshot was written only when it already existed, recorded the run's start rather than
239+
# its end, and was never read. A hard kill mid-mutation therefore left the source mutated and
240+
# the next run said nothing about it -- it reported whatever guard happened to read the mutated
241+
# file, which reads as that guard being broken.
242+
243+
("the harness never reads the snapshot it writes",
244+
"readFileSync(SNAPSHOT, 'utf8')",
245+
"'{}'",
246+
"the harness reads the snapshot the previous run wrote"),
247+
248+
("the startup check stops comparing per file",
249+
"previous[rel] !== undefined && previous[rel] !== digest(rel)",
250+
"false",
251+
"the startup comparison is per file, against the digest that was recorded"),
252+
253+
# The old form: record the state the run STARTED with. Identical to the end state whenever
254+
# nothing drifted, and a record of the mutation whenever something did.
255+
("the snapshot records the state the run started with",
256+
"writeFileSync(SNAPSHOT, JSON.stringify(Object.fromEntries(SHIPPED.map((rel) => [rel, digest(rel)]))));",
257+
"writeFileSync(SNAPSHOT, JSON.stringify(snapshotBefore));",
258+
"the snapshot records the state the run ended with, and only a clean end"),
259+
260+
# Recording a drifted end is the one that hides the thing twice: the next run compares against
261+
# the mutation and agrees with it.
262+
("the snapshot is written even when this run dirtied the source",
263+
"if (drift.length === 0) {",
264+
"if (drift.length >= 0) {",
265+
"the snapshot records the state the run ended with, and only a clean end"),
266+
267+
# The two findings collapse into one line. Reads fine, costs the reader the only thing that
268+
# distinguishes "this run broke it" from "it was already broken".
269+
("last run's residue is named exactly like this run's",
270+
"'shipped files differ from the last run'",
271+
"'shipped files restored'",
272+
"this run's own residue and last run's residue are named differently"),
220273
]
221274

222275

‎plugins/avatasia/mmc-trajectory/tests/run.mjs‎

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ const filter = args.find((a) => !a.startsWith('--')) ?? '';
3030
// rewrite are hashed before the first control runs and checked after the last one. A mismatch is
3131
// reported as a failed guard of its own, with the restore command, instead of being inferred from
3232
// whatever the next person sees.
33+
//
34+
// That catches a control that failed to restore DURING a run. A run that was killed outright writes
35+
// nothing, so its residue can only be seen by comparing against the state the run BEFORE it ended
36+
// with — which means a snapshot on disk, read at startup, and a report that is deliberately not a
37+
// verdict. The two are separate findings: the drift below says "this run dirtied the source", the
38+
// warning at startup says "the source was already different when this run began".
3339
// The shipped files a mutate-* control rewrites in place — that is the whole membership rule, and
3440
// it is a rule about files rather than about importance. docs/LAYERS.md sat in this list until it
3541
// moved out of the package to the workspace root: it then hashed to 'absent' on every run, which
@@ -55,10 +61,52 @@ const digest = (rel) => {
5561

5662
const snapshotBefore = Object.fromEntries(SHIPPED.map((rel) => [rel, digest(rel)]));
5763

64+
// What the previous run left behind. The snapshot this reads was written by that run and meant the
65+
// bytes it ended with, so a file that differs now is one of two things — a mutate-* control killed
66+
// mid-run, or somebody edited the plugin after that run — and this suite cannot tell them apart.
67+
// Both were once silent: nothing read the snapshot at all, so the residue surfaced only as unrelated
68+
// red in whatever guard happened to read the mutated file, which reads as that guard being broken
69+
// rather than as the checkout being dirty.
70+
//
71+
// Reported, not failed, and that is a decision rather than a dodge. The suite runs on machines with
72+
// uncommitted work; failing on "these files are not what the last run left" would report a
73+
// contributor writing code as a broken checkout, and the fix for that — trusting git — is exactly
74+
// what is unavailable in an unpackaged copy. So the two states are named separately and only one
75+
// of them is a verdict: this is a line above the results, the drift below is a failed guard.
76+
const readSnapshot = () => {
77+
try {
78+
return existsSync(SNAPSHOT) ? JSON.parse(readFileSync(SNAPSHOT, 'utf8')) : null;
79+
} catch {
80+
// A missing or unreadable baseline is not a reason to stop. Without one there is nothing to
81+
// compare against, which is the state a fresh checkout is in, and that is a normal first run.
82+
return null;
83+
}
84+
};
85+
86+
const previous = readSnapshot();
87+
const leftover = previous
88+
? SHIPPED.filter((rel) => previous[rel] !== undefined && previous[rel] !== digest(rel))
89+
: [];
90+
91+
if (leftover.length) {
92+
console.log('');
93+
console.log(`WARN ${'shipped files differ from the last run'.padEnd(30)} ${leftover.join(', ')}`);
94+
console.log(' either a mutate-* control was killed mid-run and left the source mutated,');
95+
console.log(' or these files were edited after that run. Not a failure: this suite runs on');
96+
console.log(' machines with uncommitted work, and a contributor writing code is not a broken');
97+
console.log(' checkout. The results below are only meaningful if you know which one it was.');
98+
console.log(' if you did not just edit them: git checkout -- <file>');
99+
}
100+
58101
function checkShipped() {
59102
const drift = SHIPPED.filter((rel) => digest(rel) !== snapshotBefore[rel]);
60-
try { existsSync(SNAPSHOT) && writeFileSync(SNAPSHOT, JSON.stringify(snapshotBefore)); }
61-
catch { /* the snapshot is a convenience, never a reason to fail the suite */ }
103+
// Only a clean end becomes the baseline for the next run. Recording a drifted end would make the
104+
// next comparison agree with the mutation, which is the one thing this file exists to prevent.
105+
if (drift.length === 0) {
106+
try {
107+
writeFileSync(SNAPSHOT, JSON.stringify(Object.fromEntries(SHIPPED.map((rel) => [rel, digest(rel)]))));
108+
} catch { /* the snapshot is bookkeeping, never a reason to fail the suite */ }
109+
}
62110
return drift;
63111
}
64112

@@ -131,7 +179,8 @@ const drift = checkShipped();
131179
if (drift.length) {
132180
console.log('');
133181
console.log(`FAIL ${'shipped files restored'.padEnd(30)} ${drift.join(', ')}`);
134-
console.log(' a mutate-* control was killed mid-run and left the source mutated.');
182+
console.log(' a mutate-* control ran to completion and left the source mutated. This run\'s own');
183+
console.log(' residue, not the one the warning above reported.');
135184
console.log(' restore with: git checkout -- <file>');
136185
failed.push({ name: 'shipped files restored byte for byte', ok: false });
137186
}

0 commit comments

Comments
 (0)