@am_shork/attest 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -13,6 +13,514 @@ input, removes/renames a public API or `--json` field, or changes a default
13
13
  runtime behavior an existing invocation relies on — diagnostic message text is
14
14
  not API.
15
15
 
16
+ ## [0.7.4] - 2026-08-08
17
+
18
+ ### Added
19
+
20
+ - **`ATX-37`'s three unattested sites are closed, and the sweep that was going
21
+ to close them is rejected instead.** The requirement says *everything* the CLI
22
+ writes to a terminal replaces the control characters in text it quotes from
23
+ the project under test, and the `QUANTIFIED` table had carried four `null`
24
+ rows against it since `[0.7.0]`: the status rows, the coverage table, the
25
+ archive verdict, and the path `--out` echoes. Three of those are formatters
26
+ interpolating a reqId, a change name or a written path — correct by reading,
27
+ attested by nothing, which is the state a `sites` quantifier is the only kind
28
+ able to reach in silence. Three scenarios now drive them with hostile input,
29
+ covering six interpolations, and each was **proven able to go red by injection
30
+ before being believed**: `inline` removed from the status row's id, from the
31
+ merged path, from the archive verdict's change name, and from the coverage
32
+ row's id — four defects, each failing exactly one scenario and no other. The
33
+ fourth `null` stays: `--out` needs a filename holding a control character,
34
+ which NTFS refuses and Linux permits, and an unmeasured half is what a `null`
35
+ records.
36
+ **A fifth injection is why the archive verdict is asserted three times.**
37
+ Reading the diff caught what the site list does not resolve to: `change` is
38
+ interpolated by three separate expressions in that one function — merged,
39
+ archivable, and gate-failed — and the first draft covered two of them, leaving
40
+ `inline` deletable from the third with the whole suite green. A `sites` row
41
+ names sites at the granularity a sentence uses, and a branch is finer than
42
+ that; the injection is what tells the two apart.
43
+ **Re-measuring the proposal before building it is what split it in two**, and
44
+ the figures moved in both directions. `src/cli/report.ts` has 8 exports of
45
+ which **7** are `format*` — the eighth is `summarize`, so the entry's "eight
46
+ formatters" was already one off its own wording. The "eight fixtures, one per
47
+ signature" was worse: there are 7 distinct parameter types, `Issue[]` is
48
+ shared by three functions, and the honest cost of the fix turned out to be two
49
+ fixtures extending ones `self/report.spec.ts` already builds. And the entry
50
+ **undercounted its own benefit** — "three currently-correct sites" is three
51
+ *formatters* carrying six interpolations.
52
+
53
+ - **The `✗` samples are bound to a fixture whose rejection the suite asserts.**
54
+ A page showing input the engine must *reject* was the one kind of sample
55
+ nothing could see going stale: it carries no version, names no `src/` symbol,
56
+ and keeps its page's structure perfectly, so every gate in
57
+ `tests/docs-consistency.spec.ts` passed while the claim underneath it aged.
58
+ The failure that mattered is asymmetric — a stale runnable sample stops
59
+ working when a reader pastes it, while a stale counterexample is a reader
60
+ taught a rule that is no longer a rule, which is worse than teaching nothing.
61
+ A `✗` annotated with a code the engine can emit is now bound to a fixture,
62
+ byte-equal the way `fixtures/consumer/` is bound to the README, and the
63
+ fixture is **run through the reader the documented command uses** — the code
64
+ and the line it produces are compared with the code and the line the page
65
+ shows. The line is the half that catches the shape a code-only assertion would
66
+ not: a fixture still rejected, for a different reason, having quietly stopped
67
+ demonstrating what it is quoted for.
68
+ **Re-measuring the entry before building it moved its cost by a factor.** It
69
+ was filed as the expensive half, "a fixture per diagnostic". Of the 33 fenced
70
+ `ts` blocks in the README and `docs/en/**`, 8 carry a `✗`, 3 name a code the
71
+ engine can emit, and 1 of those 3 is elided (`duplicate-prefix`, taught as two
72
+ one-line registries whose bodies are `{ /* … */ }`) — leaving one sample,
73
+ quoted on two pages, bound to one fixture. The count was never the reason it
74
+ sat: what it was really waiting for is the thing that makes the roster hold,
75
+ which is that the binding is a **sweep** rather than a list. A new `✗ code`
76
+ block on any English page is bound to a fixture or fails as `unbound`, so the
77
+ next diagnostic pays a fixture because it is obliged to, not because someone
78
+ remembered the roster existed.
79
+ **Proven able to go red before being believed**, since a gate over samples
80
+ that are all currently correct has never failed and cannot be trusted until it
81
+ does: four mutations, one per way the binding can rot — the page edited away
82
+ from the fixture, the fixture made static so the engine accepts it, a `✗`
83
+ sample added bound to nothing, and the defect moved to a line the annotation
84
+ does not mark. Each was reported naming the page or the fixture.
85
+ *Two limits, both structural.* **English only**: `docs/zh/**` matches
86
+ `docs/en/**` down to the comments inside its samples, so byte-equality is a
87
+ claim only one side can carry, and what covers the Chinese side is the parity
88
+ gate from `[0.4.3]` holding the code-sample sequences equal — structure, not
89
+ meaning. And an **elided block is excluded by rule**, not by omission from the
90
+ roster: `{ /* … */ }` is what makes that sample readable and also what makes it
91
+ unrunnable. The residual risk recorded in `[0.4.3]` does *not* apply here —
92
+ the byte gate and the run proving the fixture still fails are in the same
93
+ file, so a fixture that stopped being rejected cannot pass by living in a
94
+ suite nobody connected to the page.
95
+
96
+ - **`ATX-15` is swept over `src/` from the AST, rather than attested one site at
97
+ a time.** The requirement quantifies over *sites* — every ordering committed to
98
+ disk or compared for equality — and `sites` is the one shape the quantifier
99
+ table records as able to stop being true in silence: a new site is a new
100
+ obligation and nothing enumerates them. Two of its five sites were `null`:
101
+ `red-record.ts` sorts ids and scenario names into `first-run.json`, and
102
+ `splice.ts` sorts ids and param keys into the registry file `--apply` writes.
103
+ Both are committed to disk, both were correct, and neither had a scenario. A
104
+ scenario per site would have closed those two and left the shape untouched,
105
+ because site six would have been unattested the day it was added. The sweep
106
+ instead makes the roster one the **code** owns — every `.sort`/`.toSorted` in
107
+ `src/` is a member — so a new ordering site is covered by construction. The
108
+ sanctioned comparator, the sort methods and the banned APIs are `ATX-15`
109
+ params, read by the scenario, pinned against literals for the reason the
110
+ domain table states: shrinking a list a scenario iterates removes a case and
111
+ everything left still passes.
112
+ **Stated narrowly enough to be true: it constrains how a site orders, not that
113
+ it orders.** A `red-record.ts` that stopped sorting altogether is still green
114
+ here, and the three hand-written scenarios are what speak to the result. That
115
+ is the honest limit of what a sweep can prove, and the table records it rather
116
+ than letting the row read as fully discharged.
117
+ **It was proven able to go red against the real corpus before being believed** —
118
+ three injected defects, one per shape the rule admits: a `localeCompare`
119
+ comparator in `red-record.ts`, a bare `.sort()` in `splice.ts`, and a
120
+ hand-rolled comparator in `merge.ts` that never reaches `byCodeUnit`. Each was
121
+ reported with its file, line and offending source. A gate asserting a property
122
+ that already holds everywhere has never been red and cannot become so until it
123
+ fires, so a negative control is not optional for it; a second scenario keeps
124
+ one in the suite permanently, including the case a line-wise scan gets wrong.
125
+ **Re-measuring the proposal's figures before building it changed two of
126
+ them**, which is the section's own rule and the reason it is worth restating:
127
+ the entry claimed 21 `.sort` call sites (there are **20** — the 21st was a
128
+ comment, which a regex counts and an AST does not), and two exceptions to
129
+ "names `byCodeUnit` on the call line" (there are **six**: one delegating to a
130
+ local `compareIds`, five inline arrows). The exception count being 3× what was
131
+ written is what made following local delegation part of the rule rather than a
132
+ footnote. The `localeCompare` figure moved the same way and in the direction
133
+ that matters most: **both occurrences in `src/` are comments explaining why it
134
+ is not used**, so a line-wise regex would have opened at 100% false positives.
135
+ Reading from the AST was the entry's own recommendation; it turns out to have
136
+ been the difference between a gate and a nuisance.
137
+ One shape the rule admits was not in the proposal and is worth naming:
138
+ `report.ts` sorts by `b.count - a.count || byCodeUnit(…)`. A numeric term is
139
+ legitimate, so the rule is "every comparator **reaches** the sanctioned one",
140
+ never "sorts only by code unit" — a stricter rule would have been red on
141
+ correct code the day it was written.
142
+
143
+ ### Fixed
144
+
145
+ - **`attest check` answered `✓ No issues.` for a `*.reqs.ts` that does not
146
+ compile.** TypeScript's parser is error-tolerant by design: handed a file
147
+ truncated before its closing `});` it recovers the object literal and returns a
148
+ tree anyway, so the static reader read it as a *complete* registry and reported
149
+ nothing at all — while `--eval` on the same bytes said `registry-invalid`. Two
150
+ readers of one file disagreeing about whether it is even a file is the
151
+ condition the differential suite exists to prevent, and that suite could not
152
+ have caught it: it runs over registries that all compile. Nothing was looking
153
+ at `parseDiagnostics`, which is the one field that says so.
154
+ **The reach is three readers, not one**, because the tolerance belongs to the
155
+ compiler rather than to any reader: `parser.ts`, `static-registry.ts` and
156
+ `splice.ts` all open with the same `createSourceFile` call. The same half-saved
157
+ file was also a spec whose scenarios above the break still counted as coverage,
158
+ a delta whose ids `status` still reported, and — the site that writes rather
159
+ than reports — an insertion offset `attest apply` would have spliced a new
160
+ requirement into. All four now refuse it: the three readers as
161
+ `unreadable-file`, and the insertion point by returning the `undefined` it
162
+ already documents as its refusal to guess, because a write path that throws
163
+ mid-merge is worse than one that declines.
164
+ The diagnostic carries the **line** the syntax error is on, through both
165
+ shapes of refusal — the readers return it, and the spec parser carries it
166
+ through the throw its guard already catches. Without that second half the same
167
+ code would have had a line for a registry and none for a spec, for one
168
+ condition.
169
+ `unreadable-file` rather than a new code: `ATX-65` already states this
170
+ obligation for a file the parser cannot read, and the reader's situation is
171
+ identical — nothing in the file was checked, everything else in the run still
172
+ was. What `ATX-65` did not state is the *detection*, which is the whole of what
173
+ was missing; it was written for a parser that throws, and a parser that quietly
174
+ succeeds needs a different question asked of it.
175
+ **Syntactic only, and that is what makes it safe rather than merely narrow.**
176
+ `parseDiagnostics` is the parser's own list, so a type error, an unresolved
177
+ import and a name that does not exist are all absent from it — Attest does not
178
+ typecheck an adopter's project and this does not start. Measured over the 129
179
+ TypeScript files in this repository with every fixture included: zero carry a
180
+ parse diagnostic, so nothing valid today starts failing. The one reader left
181
+ deliberately tolerant is the id recovery added above, which runs only on files
182
+ a reader has already refused.
183
+
184
+ - **Two scenarios named by a template literal were invisible to the static
185
+ plan.** `self/half-loaded.spec.ts` generated one scenario per command, and the
186
+ parser reads names from the AST — so `cover` counted one scenario where two
187
+ ran, and `archive`'s never-red gate could never have seen the second. Found
188
+ while binding the new requirement's roster to a scenario the plan declares.
189
+ Both are now one scenario looping the roster, which is the shape `ATX-64`
190
+ already used; the label on each `expect` is what the generated name was buying,
191
+ and it survives a failure. **The engine is not changed**: a name a static
192
+ reader cannot resolve is not a defect in the reader, and the failure it
193
+ produces on a requirement covered *only* that way is `uncovered-requirement`,
194
+ which is loud.
195
+
196
+ ### Changed
197
+
198
+ - **One unreadable `*.reqs.ts` reported a finding per scenario that attested it,
199
+ and the finding was about the file.** The ids that file declares are missing
200
+ from the run, so every scenario covering one of them was an `orphan-test` —
201
+ measured on a registry of three requirements: six ERRORs, of which one was the
202
+ cause and five were its shadow, with the true finding at the top where the wall
203
+ of red pushes it out of view. Those scenarios are now reported once per
204
+ registry file as `orphan-from-failed-registry` (WARNING), naming the file, the
205
+ count and the ids; the same case is now two ERRORs and one WARNING. A WARNING
206
+ because the load failure is already an ERROR and is present whenever this is,
207
+ so no verdict moves and this cannot make a broken registry pass.
208
+ **What unblocked it was that the question was framed wrongly**, and the entry
209
+ under `Under consideration` had carried that framing for two releases: it asked
210
+ for a prefix→file mapping, which is exactly what a file that never parsed
211
+ cannot give — and could only have been recovered from the file *name*, which
212
+ this repository is its own counterexample to (`attest.reqs.ts` holds `ATX-*`,
213
+ the evidence that killed the prefix-matches-filename rule). The **ids** are
214
+ written in the source, and reading them back needs no naming convention that
215
+ nothing enforces. It is also strictly more precise than the prefix would have
216
+ been: a genuine typo sharing a prefix with the broken file stays reported.
217
+ Recall is partial and the direction of the miss is why that is acceptable — an
218
+ id not recovered keeps its `orphan-test`, so the report stays noisy and never
219
+ wrong. Measured over the ways a registry fails: a truncated file, a missing
220
+ default export, a non-literal value, a schema-invalid entry, a module that
221
+ throws at import and a spread from another module all yield their ids; only a
222
+ registry the file *computes* yields none, and that source contains no id to
223
+ find. The recovery is a source read that returns ids and nothing else — it
224
+ builds no registry, so it is not a second reader and cannot become the
225
+ evaluation fallback `registry-not-static` refuses.
226
+ Stated as a replacement rather than a suppression for the reason `ATX-62`
227
+ records about `spec-load-failed`: suppressing with nothing in its place trades
228
+ a wrong message for silence, and a wrong message beats silence.
229
+
230
+ - **The hedged `orphan-test` advice added in `[0.7.0]` was applied to every
231
+ orphan in the report, including the ones it does not describe.** Any registry
232
+ file failing to load switched *all* of them to "a registry file failed to load
233
+ — fix that first, and add this id only if it is still unknown afterwards",
234
+ so a genuine typo was told to wait for work that has nothing to do with it.
235
+ The hedge now fires only where the uncertainty is real: when a failed file's
236
+ ids could not be recovered. When they all could, an orphan none of them claims
237
+ is genuinely unknown and gets the plain advice back.
238
+
239
+ `SCHEMA_VERSION` is unchanged: a new `code` value is additive, and no envelope
240
+ field moved.
241
+
242
+ ## [0.7.3] - 2026-08-07
243
+
244
+ ### Fixed
245
+
246
+ - **A loader that never opened left its scratch directory behind — one per
247
+ `attest` invocation, i.e. one per CI build.** `createLoader` creates the
248
+ directory and writes the stub, and the only `rm` of it lives inside the `close`
249
+ of the object it returns — so nothing between those two points can be cleaned
250
+ up by anyone, because until the caller holds that object no `close` exists to
251
+ call. It is the exact leak the comment on `close` records as already fixed,
252
+ surviving on the other side of the same function. The window is now closed by
253
+ the function that opened it.
254
+ *Stated over the window rather than over `createServer`, which is where it was
255
+ found.* `writeFileSync` is inside it too, and a full disk or a temp directory
256
+ that turns read-only between the two calls reaches it with no project
257
+ involved — so scoping the repair to the call it was noticed at would have left
258
+ half of it.
259
+ *The trigger this was filed on does not reproduce, and that is the useful
260
+ half.* The entry proposed a malformed `package.json` at the root, on the
261
+ reasoning that `configFile: false` still leaves Vite reading it for `type`.
262
+ Measured: it starts a server cleanly, as do eight other shapes — a
263
+ `package.json` holding an array, one that is a directory, an unknown `type`, a
264
+ `node_modules` occupied by a file, a `node_modules/.vite` occupied by a file, a
265
+ `.env` that is a directory, a deleted root, and a well-formed control. Nine
266
+ candidates, zero throws. **`configFile: false` is why**, and it is worth
267
+ recording as a property rather than an accident: the option set chosen to stop
268
+ the loader reading a project's Vite config, and to stop it opening a socket,
269
+ also leaves almost no project I/O that can fail hard. The throw surface was
270
+ closed as a side effect of decisions taken for other reasons.
271
+ *So the repair is justified by ownership rather than by a symptom*, which is a
272
+ weaker case than this file usually accepts and is why the alternative was to
273
+ reject the entry outright. What decides it is the asymmetry of cost: the fan-out
274
+ entry below stays open because bounding concurrency changes how every run
275
+ behaves, whereas a `try` around a window nothing currently reaches changes
276
+ nothing observable on any path that exists. A resource whose ownership transfer
277
+ is conditional on nothing throwing is a shape, and the shape is what recurs.
278
+ *A cleanup that fails must not replace the error that caused it*, so the `rm`
279
+ swallows its own failure and the original throw propagates unchanged. The
280
+ regression test asserts the identity of what comes out, not its wording.
281
+ **`close` had the same hazard and now makes the same choice, which is a change
282
+ to an existing path rather than a new one.** Its `rm` runs in a `finally`, so a
283
+ failed cleanup replaced whatever `close` was doing — and all five callers in
284
+ `pipeline.ts` call `close` from a `finally` of their own, where the damage is
285
+ larger than symmetry: a real error on the way out is lost, and a *successful*
286
+ command becomes a crash about a temp directory. What that reader is handed is a
287
+ misdiagnosis pointing at their own correct files, which this project already
288
+ treats as worse than the gap it fills — the argument `ATX-49` records against
289
+ reporting a load failure as a wall of skipped scenarios, in a much smaller
290
+ place. Given up with it: the one signal a failed cleanup could have raised,
291
+ which no caller could have acted on.
292
+ *The test goes red without the fix, which is the only reason it is worth
293
+ having.* Verified by reverting `src/` and re-running: `expected [
294
+ 'attest-loader-z7jhoK' ] to deeply equal []`, the leaked directory named in the
295
+ failure. It injects the throw at the module boundary — no project input could
296
+ produce one — and mocks `tmpdir` to a directory of its own, because on the
297
+ throwing path there is no loader to ask for its `scratchDir` and counting
298
+ `attest-loader-*` in the shared temp directory is the flake the lifecycle test
299
+ beside it already documents.
300
+ *It is its own file*, which is a rule this repo already wrote down and which
301
+ the first draft broke: `tests/locate-fanout.spec.ts` is separate because
302
+ `vi.mock` is file-scoped and it mocks a Node builtin, and the four lifecycle
303
+ tests boot the *real* Vite loader — sharing a module registry with a
304
+ `createServer` that throws is exactly what that convention exists to prevent.
305
+ The first attempt scoped the mocks with `doMock` and `resetModules` inside the
306
+ shared file instead, which is machinery bought to work around a constraint the
307
+ repo had already decided how to solve. Following it deleted the machinery.
308
+ *No new requirement, and the precedent cuts the other way, so it is worth
309
+ saying why.* `ATX-36` is the sibling property — what a live loader leaves on the
310
+ user's machine, stated as a count, with a scenario in `self/` — and "leaves no
311
+ scratch directory" is the same shape. What decides against filing one here is
312
+ that the success-path half has never had a requirement either, so a new one
313
+ covering only the throwing path would state half a contract and cost a
314
+ permanent scenario for the half nothing reaches. If it is ever filed it should
315
+ be filed whole, and that is a larger change than this fix.
316
+
317
+ - **The `cmd.exe` guard in `tests/consumer.spec.ts` checked one character of a
318
+ class, and the comment above it claimed the quoting was doing work it cannot
319
+ do here.** `shellArg` asserted the argument held no `"` and then quoted only
320
+ when it held whitespace, so an argument with neither went to the shell bare
321
+ with `&`, `|`, `^`, `<`, `>`, `(` unexamined. Measured against the twelve
322
+ shapes: the old assertion let **11 of 12** through.
323
+ *What decided it, and it is the thing the entry this replaces did not have.*
324
+ That entry framed the choice as "widen the class, or fix the prose", and
325
+ argued that widening while leaving the prose is the change that looks like a
326
+ fix and settles nothing. Both halves turn on a fact neither named: **every
327
+ command this suite spawns is a `.cmd`**, which is the whole reason
328
+ `NEEDS_SHELL` exists, and a batch file expands `%*` into its own line *before*
329
+ anything parses it — so a metacharacter survives double quotes and is acted on
330
+ at the far end. That is the argument-injection hole Node closed in 22.x,
331
+ arrived at the long way round. So the quoting is not a defence that the
332
+ assertion merely backstops; **the assertion is the only defence**, and a
333
+ defence that covers one character of its class is the defect rather than the
334
+ prose being generous. Both are fixed together: the class is named, and the
335
+ comment now says the quotes do whitespace and nothing else.
336
+ *`%` and `!` are in the class for a different reason* — expansion rather than
337
+ syntax, and `!` only under delayed expansion, which the spawned shell does not
338
+ enable but a batch file can enable for itself. Neither costs anything to
339
+ refuse.
340
+ *The cost, which is real and is the right cost.* Windows permits `&`, `(` and
341
+ `%` in a user name, and `os.tmpdir()` sits under it — so on such a machine this
342
+ suite now stops with *"argument would need escaping, not quoting"* instead of
343
+ running. That is correct rather than a false red: through a batch target those
344
+ arguments genuinely are not safe, and the invariant the assertion guards is
345
+ that every argument is a literal flag or a path from `mkdtemp`. Stopping is
346
+ right even where quoting would have survived it, because the value is then not
347
+ the kind of value the helper accepts.
348
+ *What is measured, and what is not.* `pnpm test:consumer` was run on Windows,
349
+ where `NEEDS_SHELL` is true and the changed path actually executes: 11 tests
350
+ passed, none skipped — the distinction that matters, since the failure this
351
+ file's own comment records reported ten tests as *skipped* at zero run. CI is
352
+ Linux, where `shellArg` returns before the assertion, so the pipeline says
353
+ nothing about this either way. Not measured end to end: an argument carrying a
354
+ metacharacter actually reaching the guard, because no call site can produce
355
+ one — the class is verified as a predicate over the twelve shapes above and
356
+ the sixteen arguments the suite really passes, not by driving the suite red.
357
+
358
+ - **A statement or rationale can no longer become HTML, or open a section of
359
+ its own.** `sanitised` is the entry §9.1 names and it strips control
360
+ characters and nothing else, so `statement` and `rationale` reached the
361
+ Markdown by concatenation exactly as written. Measured: a `rationale`
362
+ containing a newline and `## ATX-999` rendered a whole section — heading,
363
+ statement, `**Why:**` — for a requirement no registry holds, sitting between
364
+ two real ones and worded identically, with the overview table above it listing
365
+ three ids and the body carrying four sections. `<script>` and `<img onerror>`
366
+ survived the same way into a file that is committed and served. `check` and
367
+ `cover` saw nothing, because the id is in no registry, and `render --check`
368
+ could not object, because that *was* what the registry rendered to.
369
+ *Which of the three is intended, which is the part the fix had to decide.*
370
+ Marking prose up stays — `plain` escapes a param value precisely because the
371
+ value is data and the sentence around it is not — and that decision had never
372
+ been separated from allowing raw HTML or a heading. It is now a **fifth
373
+ obligation in design §9.1** rather than a patch at an emitter: prose may be
374
+ marked up, it may not become a tag, and it may not open a section. Stated as a
375
+ count over the document — one heading per requirement id — because what a
376
+ reader trusts is that a section means a requirement (`ATX-67`).
377
+ *Escaped rather than refused, on an asymmetry.* A rule that misses something
378
+ ships the document it ships today; a rule that fires wrongly makes a
379
+ legitimate rationale a hard error in the one command whose value is that it
380
+ always produces the document — the `ATX-29` argument about `--check` seen from
381
+ the other side. So there is no new diagnostic and no exit code moves.
382
+ *And escaped for the position, which is what keeps it lossless.* A code span
383
+ is literal text in every renderer, so `&lt;` written inside one is **visible**
384
+ — Markdown does not decode entities there, and this repository documents
385
+ `attest status <change>` in a statement. The scan therefore tracks spans and
386
+ leaves their contents alone, conservatively: a backtick run opens a span only
387
+ if a run of exactly that length closes it, and an unmatched run is text with
388
+ the defence still applied after it.
389
+ *One rendering change beyond the fix, and it is a repair.* A param
390
+ substituted inside the author's own code span was emitted as `**value**`,
391
+ which in a code span is two asterisks around the value; knowing the position
392
+ is what removes them. `SPEC.md` in this repository moves by 12 lines, all of
393
+ that shape — `` `**changes/<name>/first-run.json**` `` becomes
394
+ `` `changes/<name>/first-run.json` `` — and no `&lt;` appears anywhere in it,
395
+ which is the measurement that the span exception works on real prose.
396
+ *The defence had the defect it was written next to, and `ATX-59` gained a
397
+ third shape because of it.* The first code-span scan searched forward from
398
+ each backtick run for a run of the same length, so a rationale whose runs are
399
+ all of *different* lengths paid a scan to the end of the string for every one
400
+ of them: 500 KB cost 1.6 s and 2 MB cost 15 s, from `attest render` with no
401
+ flag, on text the registry chooses — the same "a registry must not decide what
402
+ rendering costs" defect the padded statement and the backtick-run param
403
+ already stand for. The runs are now read once and indexed by length, each
404
+ length holding a forward-only cursor, which is linear: 40 ms at 2 MB. It is
405
+ filed under `ATX-59` rather than here because the obligation is that one, and
406
+ it is worth saying where the site came from — the *defence another requirement
407
+ added*, which is the direction the next one will arrive from too.
408
+ *This leaves `Planned`.* The forged section was reproduced first, then the
409
+ four scenarios were run against the pre-fix renderer: three went red, and the
410
+ fourth — the setext underline, which heads the line *above* it — went green,
411
+ because counting `##` lines cannot see a heading that has no `##` in it. That
412
+ scenario was rewritten to assert the neutralised underline instead. A scenario
413
+ that cannot fail is the defect `archive` exists to catch, and it was caught
414
+ here only by running it against the old code.
415
+
416
+ - **`attest init` no longer follows a symbolic link out of the project, and
417
+ refuses the whole run when a destination leads there.** `runInit` did
418
+ `join(root, target.file)`, `mkdir -p` and then the write, and nothing asked
419
+ whether the path was still under `root` once the filesystem had had its say.
420
+ A `.claude/skills/attest` planted in a repository as a link to anywhere the
421
+ user can write was followed by both: measured on Windows with a junction, a
422
+ file at the far end holding `USER OWNED FILE - DO NOT OVERWRITE` came back
423
+ holding the skill, and the report printed the relative path, so nothing on
424
+ screen said where it had gone. Every destination is now resolved segment by
425
+ segment before the first byte of any of them is written, and one that escapes
426
+ is an `unsafe-target-path` ERROR that writes nothing at all (`ATX-66`).
427
+ *What the exposure was, stated precisely, because two nearby things are not
428
+ it.* `writeAtomic`'s exclusive creation is not the gap and was never scoped to
429
+ be one: it defends the *temporary* path against a planted link, and it does
430
+ that perfectly in whichever directory the destination turns out to be. And
431
+ `workflowBody()` is a pure function of nothing, so no byte of the checked
432
+ project reaches the file — there is nothing to exfiltrate. What was exposed is
433
+ the **path**, which is `targets.ts`'s first scoping rule ("Attest owns the
434
+ path") having been an assumption rather than a check.
435
+ *Why the check is a resolution rather than a comparison.* `join` normalises
436
+ `..`, so a lexical containment test was already satisfied — the escape is a
437
+ segment that *is* a link, which only `realpath` can see. Containment itself
438
+ goes through `isInside` in `src/core/paths.ts`, asked as a relative path
439
+ rather than a string prefix: `/repo-backup` starts with `/repo`, and on
440
+ Windows a path on another drive has no relative spelling at all, so
441
+ `path.relative` answers with an absolute one instead of a chain of `..`. Both
442
+ read as contained under `startsWith`.
443
+ *The limit, recorded rather than papered over.* A link planted between the
444
+ resolution and the write is not caught, and no check outside the filesystem
445
+ can catch it. The exposure this closes is one committed to a repository, which
446
+ is the one a fresh clone and every merge-request checkout hands you.
447
+ *Behaviour change:* an `init` that used to write through such a link now
448
+ fails with exit 1. The only input that reaches it is a path that was already
449
+ leaving the project. The JSON surface is unchanged beyond one more `code`
450
+ value, which is additive — no `SCHEMA_VERSION` bump.
451
+ *This leaves `Planned`*, where it was filed with the measurement above; the
452
+ first act of building it was re-running that measurement, and it reproduced.
453
+
454
+ ### Changed
455
+
456
+ - **The guidance now draws the line between a literal that is drift and a literal
457
+ that is a pin — the shipped text forbade the only defence against a shortened
458
+ domain.** `[0.7.2]` fixed the repo half: every param a scenario iterates gained
459
+ a literal pinning its extent, after three of that release's four green
460
+ mutations turned out to be a domain silently losing cases rather than the
461
+ tautology the experiment was looking for. The user-facing half was held back,
462
+ and it was not merely absent. The `init` skill's "Four things you must not do"
463
+ says **"Do not hardcode a value that lives in `params`"**, and the pin every one
464
+ of those fixes adds is, read literally, exactly that. An adopter who writes a
465
+ roster param — which the design actively pushes them toward, since a quantifier
466
+ a scenario can iterate is the shape `QUANTIFIED` recommends — was being handed
467
+ an instruction that forbids the only thing standing between them and a run that
468
+ covers less every time someone edits the list.
469
+ *The distinction is real and one sentence long*, and it is now drawn in all four
470
+ places the claim is made: the rule is about the **expectation** the system is
471
+ measured against, and the pin asserts what the **intent** is. A list a scenario
472
+ loops over is not a value under test — it is the set of cases the run covers,
473
+ so dropping a member removes a case while every assertion inside still passes,
474
+ with the expectation as independent as it ever was. That is why the `[0.5.0]`
475
+ rule (*the expectation must not be a function of the param the code under test
476
+ consumed*) does not reach it, and why a literal is the **only** independent term
477
+ available: anything derived from the list shrinks with it, so a length assertion
478
+ written as a function of the list is true at every length.
479
+ *Where it landed, and why it is four documents rather than one line appended.*
480
+ README §"killer move", the `init` skill (a sibling bullet in the model section
481
+ and a carve-out inside rule 3 itself, where the contradiction was), the
482
+ `possible-drift` troubleshooting section with a worked ✓/✗ pair, and design §11
483
+ as a residual weakness beside the tautology bullet it shares a gap with — both
484
+ languages. `[0.5.0]` records the reason this shape of change is done in one
485
+ deliberate pass rather than incrementally: it is the central claim of the tool
486
+ being reworded, and the last time it was split the halves disagreed.
487
+ *No engine change, and none is proposed here.* Nothing in this is detectable —
488
+ which surface a statement claims is prose judgement, the same judgement §11
489
+ already records as the reason obligation counting can never be an engine rule.
490
+ The counter-pressure that exists is a hand-judged table, and §11 now says so,
491
+ including what it cannot reach: a domain whose size is carried by a scalar.
492
+ *This leaves `Planned`*, where it was filed with the measurement above; the
493
+ measurement was already taken, and what it needed was the pass.
494
+
495
+ - **The release publishes through `pnpm` again, and the check that read the
496
+ attestation back is gone.** Two corrections to the provenance job shipped in
497
+ `[0.7.2]`, both from what the first real tag pipeline showed.
498
+ *`pnpm publish --provenance` works on the pinned 10.28.0; the claim that it
499
+ did not was read off `pnpm publish --help`, where the flag is absent.* The
500
+ parser knows it — `--provenanceX` is rejected with *"Did you mean
501
+ 'provenance'?"*, and `--provenance --dry-run` runs clean. A help text is a
502
+ document, not the interface; checking a flag means asking the parser. So the
503
+ job is pnpm like every other job here, `--no-git-checks` comes back with it
504
+ (pnpm inspects the branch, CI builds tags in detached HEAD), and
505
+ `COREPACK_ENABLE_STRICT=0` goes away, since it existed only so a bare `npm`
506
+ could run under a `packageManager` that pins pnpm.
507
+ *The post-publish check failed the 0.7.2 release, and the publish it failed
508
+ was correctly signed.* Six attempts over a minute all got a 404 from the
509
+ registry's attestation endpoint; the same endpoint now serves
510
+ `slsa.dev/provenance/v0.2` for `0.7.2`. Lengthening the budget was the wrong
511
+ repair, and the entry it replaces already contains the reason without drawing
512
+ the conclusion: the check runs *after* an irreversible step, so its red cannot
513
+ be cleared — the republish that would rerun it fails `EPUBLISHCONFLICT` on a
514
+ version that now exists. Every failure it can reach is therefore either a
515
+ false one or an unfixable one, on a job nobody can make green again, which
516
+ makes it a machine for teaching that red is routine — the exact habit the
517
+ `audit` job's comment refuses. A gate has to sit where the answer can still
518
+ change what happens. Verification moves to where it can: `npm audit
519
+ signatures` in an installing project, or the package page's Provenance panel.
520
+ *What is unchanged:* the attestation itself, the three preconditions, and the
521
+ fact that npm rejects the publish rather than degrading to an unsigned one if
522
+ any is missing. `0.7.2` is signed, and shipped signed, despite the red job.
523
+
16
524
  ## [0.7.2] - 2026-08-07
17
525
 
18
526
  ### Added
@@ -3365,91 +3873,17 @@ about itself, which is three things.
3365
3873
  **An entry can sit here for releases on the strength of a figure nobody has
3366
3874
  checked.** The discipline aims at keeping the *argument*, not at re-testing the
3367
3875
  evidence under it, so the first act of building one is re-measuring what it
3368
- claims rather than implementing what it proposes. That has paid twice: once
3369
- killing an entry outright, when the spans it rested on turned out not to be
3370
- spans; once correcting an entry's headline number before a line of it was
3371
- written. **What a release costs its adopters is not known on the day it is cut**
3876
+ claims rather than implementing what it proposes. That has paid three times:
3877
+ once killing an entry outright, when the spans it rested on turned out not to be
3878
+ spans; twice correcting an entry's own numbers before a line of it was written —
3879
+ a call-site count that a regex had inflated, and a cost filed as "a fixture per
3880
+ diagnostic" that the corpus put at one fixture. **What a release costs its adopters is not known on the day it is cut**
3372
3881
  — several entries arrived from one adoption report, about shapes the tool made
3373
3882
  someone work around without ever erroring. And **the fixes with no observable
3374
3883
  symptom are worth batching**: individually none justifies a commit, together they
3375
3884
  cost about an hour, and they are the group no gate could have found, since having
3376
3885
  no symptom is the definition of what a gate cannot see.
3377
3886
 
3378
- - **Telling adopters about the domain trap, which the shipped guidance
3379
- currently walks them into.** The repo half landed under `[Unreleased]`; the
3380
- user-facing half has not, and it is not merely absent. `skill.ts`'s "Four
3381
- things you must not do" says **"Do not hardcode a value that lives in
3382
- `params`"**, and the pin every fix in that entry adds is, read literally,
3383
- exactly that. An adopter who writes a roster param — which the design
3384
- actively pushes them toward, since a quantifier a scenario can iterate is the
3385
- shape `QUANTIFIED` recommends — gets an instruction that forbids the only
3386
- defence against shortening it. The distinction is real and one sentence long:
3387
- the rule is about the **expectation** the system is measured against, and the
3388
- pin asserts what the **intent** is. Nothing shipped draws it.
3389
- *Why it is filed rather than done in the same pass.* It reaches README §"killer
3390
- move", the `init` skill, `possible-drift`'s troubleshooting section and design
3391
- §11, in both languages — the same surface `[0.5.0]` rewrote, and that entry
3392
- records the lesson: rewording the central claim of the tool is not a line
3393
- appended, and it was held back once precisely so it could be done deliberately
3394
- and in one pass. The measurement it needs is already taken; what it needs next
3395
- is the pass. *Not* a `possible-drift` change: nothing here is detectable by
3396
- the engine, for the reason the entry below gives about prose judgement.
3397
-
3398
- - **Binding the `✗` samples to fixtures whose rejection is asserted.** A sample
3399
- showing input the engine must *reject* can be bound the way a runnable one is,
3400
- to a fixture whose rejection is the thing under test — which is what
3401
- `fixtures/eval-invalid-registry` already is. This is the more valuable half of
3402
- what was once one entry with the region-marking below, because a counterexample
3403
- that quietly stopped being a counterexample is the one kind of stale sample
3404
- that actively teaches the wrong thing: a reader shown "this fails" about input
3405
- the engine now accepts is worse off than one shown nothing. It is also the
3406
- expensive half, needing a fixture per diagnostic, which is why it was never the
3407
- free part of that entry and is now the only part left in it.
3408
- *The mirror rule caps what can be claimed on the Chinese side.* `docs/zh/**`
3409
- matches `docs/en/**` down to the comments inside its code samples, so
3410
- byte-equality holds on one side only and the zh blocks stay translated prose
3411
- around the same code — the parity gate in `[0.4.3]` covers them, and it covers
3412
- structure, not meaning. The residual risk recorded with the whole-file half in
3413
- `[0.4.3]` — that the gate and the run proving the fixture still works live in
3414
- different suites — applies here identically.
3415
-
3416
- - **`render`'s document can carry raw HTML and a forged requirement section.**
3417
- `sanitised` is the entry §9.1 names and it strips control characters and
3418
- nothing else, so `statement` and `rationale` reach the Markdown by
3419
- concatenation exactly as written. Measured: a `rationale` containing newlines
3420
- and `## ATX-999` renders a whole section — heading, statement, `**Why:**` —
3421
- for a requirement no registry holds, sitting between two real ones and worded
3422
- identically; `<script>` and `<img onerror>` survive the same way. `check` and
3423
- `cover` see only the real id, and `render --check` cannot object, because that
3424
- *is* what the registry renders to now.
3425
- *Not an accepted risk, which is the part worth pinning.* §9.1 and the
3426
- reference both scope their guarantee to control characters, and the
3427
- reference's heading over that paragraph is "Safe to read, and safe to keep",
3428
- with "a site generator" named in the threat it describes. What *is* a decision
3429
- is that prose may be marked up — `plain()` escapes a param value precisely
3430
- because it is data while the statement around it is not — and that decision
3431
- has never been separated from allowing raw HTML or a heading. So the entry is
3432
- as much about stating which of the three is intended as about the escaping,
3433
- and if the answer is that Markdown stays and HTML and headings go, that is a
3434
- fourth obligation in §9.1 rather than a patch at an emitter.
3435
-
3436
- - **`attest init` follows a symlink out of the project root.** `runInit` does
3437
- `mkdir(dirname(dest), { recursive: true })` and then `writeAtomic`, and
3438
- neither asks whether `dest` is still under `root`. A `.claude/skills/attest`
3439
- planted in the repository as a link to anywhere the user can write is followed
3440
- by both: measured on Windows with a junction, a file at the target holding
3441
- `USER OWNED FILE - DO NOT OVERWRITE` came back holding the skill. The report
3442
- prints the relative path, so nothing on screen says where it went.
3443
- *`write.ts` is not the gap and neither is `targets.ts`'s content.* The `wx`
3444
- flag defends the *temporary* path against a planted link and does exactly
3445
- that; the destination's directory was never in its scope. And `workflowBody()`
3446
- is a pure function of nothing, so no byte of the checked project reaches the
3447
- file — the exposure is the path, not the content, which is `targets.ts`'s
3448
- first rule ("Attest owns the path") being an assumption rather than a check.
3449
- The check belongs beside `resolveTargets`, which is already all-or-nothing for
3450
- the same reason: a name known to be unwritable must not leave half a set
3451
- behind.
3452
-
3453
3887
  ## Under consideration
3454
3888
 
3455
3889
  Candidates with **no decision yet**, between the two sections either side of it
@@ -3463,7 +3897,7 @@ decision, because that is what a re-proposal a year from now would otherwise
3463
3897
  have to gather again.
3464
3898
 
3465
3899
  - **Trusted publishing, which would delete `NPM_TOKEN` rather than add to it.**
3466
- The provenance work in `[Unreleased]` signs the release but does not change
3900
+ The provenance work in `[0.7.2]` signs the release but does not change
3467
3901
  what authorises it: a long-lived automation token, masked and protected, that
3468
3902
  publishes as us to anyone holding it. npm's trusted publishing exchanges the
3469
3903
  same OIDC identity the attestation already uses for a short-lived credential,
@@ -3481,41 +3915,6 @@ have to gather again.
3481
3915
  npm's current trusted-publisher documentation for GitLab support before
3482
3916
  writing any YAML.
3483
3917
 
3484
- - **A static sweep for the two properties claimed over the whole of `src/`.**
3485
- The quantifier table's first strict read left three rows wanting the same
3486
- thing, and the repo already owns the idiom: `tests/import-boundary.spec.ts` is
3487
- a sweep over the import graph asserting a cross-cutting property no runtime
3488
- test can see, written for the same reason — "only the graph shows it".
3489
- *The two candidates, and they are not equally shippable.* `ATX-15` is the easy
3490
- one: **no `.sort` outside `byCodeUnit`, and no `localeCompare`.** Measured over
3491
- `src/` today — 21 `.sort` call sites, every one reaching `byCodeUnit`, but only
3492
- 19 naming it on the call line: `render.ts` goes through its own `compareIds`,
3493
- and `merge.ts` through a comparator that spans two lines. The only four
3494
- occurrences of `localeCompare` are comments explaining why it is not used. A sweep would have **zero false positives on the current corpus**,
3495
- which is the bar `divergent-param` failed and `compound-requirement` cleared —
3496
- and those two exceptions are already the warning that this needs the AST rather
3497
- than a line-wise regex, plus a rule for a comparator that delegates. `ATX-37` is the hard one: what it needs is "every project-derived
3498
- string interpolated into a terminal line goes through `inline`", and that is
3499
- not a token scan. The shape that would work is a scenario driving every
3500
- exported `format*` in `src/cli/report.ts` with hostile input — a real roster,
3501
- since the module's exports are what a new formatter joins — but each takes a
3502
- different argument type, so it needs a hostile fixture per signature rather
3503
- than one loop.
3504
- *What blocks the decision.* Two things, one per candidate. The `ATX-15` sweep
3505
- is cheap and precise and would be **the first rule in this repository that
3506
- polices how `src/` is written rather than what it does** — a lint rule wearing
3507
- a scenario's clothes, and the question is whether that belongs in `self/` (as
3508
- an obligation `ATX-15` already carries) or in `tests/` beside the import
3509
- boundary. Filing it under `ATX-15` makes it a permanent obligation with a
3510
- scenario; filing it in `tests/` makes it deletable in a green diff, which
3511
- `ATX-57`'s rationale argues against in as many words. The `ATX-37` roster is
3512
- blocked on cost rather than principle: eight formatters, eight fixtures, and
3513
- the honest count of what it buys is three currently-correct sites.
3514
- *Why neither is urgent.* Both properties hold everywhere today, verified by
3515
- reading. What is missing is not correctness but the thing that keeps it — and
3516
- the measurement above is what a re-proposal would otherwise have to gather
3517
- again.
3518
-
3519
3918
  - **Bounding the fan-out in `findFiles` and `parseSpecs`.** Both walk the
3520
3919
  project with no concurrency limit: `findFiles` recurses through
3521
3920
  `Promise.all(subdirs.map(walk))`, so every directory in the tree is opened at
@@ -3751,17 +4150,34 @@ have to gather again.
3751
4150
  weaker signal "some registry file failed to load, so suppress all
3752
4151
  `orphan-test`" is the other candidate and is a strictly worse `check` on a
3753
4152
  multi-file registry, where the other files are fine and their orphans are real.
3754
- **The wrong *advice* has been separated out and fixed; the noise has not.**
3755
- `[0.7.0]` changes `orphan-test`'s fix hint when any registry file failed
3756
- to load, so the loudest line in that report stops telling the reader to add an
3757
- id that already exists. That needed no mapping, because it claims nothing about
3758
- *which* orphans came from the broken file only that the command cannot tell,
3759
- and what order to work in. It is worth separating because the two halves were
3760
- filed as one problem and are not: **being wrong** was fixable today at the cost
3761
- of a sentence, and **being loud** is the part still blocked on the mapping
3762
- above. The entry stays open on exactly that, with one blocker retired: the
3763
- measured six derived findings from one cause are still six, and only one of
3764
- them has stopped pointing at work that must not be done.
4153
+ **The wrong *advice* was separated out and fixed in `[0.7.0]`; the noise is
4154
+ fixed under `[Unreleased]`, and the blocker above was the wrong question.**
4155
+ The mapping this entry called for could not be recovered, and did not have to
4156
+ be: what a suppression needs is not the broken file's *prefix* but the ids it
4157
+ declares, and those are written in its source. Reading them back is a source
4158
+ read that returns ids and builds nothing, so the failure modes that matter
4159
+ a truncated file, a missing default export, a non-literal value, a
4160
+ schema-invalid entry, a module that throws at import all give them up. The
4161
+ six derived findings from one cause are now two ERRORs and one WARNING, and
4162
+ the collapse is per id rather than per prefix, which is strictly more precise
4163
+ than what was blocked: a genuine typo sharing a prefix with the broken file
4164
+ stays reported. **The lesson is the entry, not the fix.** It sat here for two
4165
+ releases behind a blocker stated as "the mapping is unavailable", which was
4166
+ true and was about a mapping nothing needed; the evidence it carried was about
4167
+ the right problem and the mechanism it proposed was not, and nothing rereads a
4168
+ blocker that has already been written down. That is the same failure the
4169
+ section's own preamble records about a figure nobody re-checks, one level up:
4170
+ the *shape* of the proposal goes stale as quietly as its numbers do.
4171
+ *What is left of this entry is only the original question* — whether `check`
4172
+ and `verify` should refuse a half-loaded registry outright, the way `cover`
4173
+ and `render` do — **and the evidence that motivated it has been spent.** The
4174
+ argument for refusing was never the load failure itself; it was the wall of
4175
+ derived findings burying it, and that wall is gone. What remains is the
4176
+ breadth argument recorded above, which points the other way for both commands.
4177
+ It stays here rather than moving down because "the reason to do it was
4178
+ removed" is not the same as "it was decided against", and the case would come
4179
+ back the moment another finding is found to scale with a broken registry
4180
+ rather than with the number of problems.
3765
4181
 
3766
4182
  - **A statement that quantifies over a surface, with scenarios covering only the
3767
4183
  part that existed when it was written.** Found by reading, and it had already
@@ -3810,7 +4226,7 @@ have to gather again.
3810
4226
  agreed to maintain it.
3811
4227
 
3812
4228
  - **Whether a requirement can ever be retired, and what it costs that none can.**
3813
- The registry is at 62 requirements and 160 scenarios, roughly 2.6 each, and
4229
+ The registry is at **69 requirements and 192 scenarios, 2.78 each**, and
3814
4230
  every one is a permanent obligation: `uncovered-requirement` is an ERROR, so a
3815
4231
  requirement without a scenario cannot exist, and nothing ever removes one.
3816
4232
  `delta.removed` exists and `applyDelta` applies it, but `--apply` refuses to
@@ -3832,45 +4248,29 @@ have to gather again.
3832
4248
  requirement, where the scenario should move rather than go. Only the second is
3833
4249
  really about cost, and it is the one where deleting the intent to save the
3834
4250
  scenario inverts what the whole tool claims — that intent outlives its tests.
3835
- Nothing here is urgent while the number is 61. It is filed now because the
3836
- moment to write the argument down is while the reasoning is available, and
3837
- because a project whose requirement count only rises should have decided in
3838
- advance what would make one leave.
3839
-
3840
- - **The `cmd.exe` quoting in `tests/consumer.spec.ts` guards one character of
3841
- several.** `shellArg` asserts the argument holds no `"` and then quotes only
3842
- when it holds whitespace, so an argument with none goes to the shell bare —
3843
- and `&`, `|`, `^`, `<`, `>`, `(` are metacharacters there that the assertion
3844
- does not name. The fix is one character class, which is why the entry is not
3845
- about the fix.
3846
- *What blocks the decision is that no input reaches it.* Both arguments are
3847
- either a literal flag or a path from `mkdtemp`, `NEEDS_SHELL` is win32 only
3848
- and CI is Linux, and the one path an attacker could plausibly influence — the
3849
- checkout directory on a fork MR pipeline comes from a GitLab project path,
3850
- whose character set excludes every metacharacter above. So this is an
3851
- observation with no attack path, and the honest question is not whether to
3852
- widen the class but whether the comment above it should stop claiming more
3853
- than the assertion covers: it says "the assertion keeps that true" about
3854
- arguments generally, where what it checks is one character. Widening the class
3855
- and leaving the prose is the change that would look like a fix and settle
3856
- nothing.
3857
-
3858
- - **A loader that fails to start leaves its scratch directory behind.**
3859
- `createLoader` calls `mkdtempSync` and writes the stub *before* `createServer`,
3860
- and the only `rm` of that directory is inside the `close` of the object
3861
- `createServer` returns — so a throw from it leaks one `attest-loader-*` per
3862
- invocation, which on a CI runner is one per build. That is the exact failure
3863
- shape the comment on `close` records as already fixed, surviving on the other
3864
- path.
3865
- *What blocks it is that nothing has been made to throw there.* With
3866
- `configFile: false` Vite still reads the `package.json` at the root to decide
3867
- `type`, so a malformed one in the checked project is the obvious candidate and
3868
- it is the project's to write — but it was not tried, and an entry claiming a
3869
- trigger it has not seen is the thing this section exists to not accumulate.
3870
- The repair is three lines (a `try` around the two calls that `rm`s and
3871
- rethrows) and is not what the decision turns on; measuring whether the throw
3872
- is reachable at all is, because if it is not, this is a leak with no input
3873
- that produces it.
4251
+ Nothing here is urgent at 69. It is filed now because the moment to write the
4252
+ argument down is while the reasoning is available, and because a project whose
4253
+ requirement count only rises should have decided in advance what would make
4254
+ one leave.
4255
+ *The figures above were restated on 2026-08-08, and how they were wrong is
4256
+ itself the section's own warning happening.* As filed on 2026-08-03 this entry
4257
+ said "62 requirements and 160 scenarios, roughly 2.6 each" while closing on
4258
+ "the number is 61" an inconsistency inside one entry, and the closing half
4259
+ was the right one. Re-measured at the entry's own commit with the current
4260
+ parser: **61 requirements, 156 scenarios**, so both opening figures were wrong
4261
+ on the day they were written, and only the ratio survived. Measured the same
4262
+ way today it is 69 and 192, and the method is the same one `verify self`
4263
+ prints the control is that today's `self/` counted this way returns exactly
4264
+ what the runner does, which is what makes the 2026-08-03 number trustworthy. The
4265
+ cheap way `grep -c 'scenario('`answers 211 against today's true 192, and
4266
+ is what would have been reached for: comments and prose count, which is the
4267
+ `ATX-15` error again.
4268
+ **What moved in five days sharpens the argument rather than dating it.** +8
4269
+ requirements and +36 scenarios, concentrated in three sessions rather than
4270
+ accrued at a rate, so nothing here is a trend line. The part worth keeping is
4271
+ the ratio: 2.56 2.78. The cost of a requirement is not flat and it is not
4272
+ falling, which is the premise the whole entry rests on, and it is the first
4273
+ time this file has had two measurements of it to compare.
3874
4274
 
3875
4275
  ## Considered and rejected
3876
4276
 
@@ -4112,6 +4512,46 @@ skill file. A pointer is cheap enough to be resident, it is their edit in their
4112
4512
  file, and it cannot go stale — the file it names is the one `init` keeps
4113
4513
  current.
4114
4514
 
4515
+ ### A sweep driving every exported `format*` with hostile input
4516
+
4517
+ The mechanism `ATX-37` sat under `Under consideration` waiting for, kept there
4518
+ on cost, and killed by measuring that cost rather than by paying it. The three
4519
+ sites it was aimed at are closed under `[Unreleased]` by hand-written scenarios;
4520
+ what is rejected is the roster, and the argument is that **`src/cli/report.ts`'s
4521
+ exports are the wrong roster in both directions at once.**
4522
+
4523
+ *Too wide.* Three of the eight exports cannot carry project-derived text at all:
4524
+ `summarize` and `formatScope` take `VerifyCounts`, which is four numbers, and
4525
+ `formatRepeats` prints a count, a `Level` and an `IssueCode` — both fixed
4526
+ rosters. A hostile fixture for those has nothing hostile to put in it, so a loop
4527
+ over the exports either forces three members that **cannot go red**, which is
4528
+ the defect this project ships a gate against, or carries an exclusion list —
4529
+ and an exclusion list is precisely what `ATX-15`'s sweep avoided by making the
4530
+ roster one the code owns.
4531
+
4532
+ *Too narrow.* The statement is over everything the CLI writes to a terminal, and
4533
+ every site that has escaped it so far was outside this module: the `status`
4534
+ header was built and printed in `cli/index.ts`, the loader's writes are in
4535
+ `core/loader.ts`, and the `render` document that `ATX-58` was carved out for is
4536
+ in `core/render.ts`. A roster of formatters is neither necessary nor sufficient
4537
+ for the sentence, and the reading that made it look sufficient — that the
4538
+ formatters are the whole surface — is the exact assumption the `status` header
4539
+ falsified when it was found.
4540
+
4541
+ *And the general form is the §0 wall.* "Every project-derived string
4542
+ interpolated into a terminal line goes through `inline`" needs to know which
4543
+ values are project-derived, which is prose judgement over a data flow. That is
4544
+ the ground truth `tests/intent-rule-candidates.spec.ts` records as the reason
4545
+ none of this can ever be an engine rule, and it does not become computable by
4546
+ being asked about this repository instead of someone else's.
4547
+
4548
+ *What keeps the property instead* is the `QUANTIFIED` row itself — hand-judged,
4549
+ one entry per site, every named scenario asserted to exist. That is a
4550
+ counter-pressure rather than a detector, it holds for one registry whose authors
4551
+ agreed to maintain it, and after this change exactly one of its `ATX-37` sites
4552
+ is open. This reopens only if a site escapes that a roster of formatters would
4553
+ have caught, which none of the four so far would have been.
4554
+
4115
4555
  ### A Codex target for `attest init`
4116
4556
 
4117
4557
  Evaluated when `init` grew `--target` and shipped for Claude Code, Cursor and
@@ -4196,6 +4636,8 @@ requirement, and human review at propose is still the whole answer. What is
4196
4636
  rejected is grouping as a way to assist it, and this reopens only on a
4197
4637
  contradiction that grouping would have caught.
4198
4638
 
4639
+ [0.7.4]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.4
4640
+ [0.7.3]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.3
4199
4641
  [0.7.2]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.2
4200
4642
  [0.7.1]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.1
4201
4643
  [0.7.0]: https://gitlab.com/Pseudorca/attest/-/tags/v0.7.0